diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9cd86e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,306 @@ +name: CI Pipeline + +on: + push: + branches: [ main, master, develop, 'feature/**' ] + tags: [ 'v*' ] + pull_request: + branches: [ main, master, develop ] + +env: + GO_VERSION: 1.22 + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # Code quality and security checks + quality: + name: Code Quality & Security + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Clean Go module cache directory + run: | + rm -rf ~/.cache/go-build || true + rm -rf ~/go/pkg/mod || true + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: make deps + + - name: Format check + run: | + make fmt + if [ -n "$(git status --porcelain)" ]; then + echo "Code is not formatted properly" + git diff + exit 1 + fi + + - name: Build frontend assets + run: make build-frontend + + - name: Install golangci-lint v2 + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.4.0 + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Lint + run: golangci-lint run --timeout=10m + + - name: Security check with gosec + run: | + # Install gosec + go install github.com/securego/gosec/v2/cmd/gosec@latest + + # Run gosec (allow it to fail) + echo "Running gosec security scan..." + gosec ./... || true + + echo "Gosec scan completed" + + - name: Verify Go modules + run: go mod verify + + # Comprehensive testing + test: + name: Test Suite + runs-on: ubuntu-latest + needs: quality + strategy: + matrix: + go-version: [1.22] + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: ci_test_password_postgres + POSTGRES_USER: ci_test_user + POSTGRES_DB: ci_test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: ci_test_root_password_mysql + MYSQL_DATABASE: ci_test_db + MYSQL_USER: ci_test_user + MYSQL_PASSWORD: ci_test_password_mysql + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + ports: + - 3306:3306 + + redis: + image: redis:7 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Clean Go module cache directory + run: | + rm -rf ~/.cache/go-build || true + rm -rf ~/go/pkg/mod || true + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }} + + - name: Install dependencies + run: make deps + + - name: Run unit tests + run: make test + env: + POSTGRES_URL: postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable + MYSQL_URL: testuser:testpass@tcp(localhost:3306)/testdb + REDIS_URL: redis://localhost:6379 + + - name: Verify plugin functionality + run: | + echo "Testing plugin build and basic functionality..." + make build + echo "Plugin built successfully" + + - name: Run benchmarks + run: make benchmark + + - name: Upload coverage to Codecov + if: matrix.go-version == '1.22' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.out + flags: unittests + name: codecov-umbrella + + # Docker image build and push + docker: + name: Docker Build & Push + runs-on: ubuntu-latest + needs: [quality, test] + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate Docker tag + id: docker-tag + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + TAG="pr-${{ github.event.number }}" + else + TAG="${{ github.ref_name }}" + # Sanitize tag name + TAG=$(echo "$TAG" | sed 's/[^a-zA-Z0-9_.-]/-/g') + fi + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "Generated Docker tag: $TAG" + + - name: Generate lowercase image name + id: image-name + run: | + IMAGE_NAME_LOWER=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') + echo "name=$IMAGE_NAME_LOWER" >> $GITHUB_OUTPUT + echo "Generated lowercase image name: $IMAGE_NAME_LOWER" + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.name }} + tags: | + type=raw,value=${{ steps.docker-tag.outputs.tag }} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=${{ github.sha }} + BUILD_DATE=${{ github.event.head_commit.timestamp }} + + + # Security scanning + security: + name: Security Scan + runs-on: ubuntu-latest + needs: [docker] + if: github.event_name != 'pull_request' && needs.docker.result == 'success' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Generate Docker tag for scanning + id: scan-tag + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + TAG="pr-${{ github.event.number }}" + else + TAG="${{ github.ref_name }}" + # Sanitize tag name + TAG=$(echo "$TAG" | sed 's/[^a-zA-Z0-9_.-]/-/g') + fi + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Generate lowercase image name for scanning + id: scan-image-name + run: | + IMAGE_NAME_LOWER=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') + IMAGE_REF="${{ env.REGISTRY }}/${IMAGE_NAME_LOWER}:${{ steps.scan-tag.outputs.tag }}" + echo "image_ref=$IMAGE_REF" >> $GITHUB_OUTPUT + echo "Scanning Docker image: $IMAGE_REF" + + - name: Wait for image availability + run: | + IMAGE_REF="${{ steps.scan-image-name.outputs.image_ref }}" + echo "Checking if image exists: $IMAGE_REF" + + # Try to pull the image to verify it exists + if docker manifest inspect "$IMAGE_REF" >/dev/null 2>&1; then + echo "✅ Image found: $IMAGE_REF" + echo "SCAN_IMAGE=true" >> $GITHUB_ENV + else + echo "⚠️ Image not found: $IMAGE_REF" + echo "SCAN_IMAGE=false" >> $GITHUB_ENV + echo "Skipping Trivy scan - image not available" + fi + + - name: Run Trivy vulnerability scanner + if: env.SCAN_IMAGE == 'true' + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ steps.scan-image-name.outputs.image_ref }} + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy scan results to GitHub Security tab + if: env.SCAN_IMAGE == 'true' && always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0a26b98 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +env: + GO_VERSION: 1.22 + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # Build and push Docker image with release tags + docker-release: + name: Release Docker Images + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + tag_name: ${{ steps.get_version.outputs.tag_name }} + version: ${{ steps.get_version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version + id: get_version + run: | + echo "tag_name=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=${{ steps.get_version.outputs.version }} + GIT_COMMIT=${{ github.sha }} + BUILD_DATE=${{ github.event.head_commit.timestamp }} + + # Notify on release completion + notify: + name: Notify Release + runs-on: ubuntu-latest + needs: [docker-release] + if: always() + steps: + - name: Notify success + if: needs.docker-release.result == 'success' + run: | + echo "✅ Release ${{ needs.docker-release.outputs.tag_name }} completed successfully!" + # Add notification to Slack, Discord, or other services here + + - name: Notify failure + if: needs.docker-release.result == 'failure' + run: | + echo "❌ Release ${{ needs.docker-release.outputs.tag_name }} failed!" + # Add failure notification here \ No newline at end of file diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..ad271d0 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,35 @@ +name: Go Verify + +on: + push: + branches: [ main, master, develop, 'feature/**' ] + pull_request: + branches: [ main, master, develop, 'feature/**' ] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.22 + + - name: Install golangci-lint v2 + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.4.0 + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Run verification task + run: make verify + + - name: Ensure working tree clean + run: | + if ! git diff --quiet; then + echo "Code formatting introduced changes. Please run 'make verify' locally and commit the results." + git status --short + exit 1 + fi diff --git a/.gitignore b/.gitignore index 9f11b75..1bf5b94 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,179 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +*.prof + +# Dependency directories +vendor/ +node_modules/ + +# Go workspace file +go.work +go.work.sum + +# IDE files +.vscode/ .idea/ +*.swp +*.swo +*~ +*.orig +*.rej + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Desktop.ini + +# Claude Code project files +.claude/ + +# AI integration guide documents (copied to .claude folder) +AI_PLUGIN_INTEGRATION_GUIDE*.md + +# Local configuration files +config.local.yaml +*.local.yaml +config/local/ +config/development/ +config/production/ +!config/config.example.yaml +!config/example.yaml +!config/production.yaml + +# Log files +*.log +logs/ +*.log.* + +# Build artifacts +bin/ +dist/ +build/ +out/ +target/ + +# Temporary files +tmp/ +temp/ +*.tmp +*.temp +*.bak +*.backup + +# AI model files and caches +models/ +*.model +*.cache +*.pkl +*.pickle + +# Environment variables +.env +.env.* +!.env.example + +# Database files +*.db +*.sqlite +*.sqlite3 +*.db-journal + +# Coverage reports +coverage.html +coverage.xml +*.cover +coverage.out +coverage.txt +*.lcov + +# Documentation build output +docs/_build/ +docs/dist/ +site/ +_site/ + +# Plugin binaries +atest-ext-ai +atest-ext-ai.exe +plugins/ + +# Socket files +*.sock +*.pid + +# Project specific exclusions +CLAUDE.md +AI_PLUGIN_DEVELOPMENT.md + +# Test artifacts +testdata/output/ +test-results/ +.nyc_output/ +test_*.html +test_*.go +preview.html +integration.test + +# Docker artifacts +.dockerignore + + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Compiled documentation +*.pdf +*.epub + +# Package files +*.tar +*.tar.gz +*.tar.bz2 +*.tgz +*.zip +*.rar +*.7z + +# Lock files (except package manager locks) +*.lock +!go.sum +!package-lock.json +!yarn.lock +!Pipfile.lock + +# Editor artifacts +.vscode/settings.json +.vscode/tasks.json +.vscode/launch.json +.vscode/extensions.json + +# IDE Configuration files +.claude/ + +# Environment files +.env.local* + +# Task runner cache +.task/ + +# Generated frontend assets (created during npm build) +pkg/plugin/assets/*.css +pkg/plugin/assets/*.js diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..07c2e76 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,47 @@ +version: 2 + +run: + timeout: 10m + +linters: + enable: + # Core linters + - errorlint + - gocritic + - gosec + - govet + - misspell + - revive + - unconvert + - unparam + + # Default enabled (explicit) + - errcheck + - ineffassign + - staticcheck + - unused + +linters-settings: + govet: + enable-all: true + disable: + - shadow + - fieldalignment + + unparam: + check-exported: false + +issues: + exclude-rules: + # Exclude generated files + - path: \.pb\.go$ + linters: + - all + + # Exclude test files + - path: _test\.go + linters: + - gosec + + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..829e8f1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git ca-certificates tzdata nodejs npm + +# Set working directory +WORKDIR /app + +# Copy all source code first (needed for go.mod replace directive) +COPY . . + +# Download dependencies (replace directive requires source to be present) +RUN go mod download + +# Build frontend assets required for Go embed +RUN npm ci --prefix frontend +RUN npm run build --prefix frontend + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w" \ + -o bin/atest-ext-ai \ + ./cmd/atest-ext-ai + +# Final stage +FROM alpine:latest + +# Install ca-certificates for HTTPS requests +RUN apk --no-cache add ca-certificates tzdata + +# Create a non-root user +RUN adduser -D -s /bin/sh appuser + +# Set working directory +WORKDIR /app + +# Copy the binary +COPY --from=builder /app/bin/atest-ext-ai /app/atest-ext-ai + +# Copy config.yaml for default configuration +COPY --from=builder /app/config.yaml /app/config.yaml + +# Change ownership to appuser +RUN chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Set the entrypoint +ENTRYPOINT ["/app/atest-ext-ai"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e2c0c66 --- /dev/null +++ b/Makefile @@ -0,0 +1,140 @@ +.SHELLFLAGS := -o pipefail -c +SHELL := /bin/bash + +.DEFAULT_GOAL := default + +BINARY_NAME ?= atest-ext-ai +BUILD_DIR ?= bin +MAIN_PACKAGE ?= ./cmd/atest-ext-ai +DOCKER_IMAGE ?= atest-ext-ai +DOCKER_REGISTRY ?= ghcr.io/linuxsuren +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS ?= -s -w -X github.com/linuxsuren/atest-ext-ai/pkg/plugin.buildPluginVersion=$(VERSION) +BUILD_BIN := $(BUILD_DIR)/$(BINARY_NAME) + +.PHONY: default build-frontend build test test-watch deps clean install install-local dev fmt lint lint-check vet verify check benchmark docker-build docker-release docker-release-github coverage integration-test help + +FRONTEND_ASSETS := pkg/plugin/assets/ai-chat.js pkg/plugin/assets/ai-chat.css + +default: clean build test ## Clean, build and test + +build-frontend: ## Build frontend assets (Vue 3 + TypeScript) + @[ -d frontend/node_modules ] || (cd frontend && npm install) + cd frontend && npm run build + +build: $(FRONTEND_ASSETS) ## Build the plugin binary + mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(BUILD_BIN) $(MAIN_PACKAGE) + +pkg/plugin/assets/ai-chat.js: build-frontend ; +pkg/plugin/assets/ai-chat.css: build-frontend ; + +test: $(FRONTEND_ASSETS) ## Run tests with coverage + go test -v -race -coverprofile=coverage.out ./... + go tool cover -func=coverage.out | tail -n 1 + +test-watch: ## Run tests in watch mode + while true; do \ + go test -v ./...; \ + inotifywait -r -e modify,create,delete --exclude '\.git' .; \ + done + +deps: ## Install and verify dependencies + go mod tidy + go mod download + go mod verify + +clean: ## Clean build artifacts and caches + rm -rf $(BUILD_DIR) + rm -f coverage.out + rm -f $(FRONTEND_ASSETS) + go clean -cache -testcache -modcache + +install: build ## Install to system location (/usr/local/bin) + sudo cp $(BUILD_BIN) /usr/local/bin/ + sudo chmod +x /usr/local/bin/$(BINARY_NAME) + +install-local: build ## Install to local development directory (~/.config/atest/bin) + mkdir -p ~/.config/atest/bin/ + cp $(BUILD_BIN) ~/.config/atest/bin/ + chmod +x ~/.config/atest/bin/$(BINARY_NAME) + @echo "Local installation completed - ~/.config/atest/bin/$(BINARY_NAME)" + +dev: ## Run in development mode with debug logging + LOG_LEVEL=debug go run $(MAIN_PACKAGE) + +fmt: ## Format Go code + go fmt ./... + gofmt -s -w . + +lint: ## Run golangci-lint + golangci-lint run --fix + +lint-check: ## Run golangci-lint without fixes + golangci-lint run + +vet: ## Run go vet + go vet ./... + +verify: build-frontend ## Format, lint, vet and test Go code + $(MAKE) fmt + $(MAKE) lint-check + $(MAKE) vet + $(MAKE) test + +check: build-frontend ## Run all checks (fmt, vet, lint, test) + $(MAKE) fmt + $(MAKE) vet + $(MAKE) lint-check + $(MAKE) test + +benchmark: $(FRONTEND_ASSETS) ## Run benchmark tests + go test -bench=. -benchmem -run=^$$ ./... + +docker-build: ## Build Docker image + docker build -t $(DOCKER_IMAGE):latest . + +docker-release: docker-build ## Build and push Docker image to registry + docker tag $(DOCKER_IMAGE):latest $(DOCKER_REGISTRY)/$(DOCKER_IMAGE):$(VERSION) + docker tag $(DOCKER_IMAGE):latest $(DOCKER_REGISTRY)/$(DOCKER_IMAGE):latest + docker push $(DOCKER_REGISTRY)/$(DOCKER_IMAGE):$(VERSION) + docker push $(DOCKER_REGISTRY)/$(DOCKER_IMAGE):latest + @echo "Pushed to $(DOCKER_REGISTRY)/$(DOCKER_IMAGE):$(VERSION)" + +docker-release-github: ## Quick release to GitHub Container Registry + $(MAKE) docker-release DOCKER_REGISTRY=ghcr.io/linuxsuren + +coverage: ## Generate and view coverage report + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated - coverage.html" + +integration-test: install-local ## Run integration tests with plugin + @echo "Starting plugin..." + @~/.config/atest/bin/$(BINARY_NAME) & \ + PLUGIN_PID=$$!; \ + sleep 2; \ + OS_NAME=$$(uname -s 2>/dev/null || echo Windows); \ + case "$$OS_NAME" in \ + CYGWIN*|MINGW*|MSYS*|Windows*) \ + echo "Skipping Unix socket check on $$OS_NAME (plugin listens on TCP loopback)"; \ + ;; \ + *) \ + if [ -S /tmp/atest-ext-ai.sock ]; then \ + echo "✅ Socket created successfully"; \ + else \ + echo "❌ Socket not found"; \ + kill $$PLUGIN_PID 2>/dev/null; \ + exit 1; \ + fi; \ + ;; \ + esac; \ + kill $$PLUGIN_PID; \ + case "$$OS_NAME" in \ + CYGWIN*|MINGW*|MSYS*|Windows*) ;; \ + *) rm -f /tmp/atest-ext-ai.sock ;; \ + esac + +help: ## Show available targets + @printf "Available targets:\n" + @awk -F':.*## ' '/^[a-zA-Z0-9_.-]+:.*## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) diff --git a/README.md b/README.md index 4a59718..2bf08ee 100644 --- a/README.md +++ b/README.md @@ -1 +1,33 @@ -# atest-ext-ai +AI plugin for API-Testing[https://github.com/LinuxSuRen/api-testing]. + +## 功能 +It can(now): +1. convert natural language to SQL. +2. generate test examples.(To-do) + +目前支持的ai提供商: +云端:DeepSeek, OpenAI +本地:Ollama的任意模型(实现了本地模型自动发现) + +## 配置方式 + +插件由 API Testing 的 GUI 负责下发所有 AI 服务配置。在桌面端选择提供商、终端地址与模型后,插件会自动加载最新设置并刷新连接状态。无需在终端内设置任何环境变量;如需高级调试,可使用 CLI 环境变量,但未来版本可能移除该能力。 + +### 跨平台监听地址 + +- macOS / Linux 默认仍然使用 `unix:///tmp/atest-ext-ai.sock`,保持原有的安全隔离。 +- Windows 会自动改用本地 TCP 地址 `127.0.0.1:38081`,无需手工处理 Unix 套接字。 +- 如需手动覆盖,可设置: + - `AI_PLUGIN_LISTEN_ADDR`:统一入口,支持 `unix:///path` 或 `tcp://host:port`; + - 或在 Windows 下使用 `AI_PLUGIN_TCP_ADDR`,在类 Unix 系统使用 `AI_PLUGIN_SOCKET_PATH`。 +- 主应用(API Testing)需要读取同样的地址后再去连接,建议在扩展配置里加一个“Windows 默认 TCP”说明。 + +## 开发命令 + +项目通过 `make` 提供常用的开发流程: +- `make build` 编译后端插件 +- `make build-frontend` 构建前端资源 +- `make test` 运行完整测试套件 +- `make install-local` 重新打包并安装插件到 `~/.config/atest/bin` + +使用 `make help` 可以查看全部可用的目标。 diff --git a/cmd/atest-ext-ai/main.go b/cmd/atest-ext-ai/main.go new file mode 100644 index 0000000..daf812f --- /dev/null +++ b/cmd/atest-ext-ai/main.go @@ -0,0 +1,381 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package main starts the atest-ext-ai plugin process and exposes the gRPC socket. +package main + +import ( + "context" + "fmt" + "log" + "net" + "os" + "os/signal" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "syscall" + "time" + + "github.com/linuxsuren/api-testing/pkg/testing/remote" + "github.com/linuxsuren/atest-ext-ai/pkg/plugin" + "google.golang.org/grpc" + "google.golang.org/grpc/peer" +) + +const ( + // SocketFileName is the socket file name for AI plugin + SocketFileName = "atest-ext-ai.sock" + // defaultWindowsTCPAddress is the fallback TCP address for Windows hosts + defaultWindowsTCPAddress = "127.0.0.1:38081" +) + +type listenerConfig struct { + network string + address string + isUnix bool +} + +func (l listenerConfig) URI() string { + switch l.network { + case "unix": + path := l.address + if !strings.HasPrefix(path, "/") { + path = "/" + strings.TrimPrefix(path, "/") + } + return "unix://" + path + default: + return fmt.Sprintf("%s://%s", l.network, l.address) + } +} + +func (l listenerConfig) Display() string { + if l.isUnix { + return l.address + } + return l.address +} + +func main() { + // Configure memory optimization + configureMemorySettings() + + // Setup structured logging + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Printf("=== Starting atest-ext-ai plugin %s ===", plugin.PluginVersion) + log.Printf("Build info: Go version %s, OS %s, Arch %s", runtime.Version(), runtime.GOOS, runtime.GOARCH) + log.Printf("Process PID: %d", os.Getpid()) + + listenCfg := resolveListenerConfig() + log.Printf("Socket configuration: %s (%s)", listenCfg.Display(), listenCfg.network) + + // Clean up any existing socket file + if listenCfg.isUnix { + log.Printf("Step 1/4: Cleaning up any existing socket file...") + if err := cleanupSocketFile(listenCfg.address); err != nil { + log.Fatalf("FATAL: Failed to cleanup existing socket file at %s: %v\nTroubleshooting: Check file permissions and ensure no other process is using the socket", listenCfg.address, err) + } + } else { + log.Printf("Step 1/4: Preparing TCP listener on %s...", listenCfg.address) + } + + // Create listener + log.Printf("Step 2/4: Creating %s listener...", strings.ToUpper(listenCfg.network)) + listener, err := createListener(listenCfg) + if err != nil { + log.Fatalf("FATAL: Failed to create listener at %s: %v\nTroubleshooting: Check address availability, permissions, and security policies", listenCfg.Display(), err) + } + defer func() { + log.Println("Performing cleanup...") + if err := listener.Close(); err != nil { + log.Printf("Warning: Error closing listener: %v", err) + } + if listenCfg.isUnix { + if err := cleanupSocketFile(listenCfg.address); err != nil { + log.Printf("Warning: Error during socket cleanup: %v", err) + } + } + log.Println("Socket cleanup completed") + }() + + // Initialize AI plugin service + log.Printf("Step 3/4: Initializing AI plugin service...") + aiPlugin, err := plugin.NewAIPluginService() + if err != nil { + log.Panicf("FATAL: Failed to initialize AI plugin service: %v\nTroubleshooting: Check configuration file, AI service connectivity, and logs above for details", err) + } + log.Println("✓ AI plugin service initialized successfully") + + // Create gRPC server with enhanced configuration + log.Printf("Step 4/4: Registering gRPC server...") + grpcServer := createGRPCServer() + remote.RegisterLoaderServer(grpcServer, aiPlugin) + log.Println("✓ gRPC server configured with LoaderServer") + + // Handle graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Setup signal handling + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) + + go func() { + sig := <-signalChan + log.Printf("\n=== Received signal: %v, initiating graceful shutdown ===", sig) + + // Shutdown AI plugin first + log.Println("Shutting down AI plugin service...") + aiPlugin.Shutdown() + log.Println("✓ AI plugin service shutdown completed") + + // Stop gRPC server gracefully with timeout + log.Println("Stopping gRPC server...") + done := make(chan struct{}) + go func() { + grpcServer.GracefulStop() + close(done) + }() + + // Force shutdown if graceful shutdown takes too long + select { + case <-done: + log.Println("✓ gRPC server shutdown completed gracefully") + case <-time.After(30 * time.Second): + log.Println("⚠ Forcing gRPC server shutdown due to timeout (30s exceeded)") + grpcServer.Stop() + } + + cancel() + }() + + log.Printf("\n=== Plugin startup completed successfully ===") + log.Printf("Socket endpoint: %s", listenCfg.URI()) + log.Printf("Status: Ready to accept gRPC connections from api-testing") + log.Printf("To test: Use api-testing to connect to %s", listenCfg.URI()) + log.Printf("\n") + + // Start serving + if err := grpcServer.Serve(listener); err != nil { + log.Printf("gRPC server stopped: %v", err) + } + + <-ctx.Done() + log.Println("\n=== AI Plugin shutdown complete ===") + +} + +// resolveListenerConfig determines the appropriate listener settings based on +// environment variables and operating system defaults. +func resolveListenerConfig() listenerConfig { + // Highest priority: explicit listen address (supports tcp:// or unix://) + if raw := os.Getenv("AI_PLUGIN_LISTEN_ADDR"); raw != "" { + if cfg, err := parseListenAddress(raw); err == nil { + log.Printf("Using listener configuration from AI_PLUGIN_LISTEN_ADDR: %s", cfg.URI()) + return cfg + } + log.Printf("Warning: invalid AI_PLUGIN_LISTEN_ADDR value '%s', falling back to OS defaults", raw) + } + + // Windows default: TCP loopback + if runtime.GOOS == "windows" { + address := os.Getenv("AI_PLUGIN_TCP_ADDR") + if address == "" { + address = defaultWindowsTCPAddress + } + log.Printf("Detected Windows platform, using TCP listener at %s", address) + return listenerConfig{ + network: "tcp", + address: address, + isUnix: false, + } + } + + // POSIX default: Unix domain socket + if path := os.Getenv("AI_PLUGIN_SOCKET_PATH"); path != "" { + log.Printf("Using socket path from AI_PLUGIN_SOCKET_PATH: %s", path) + return listenerConfig{ + network: "unix", + address: path, + isUnix: true, + } + } + + socketPath := "/tmp/" + SocketFileName + log.Printf("Using default Unix socket path: %s", socketPath) + return listenerConfig{ + network: "unix", + address: socketPath, + isUnix: true, + } +} + +func parseListenAddress(value string) (listenerConfig, error) { + addr := strings.TrimSpace(value) + if addr == "" { + return listenerConfig{}, fmt.Errorf("empty listen address") + } + + switch { + case strings.HasPrefix(addr, "unix://"): + path := strings.TrimPrefix(addr, "unix://") + if path == "" { + return listenerConfig{}, fmt.Errorf("unix listen address requires a path") + } + return listenerConfig{ + network: "unix", + address: path, + isUnix: true, + }, nil + case strings.HasPrefix(addr, "tcp://"): + target := strings.TrimPrefix(addr, "tcp://") + if target == "" { + return listenerConfig{}, fmt.Errorf("tcp listen address requires host:port") + } + return listenerConfig{ + network: "tcp", + address: target, + isUnix: false, + }, nil + default: + // Infer from simple patterns: path -> unix, host:port -> tcp + if strings.HasPrefix(addr, "/") || strings.Contains(addr, "\\") { + return listenerConfig{ + network: "unix", + address: addr, + isUnix: true, + }, nil + } + if strings.Contains(addr, ":") { + return listenerConfig{ + network: "tcp", + address: addr, + isUnix: false, + }, nil + } + return listenerConfig{}, fmt.Errorf("cannot determine network type from address: %s", addr) + } +} + +// cleanupSocketFile removes existing socket file if it exists +func cleanupSocketFile(path string) error { + if _, err := os.Stat(path); err == nil { + if err := os.Remove(path); err != nil { + return fmt.Errorf("failed to remove existing socket file %s: %w", path, err) + } + log.Printf("Removed existing socket file: %s", path) + } + return nil +} + +// createListener creates either a Unix domain socket listener or a TCP listener +// depending on the provided configuration. +func createListener(cfg listenerConfig) (net.Listener, error) { + if cfg.network == "unix" { + dir := filepath.Dir(cfg.address) + if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:gosec // socket directory must remain accessible to API clients + return nil, fmt.Errorf("failed to create socket directory %s: %w", dir, err) + } + + listener, err := net.Listen("unix", cfg.address) + if err != nil { + return nil, fmt.Errorf("failed to create Unix socket listener: %w", err) + } + + perms := os.FileMode(0666) + if permStr := os.Getenv("SOCKET_PERMISSIONS"); permStr != "" { + var permInt uint32 + if _, err := fmt.Sscanf(permStr, "%o", &permInt); err == nil { + perms = os.FileMode(permInt) + log.Printf("Using custom socket permissions from SOCKET_PERMISSIONS: %04o", perms) + } else { + log.Printf("Warning: invalid SOCKET_PERMISSIONS '%s', using default 0666: %v", permStr, err) + } + } + + if err := os.Chmod(cfg.address, perms); err != nil { //nolint:gosec // G302: Socket permissions configurable via env + _ = listener.Close() + return nil, fmt.Errorf("failed to set socket permissions to %04o: %w", perms, err) + } + + if fileInfo, err := os.Stat(cfg.address); err == nil { + log.Printf("Socket created successfully:") + log.Printf(" Path: %s", cfg.address) + log.Printf(" Permissions: %04o (%s)", fileInfo.Mode().Perm(), fileInfo.Mode().String()) + log.Printf(" Size: %d bytes", fileInfo.Size()) + log.Printf("Troubleshooting tips:") + log.Printf(" - If connection fails with 'permission denied', check:") + log.Printf(" 1. Client process user has read/write access (permissions: %04o)", fileInfo.Mode().Perm()) + log.Printf(" 2. Client process user is in the same group (or use SOCKET_PERMISSIONS=0666)") + log.Printf(" 3. SELinux/AppArmor policies allow socket access") + log.Printf(" - Set SOCKET_PERMISSIONS environment variable to customize (e.g., SOCKET_PERMISSIONS=0666)") + } else { + log.Printf("Warning: could not stat socket file for diagnostics: %v", err) + } + + return listener, nil + } + + listener, err := net.Listen(cfg.network, cfg.address) + if err != nil { + return nil, fmt.Errorf("failed to create %s listener: %w", strings.ToUpper(cfg.network), err) + } + log.Printf("TCP listener created successfully on %s", cfg.address) + return listener, nil +} + +// configureMemorySettings optimizes Go runtime for limited memory environments +func configureMemorySettings() { + // Set aggressive garbage collection for memory-constrained environments + debug.SetGCPercent(50) // More frequent GC cycles + + // Set memory limit from environment variable if available + if memLimit := os.Getenv("GOMEMLIMIT"); memLimit != "" { + log.Printf("Go memory limit set to: %s", memLimit) + } + + // Limit number of OS threads to reduce memory overhead + runtime.GOMAXPROCS(2) // Limit to 2 cores max for CI environments + + log.Printf("Memory optimization configured: GOGC=50, GOMAXPROCS=%d", runtime.GOMAXPROCS(0)) +} + +// createGRPCServer creates a simple gRPC server for compatibility with older clients +func createGRPCServer() *grpc.Server { + // Debug interceptor to log all incoming gRPC calls and connection info + unaryInterceptor := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + log.Printf("🔍 gRPC Call received: %s", info.FullMethod) + + // Log connection info from context + if peer, ok := peer.FromContext(ctx); ok { + log.Printf("🔍 Connection from: %s", peer.Addr) + } + + resp, err := handler(ctx, req) + if err != nil { + log.Printf("🔍 gRPC Call %s failed: %v", info.FullMethod, err) + } else { + log.Printf("🔍 gRPC Call %s succeeded", info.FullMethod) + } + return resp, err + } + + // Use simple gRPC server configuration for maximum compatibility + return grpc.NewServer( + grpc.UnaryInterceptor(unaryInterceptor), + ) +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..c963378 --- /dev/null +++ b/config.yaml @@ -0,0 +1,38 @@ +# atest-ext-ai Minimal Configuration +# This file provides minimal working defaults for quick start + +# Plugin metadata +plugin: + name: atest-ext-ai + version: 1.0.0 + log_level: info + environment: production + +# AI Service Configuration +ai: + default_service: ollama + timeout: 60s + + services: + # Local Ollama (default configuration) + ollama: + enabled: true + provider: ollama + endpoint: http://localhost:11434 + model: qwen2.5-coder:latest + max_tokens: 4096 + timeout: 60s + +# Server configuration +server: + host: 0.0.0.0 + port: 8080 + socket_path: /tmp/atest-ext-ai.sock + # Windows hosts会自动使用本地 TCP 端口,如果需要自定义可修改此项 + listen_address: 127.0.0.1:38081 + +# Logging configuration +logging: + level: info + format: json + output: stdout diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9406be5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4541 @@ +{ + "name": "atest-ai-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "atest-ai-frontend", + "version": "0.1.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "element-plus": "^2.10.4", + "vue": "^3.3.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.2", + "@vitest/coverage-v8": "^1.2.0", + "@vitest/ui": "^1.2.0", + "@vue/test-utils": "^2.4.3", + "@vue/tsconfig": "^0.5.1", + "happy-dom": "^12.10.3", + "jsdom": "^23.2.0", + "typescript": "~5.3.0", + "vite": "^5.0.11", + "vitest": "^1.2.0", + "vue-tsc": "^1.8.27" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", + "integrity": "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^2.3.1", + "is-potential-custom-element-name": "^1.0.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-1.6.1.tgz", + "integrity": "sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "fast-glob": "^3.3.2", + "fflate": "^0.8.1", + "flatted": "^3.2.9", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "sirv": "^2.0.4" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz", + "integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/shared": "3.5.22", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/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/@vue/compiler-dom": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz", + "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz", + "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/compiler-core": "3.5.22", + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.19", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/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/@vue/compiler-ssr": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz", + "integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", + "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", + "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", + "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/runtime-core": "3.5.22", + "@vue/shared": "3.5.22", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", + "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "vue": "3.5.22" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz", + "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/@vue/tsconfig": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.5.1.tgz", + "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/element-plus": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.11.4.tgz", + "integrity": "sha512-sLq+Ypd0cIVilv8wGGMEGvzRVBBsRpJjnAS5PsI/1JU1COZXqzH3N1UYMUc/HCdvdjf6dfrBy80Sj7KcACsT7w==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.1", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.13", + "escape-html": "^1.0.3", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "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==", + "dev": true, + "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/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/happy-dom": { + "version": "12.10.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-12.10.3.tgz", + "integrity": "sha512-JzUXOh0wdNGY54oKng5hliuBkq/+aT1V3YpTM+lrN/GoLQTANZsMaIvmHiHe612rauHvPJnDZkZ+5GZR++1Abg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css.escape": "^1.5.1", + "entities": "^4.5.0", + "iconv-lite": "^0.6.3", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-encoding-sniffer/node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", + "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^2.0.1", + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "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/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "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": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "dev": true, + "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.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", + "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-sfc": "3.5.22", + "@vue/runtime-dom": "3.5.22", + "@vue/server-renderer": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9a86234 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "atest-ai-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" + }, + "dependencies": { + "vue": "^3.3.4", + "element-plus": "^2.10.4", + "@element-plus/icons-vue": "^2.3.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.2", + "@vue/tsconfig": "^0.5.1", + "typescript": "~5.3.0", + "vite": "^5.0.11", + "vue-tsc": "^1.8.27", + "vitest": "^1.2.0", + "@vue/test-utils": "^2.4.3", + "@vitest/ui": "^1.2.0", + "@vitest/coverage-v8": "^1.2.0", + "jsdom": "^23.2.0", + "happy-dom": "^12.10.3" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..45e9b29 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/AIChatHeader.vue b/frontend/src/components/AIChatHeader.vue new file mode 100644 index 0000000..69b009e --- /dev/null +++ b/frontend/src/components/AIChatHeader.vue @@ -0,0 +1,144 @@ + + + + {{ t('ai.title') }} + {{ t('ai.subtitle') }} + + {{ providerLabelText }} + + + {{ statusText }} + + + + + + + + + diff --git a/frontend/src/components/AIChatInput.vue b/frontend/src/components/AIChatInput.vue new file mode 100644 index 0000000..369cbb9 --- /dev/null +++ b/frontend/src/components/AIChatInput.vue @@ -0,0 +1,395 @@ + + + + + + + + {{ statusBanner }} + + {{ t('ai.button.configure') }} + + + + + + + + + + + + + + + diff --git a/frontend/src/components/AIChatMessages.vue b/frontend/src/components/AIChatMessages.vue new file mode 100644 index 0000000..06d9562 --- /dev/null +++ b/frontend/src/components/AIChatMessages.vue @@ -0,0 +1,391 @@ + + + + + + + + {{ t('ai.welcome.startChat') }} + + + + + + + + + + + + + + + {{ message.content }} + + + SQL + + + {{ t('ai.button.copy') }} + + + {{ message.sql }} + + + {{ message.meta.model }} + {{ formatDialect(message.meta.dialect) }} + {{ message.meta.duration }}ms + + + + {{ formatTime(message.timestamp) }} + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/AISettingsPanel.vue b/frontend/src/components/AISettingsPanel.vue new file mode 100644 index 0000000..4770cb7 --- /dev/null +++ b/frontend/src/components/AISettingsPanel.vue @@ -0,0 +1,602 @@ + + + + + + + + + {{ t('ai.settings.localServices') }} + + + + + + + + + + + {{ t('ai.provider.ollama.name') }} + + + {{ t('ai.provider.local') }} + + + + {{ t('ai.provider.ollama.description') }} + + + + + + + + + + + + + + + + {{ model.name }} + {{ model.size }} + + + + + + {{ t('ai.button.refresh') }} + + + + + + + + + + + + + + {{ t('ai.settings.cloudServices') }} + + + + + + + + + + + + + + {{ t('ai.provider.openai.name') }} + + + {{ t('ai.provider.cloud') }} + + + + + {{ t('ai.provider.openai.description') }} + + + + + + + + + + + + + + + {{ model.name }} + {{ model.size }} + + + + + + + + + + + + + + {{ t('ai.button.refresh') }} + + + + + + + + + + + + + + + {{ t('ai.provider.deepseek.name') }} + + + {{ t('ai.provider.cloud') }} + + + + + {{ t('ai.provider.deepseek.description') }} + + + + + + + + + + + + + + + {{ model.name }} + {{ model.size }} + + + + + + + + + + + {{ t('ai.button.refresh') }} + + + + + + + + + + + + + + {{ t('ai.settings.advanced') }} + + + + {{ t('ai.option.includeExplanation') }} + + + + + + + {{ isLocalProvider ? t('ai.settings.timeoutHintLocal') : t('ai.settings.timeoutHintCloud') }} + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/AIWelcomePanel.vue b/frontend/src/components/AIWelcomePanel.vue new file mode 100644 index 0000000..704a5ef --- /dev/null +++ b/frontend/src/components/AIWelcomePanel.vue @@ -0,0 +1,34 @@ + + + + + + + + {{ t('ai.button.configure') }} + + + + + + + + diff --git a/frontend/src/composables/useAIChat.ts b/frontend/src/composables/useAIChat.ts new file mode 100644 index 0000000..243d124 --- /dev/null +++ b/frontend/src/composables/useAIChat.ts @@ -0,0 +1,260 @@ +import { ref, computed, watch } from 'vue' +import type { AppContext, AIConfig, Message, Model, DatabaseDialect } from '@/types' +import { loadConfig, loadConfigForProvider, saveConfig, getMockModels, generateId, type Provider } from '@/utils/config' +import { aiService } from '@/services/aiService' + +/** + * Main composable for AI Chat functionality + * Uses aiService for API calls and manages UI state + */ +export function useAIChat(_context: AppContext) { + // Note: context parameter is kept for future use (e.g., authentication tokens) + + // Configuration management + const config = ref(loadConfig()) + + const resolveProviderKey = (provider: string): Provider => { + if (provider === 'local') { + return 'ollama' + } + return provider as Provider + } + + // Store models separately for each provider to avoid cross-contamination + const modelsByProvider = ref>({ + ollama: [], + openai: [], + deepseek: [] + }) + + // Computed property to get models for current provider + const availableModels = computed(() => { + const key = resolveProviderKey(config.value.provider) + return modelsByProvider.value[key] || [] + }) + + // Check if AI is properly configured + const isConfigured = computed(() => { + const c = config.value + const providerKey = resolveProviderKey(c.provider) + if (providerKey === 'ollama') { + return !!c.endpoint && !!c.model + } + return !!c.apiKey && !!c.model + }) + + // Message management + const messages = ref([]) + const isLoading = ref(false) + + // Watch config changes and auto-save to localStorage + watch(config, (newConfig) => { + saveConfig(newConfig) + }, { deep: true }) + + // Watch provider changes and refresh models + watch(() => config.value.provider, async (newProvider, oldProvider) => { + if (newProvider === oldProvider) { + return + } + + const normalizedProvider = resolveProviderKey(newProvider) + const providerConfig = loadConfigForProvider(normalizedProvider) + + config.value = { + ...config.value, + ...providerConfig, + provider: newProvider, + status: providerConfig.status ?? 'disconnected' + } + + await refreshModels(normalizedProvider) + + // Validate current model selection for new provider + const models = modelsByProvider.value[normalizedProvider] || [] + const currentModel = config.value.model + const modelExists = models.some(m => m.id === currentModel) + + if (!modelExists) { + config.value.model = models.length > 0 ? models[0].id : '' + } + }) + + /** + * Refresh available models for current provider + */ + async function refreshModels(targetProvider?: string) { + const provider = targetProvider ?? config.value.provider + const storeKey = resolveProviderKey(provider) + + try { + // Fetch and store models for this specific provider + const models = await aiService.fetchModels(provider) + modelsByProvider.value[storeKey] = models + + // Auto-select first model if none selected and refreshing active provider + if (storeKey === resolveProviderKey(config.value.provider) && !config.value.model && models.length > 0) { + config.value.model = models[0].id + } + } catch (error) { + console.error('Failed to fetch models:', error) + // Use mock models as fallback for this provider + modelsByProvider.value[storeKey] = getMockModels(storeKey) + } + } + + /** + * Handle query submission + */ + async function handleQuery(prompt: string, options: { includeExplanation: boolean; databaseDialect: DatabaseDialect }) { + console.log('🎯 [useAIChat] handleQuery called', { + prompt, + options, + isConfigured: isConfigured.value, + config: { + provider: config.value.provider, + endpoint: config.value.endpoint, + model: config.value.model, + hasApiKey: !!config.value.apiKey, + timeout: config.value.timeout + } + }) + + // Add user message + const userMsg: Message = { + id: generateId(), + type: 'user', + content: prompt, + timestamp: Date.now() + } + messages.value.push(userMsg) + + // Show loading + isLoading.value = true + config.value.status = 'connecting' + try { + console.log('🚀 [useAIChat] Sending generateSQL request...') + + const response = await aiService.generateSQL({ + provider: config.value.provider, + endpoint: config.value.endpoint, + apiKey: config.value.apiKey, + model: config.value.model, + prompt, + timeout: config.value.timeout, + maxTokens: config.value.maxTokens, + includeExplanation: options.includeExplanation, + databaseDialect: options.databaseDialect ?? config.value.databaseDialect ?? 'mysql' + }) + + console.log('✅ [useAIChat] Received response', { + success: response.success, + hasSql: !!response.sql, + hasError: !!response.error, + meta: response.meta + }) + + if (response.success && response.sql) { + config.value.status = 'connected' + // Add AI response + messages.value.push({ + id: generateId(), + type: 'ai', + content: 'Generated SQL:', + sql: response.sql, + meta: response.meta, + timestamp: Date.now() + }) + } else { + const errorMsg = response.error || 'Failed to generate SQL' + console.error('❌ [useAIChat] Response failed', { + success: response.success, + sql: response.sql, + error: response.error + }) + throw new Error(errorMsg) + } + } catch (error) { + config.value.status = 'disconnected' + console.error('💥 [useAIChat] Exception caught', { + error, + message: (error as Error).message, + stack: (error as Error).stack + }) + + // Add error message + messages.value.push({ + id: generateId(), + type: 'error', + content: `Error: ${(error as Error).message}`, + timestamp: Date.now() + }) + } finally { + isLoading.value = false + } + } + + /** + * Save configuration to backend + */ + async function handleSaveConfig() { + try { + await aiService.saveConfig(config.value) + return { success: true } + } catch (error) { + console.error('Failed to save config to backend:', error) + throw error + } + } + + /** + * Test connection to AI provider + */ + async function handleTestConnection(testConfig?: AIConfig) { + config.value.status = 'connecting' + const payload: AIConfig = { + ...config.value, + ...(testConfig ?? {}) + } + try { + const result = await aiService.testConnection(payload) + config.value.status = result.success ? 'connected' : 'disconnected' + return result + } catch (error) { + config.value.status = 'disconnected' + return { + success: false, + message: (error as Error).message || 'Connection failed', + provider: payload.provider, + error: (error as Error).message + } + } + } + + // Initialize: load models on mount + refreshModels() + + // Diagnostic logging on startup + console.log('🚀 [useAIChat] Initialized', { + provider: config.value.provider, + endpoint: config.value.endpoint, + model: config.value.model, + hasApiKey: !!config.value.apiKey, + isConfigured: isConfigured.value, + localStorageGlobal: localStorage.getItem('atest-ai-global-config'), + localStorageProvider: localStorage.getItem(`atest-ai-config-${config.value.provider}`) + }) + + return { + config, + isConfigured, + availableModels, + modelsByProvider, + messages, + isLoading, + handleQuery, + handleSaveConfig, + handleTestConnection, + refreshModels + } +} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000..5fc5208 --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,86 @@ +{ + "ai": { + "title": "AI Assistant", + "subtitle": "Natural language to SQL query generator", + "status": { + "connected": "Connected", + "disconnected": "Disconnected", + "connecting": "Connecting..." + }, + "settings": { + "title": "Settings", + "provider": "Provider", + "endpoint": "Endpoint", + "model": "Model", + "apiKey": "API Key", + "timeout": "Request Timeout (seconds)", + "timeoutHintLocal": "Increase this value if your local model needs more time to respond.", + "timeoutHintCloud": "Most cloud providers handle timeouts automatically; adjust only when needed.", + "maxTokens": "Max Tokens", + "advanced": "Advanced Settings", + "localServices": "Local Services", + "cloudServices": "Cloud Services" + }, + "button": { + "save": "Save", + "reset": "Reset", + "refresh": "Refresh", + "close": "Close", + "copy": "Copy" + }, + "tooltip": { + "configure": "Open AI settings", + "generate": "Generate SQL", + "dialect": "Select database dialect" + }, + "input": { + "placeholder": "Enter your query in natural language..." + }, + "option": { + "includeExplanation": "Include explanation" + }, + "welcome": { + "title": "Welcome to AI Assistant", + "message": "Configure your AI provider to get started", + "noModels": "No AI models found", + "startChat": "Start a conversation by asking your first question" + }, + "provider": { + "local": "Local service", + "cloud": "Cloud service", + "ollama": { + "name": "Ollama", + "description": "Local AI, privacy-first" + }, + "openai": { + "name": "OpenAI", + "description": "GPT-4 and more" + }, + "deepseek": { + "name": "DeepSeek", + "description": "Powerful reasoning AI" + } + }, + "dialect": { + "label": "Database", + "mysql": "MySQL", + "postgresql": "PostgreSQL", + "sqlite": "SQLite" + }, + "providerLabel": "Provider", + "message": { + "configSaved": "Configuration saved successfully", + "configSaveFailed": "Saved locally, backend sync failed", + "connectionSuccess": "Connection successful!", + "connectionFailed": "Connection failed", + "generating": "Generating SQL...", + "copiedSuccess": "Copied to clipboard", + "explanationNotSupported": "Detailed explanations are not supported yet." + }, + "statusBanner": { + "connecting": "Connecting to {provider}...", + "disconnected": "Connection lost. Check the settings and try again.", + "setup": "Complete the configuration to start chatting." + } +} +} diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json new file mode 100644 index 0000000..de14062 --- /dev/null +++ b/frontend/src/locales/zh.json @@ -0,0 +1,86 @@ +{ + "ai": { + "title": "AI 助手", + "subtitle": "自然语言转 SQL 查询生成器", + "status": { + "connected": "已连接", + "disconnected": "已断开", + "connecting": "连接中..." + }, + "settings": { + "title": "设置", + "provider": "提供者", + "endpoint": "端点", + "model": "模型", + "apiKey": "API 密钥", + "timeout": "推理超时时间(秒)", + "timeoutHintLocal": "若本地模型响应较慢,可适当延长超时时间。", + "timeoutHintCloud": "云端服务通常自动处理超时,仅在必要时调整。", + "maxTokens": "最大令牌数", + "advanced": "高级设置", + "localServices": "本地服务", + "cloudServices": "云服务" + }, + "button": { + "save": "保存", + "reset": "重置", + "refresh": "刷新", + "close": "关闭", + "copy": "复制" + }, + "tooltip": { + "configure": "打开设置", + "generate": "生成 SQL", + "dialect": "选择目标数据库方言" + }, + "input": { + "placeholder": "用自然语言输入您的查询..." + }, + "option": { + "includeExplanation": "包含解释" + }, + "welcome": { + "title": "欢迎使用 AI 助手", + "message": "配置您的 AI 提供者以开始使用", + "noModels": "未检测到 AI 模型", + "startChat": "提出第一个问题开始对话" + }, + "provider": { + "local": "本地服务", + "cloud": "云服务", + "ollama": { + "name": "Ollama", + "description": "本地 AI,隐私优先" + }, + "openai": { + "name": "OpenAI", + "description": "GPT-4 等模型" + }, + "deepseek": { + "name": "DeepSeek", + "description": "强大的推理 AI" + } + }, + "dialect": { + "label": "数据库", + "mysql": "MySQL", + "postgresql": "PostgreSQL", + "sqlite": "SQLite" + }, + "providerLabel": "当前服务商", + "message": { + "configSaved": "配置保存成功", + "configSaveFailed": "本地保存成功,后端同步失败", + "connectionSuccess": "连接成功!", + "connectionFailed": "连接失败", + "generating": "生成 SQL 中...", + "copiedSuccess": "已复制到剪贴板", + "explanationNotSupported": "暂不支持生成详细解释。" + }, + "statusBanner": { + "connecting": "正在连接 {provider}...", + "disconnected": "连接已断开,请检查设置后重试。", + "setup": "请先完成配置再开始对话。" + } +} +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..e61c6b3 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,80 @@ +import { createApp, type App as VueApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import App from './App.vue' +import type { AppContext } from './types' +import './styles/tokens.css' +import { createPluginContextBridge, type PluginContextBridge } from './utils/pluginContext' +import { normalizeLocale } from './utils/i18n' + +// Store Vue app instance for potential cleanup +let app: VueApp | null = null +let bridge: PluginContextBridge | null = null +let pendingLocale: string | null = null + +/** + * Plugin interface exposed to main application + */ +const ATestPlugin = { + /** + * Mount plugin with context from main app + * @param container - DOM container element + * @param context - Optional context from main app (i18n, API, Cache) + */ + mount(container: HTMLElement, context?: AppContext) { + // Cleanup previous instance if exists + if (app) { + app.unmount() + } + + bridge = createPluginContextBridge(context) + + // Create new Vue app with context passed as props + app = createApp(App, { context: bridge.context }) + + // Use Element Plus + app.use(ElementPlus) + + // Mount to container + app.mount(container) + + if (pendingLocale) { + bridge.setLocale(pendingLocale) + pendingLocale = null + } + }, + + /** + * Unmount plugin (for cleanup) + */ + unmount() { + if (app) { + app.unmount() + app = null + } + bridge = null + }, + + /** + * Allow host application to toggle locale proactively + */ + setLocale(locale: string) { + const normalized = normalizeLocale(locale) + if (bridge) { + bridge.setLocale(normalized) + } else { + pendingLocale = normalized + } + } +} + +// Expose plugin to window for main app to access +declare global { + interface Window { + ATestPlugin: typeof ATestPlugin + } +} + +window.ATestPlugin = ATestPlugin + +export default ATestPlugin diff --git a/frontend/src/services/aiService.ts b/frontend/src/services/aiService.ts new file mode 100644 index 0000000..74c8e41 --- /dev/null +++ b/frontend/src/services/aiService.ts @@ -0,0 +1,314 @@ +import type { AIConfig, Model, QueryRequest, QueryResponse } from '@/types' + +const API_BASE = '/api/v1/data/query' +const API_STORE = 'ai' + +function toBoolean(value: unknown): boolean { + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true') { + return true + } + if (normalized === 'false') { + return false + } + } + return Boolean(value) +} + +function safeParseJSON(value: unknown): T | undefined { + if (typeof value !== 'string') { + return value as T | undefined + } + try { + return JSON.parse(value) as T + } catch (error) { + console.warn('[aiService] Failed to parse JSON value from backend', value, error) + return value as T | undefined + } +} + +/** + * AI Service Layer + * Centralized API calls for AI functionality + */ +export const aiService = { + /** + * Fetch available models for a provider + */ + async fetchModels(provider: string): Promise { + const result = await callAPI<{ models: Model[] }>('models', { provider }) + return result.models || [] + }, + + /** + * Test connection to AI provider + */ + async testConnection(config: AIConfig): Promise<{ + success: boolean + message: string + provider: string + error?: string + }> { + const payload = { + provider: config.provider, + endpoint: config.endpoint, + model: config.model, + api_key: config.apiKey, + max_tokens: config.maxTokens, + timeout: formatTimeout(config.timeout) + } + + const result = await callAPI<{ + success: string | boolean + message: string + provider: string + error?: string + }>('test_connection', payload) + + return { + success: toBoolean(result.success), + message: result.message || '', + provider: result.provider || config.provider, + error: result.error + } + }, + + /** + * Check AI service health (does not affect plugin Ready status) + */ + async checkHealth(provider: string = '', timeout: number = 5): Promise<{ + healthy: boolean + provider: string + error: string + timestamp: string + }> { + const result = await callAPI<{ + healthy: string | boolean + provider: string + error: string + timestamp: string + }>('health_check', { + provider, + timeout + }) + + return { + healthy: toBoolean(result.healthy), + provider: result.provider, + error: result.error || '', + timestamp: result.timestamp + } + }, + + /** + * Generate SQL from natural language query + */ + async generateSQL(request: QueryRequest): Promise { + console.log('📤 [aiService] generateSQL called', { + model: request.model, + provider: request.provider, + endpoint: request.endpoint, + promptLength: request.prompt.length, + includeExplanation: request.includeExplanation + }) + + try { + const result = await callAPI<{ + content: string + meta: string + success: string | boolean + error?: string + }>('generate', { + model: request.model, + prompt: request.prompt, + database_type: request.databaseDialect, + config: JSON.stringify({ + include_explanation: request.includeExplanation, + provider: request.provider, + endpoint: request.endpoint, + api_key: request.apiKey, + max_tokens: request.maxTokens, + timeout: formatTimeout(request.timeout), + database_type: request.databaseDialect + }) + }) + + console.log('📥 [aiService] Received backend result', { + hasContent: !!result.content, + contentLength: result.content?.length || 0, + success: result.success, + hasError: !!result.error, + hasMeta: !!result.meta + }) + + // Parse backend format: "sql:xxx\nexplanation:xxx" + let sql = '' + let explanation = '' + + if (result.content) { + const lines = result.content.split('\n') + for (const line of lines) { + if (line.startsWith('sql:')) { + sql = line.substring(4).trim() + } else if (line.startsWith('explanation:')) { + explanation = line.substring(12).trim() + } + } + } + + const parsedMeta = safeParseJSON>(result.meta) + const normalizedMeta = (() => { + if (parsedMeta && typeof parsedMeta === 'object') { + return { + ...parsedMeta, + dialect: parsedMeta.dialect ?? request.databaseDialect + } + } + if (result.meta) { + return { + raw: result.meta, + dialect: request.databaseDialect + } + } + return { dialect: request.databaseDialect } + })() + + const response = { + success: toBoolean(result.success), + sql, + explanation: explanation || undefined, + meta: normalizedMeta, + error: result.error + } + + console.log('✅ [aiService] Parsed response', { + success: response.success, + hasSql: !!response.sql, + sqlLength: response.sql?.length || 0, + hasExplanation: !!response.explanation, + hasError: !!response.error + }) + + return response + } catch (error) { + console.error('❌ [aiService] generateSQL failed', { + error, + message: (error as Error).message, + stack: (error as Error).stack + }) + throw error + } + }, + + /** + * Save AI configuration + */ + async saveConfig(config: AIConfig): Promise { + await callAPI('update_config', { + provider: config.provider, + config: { + provider: config.provider, + endpoint: config.endpoint, + model: config.model, + api_key: config.apiKey, + max_tokens: config.maxTokens, + timeout: formatTimeout(config.timeout), + database_type: config.databaseDialect + } + }) + } +} + +function formatTimeout(timeout: number | undefined): string { + const value = Number(timeout) + if (!Number.isFinite(value) || value <= 0) { + return '60s' + } + return `${Math.round(value)}s` +} + +/** + * Call backend API directly + * + * @private + * Note: We use fetch directly instead of DataQuery because DataQuery + * is designed for database queries and transforms the request format. + * The AI plugin expects: {type: 'ai', key: 'operation', sql: 'params_json'} + */ +async function callAPI(key: string, data: any): Promise { + const requestBody = { + type: 'ai', + key, + sql: JSON.stringify(data) + } + + console.log('🌐 [callAPI] Sending request', { + url: API_BASE, + key, + dataKeys: Object.keys(data), + bodyLength: JSON.stringify(requestBody).length + }) + + try { + const response = await fetch(API_BASE, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Store-Name': API_STORE + }, + body: JSON.stringify(requestBody) + }) + + console.log('📡 [callAPI] Received HTTP response', { + status: response.status, + statusText: response.statusText, + ok: response.ok, + contentType: response.headers.get('content-type') + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('❌ [callAPI] HTTP error', { + status: response.status, + statusText: response.statusText, + body: errorText + }) + throw new Error(`API error: ${response.status} ${response.statusText} - ${errorText}`) + } + + const result = await response.json() + console.log('📦 [callAPI] Parsed JSON result', { + hasData: !!result.data, + dataLength: result.data?.length || 0, + resultKeys: Object.keys(result) + }) + + // Parse key-value pair format from backend + const parsed: any = {} + if (result.data) { + for (const pair of result.data) { + try { + parsed[pair.key] = JSON.parse(pair.value) + } catch { + parsed[pair.key] = pair.value + } + } + console.log('🔓 [callAPI] Parsed data pairs', { + keys: Object.keys(parsed) + }) + } + + return parsed as T + } catch (error) { + console.error('💥 [callAPI] Request failed', { + error, + message: (error as Error).message, + stack: (error as Error).stack + }) + throw error + } +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..06c048c --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,52 @@ +:root { + --atest-spacing-2xs: 4px; + --atest-spacing-xs: 8px; + --atest-spacing-sm: 12px; + --atest-spacing-md: clamp(16px, 2vw, 24px); + --atest-spacing-lg: clamp(20px, 3vw, 32px); + --atest-spacing-xl: clamp(24px, 4vw, 48px); + + --atest-radius-sm: 8px; + --atest-radius-md: 12px; + --atest-radius-lg: 16px; + --atest-radius-xl: 20px; + + --atest-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08); + --atest-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.12); + --atest-shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.14); + + --atest-bg-base: var(--el-bg-color-page, #f5f7fa); + --atest-bg-surface: var(--el-bg-color, #ffffff); + --atest-bg-elevated: var(--el-bg-color-overlay, rgba(255, 255, 255, 0.95)); + --atest-border-color: var(--el-border-color, rgba(0, 0, 0, 0.08)); + --atest-border-strong: color-mix(in srgb, var(--el-border-color, rgba(0,0,0,0.12)) 80%, transparent); + + --atest-text-primary: var(--el-text-color-primary, #1f2937); + --atest-text-secondary: var(--el-text-color-secondary, #4b5563); + --atest-text-regular: var(--el-text-color-regular, #6b7280); + --atest-text-placeholder: var(--el-text-color-placeholder, #9ca3af); + + --atest-color-accent: var(--el-color-primary, #409eff); + --atest-color-accent-soft: color-mix(in srgb, var(--atest-color-accent) 12%, transparent); + --atest-color-success-soft: color-mix(in srgb, var(--el-color-success, #67c23a) 18%, transparent); + --atest-color-danger-soft: color-mix(in srgb, var(--el-color-danger, #f56c6c) 18%, transparent); + + --atest-transition-base: all 0.25s ease; +} + +html.dark { + --atest-bg-base: var(--el-bg-color-page, #0f172a); + --atest-bg-surface: var(--el-bg-color, #111827); + --atest-bg-elevated: var(--el-bg-color-overlay, rgba(17, 24, 39, 0.92)); + --atest-border-color: rgba(255, 255, 255, 0.08); + --atest-border-strong: rgba(255, 255, 255, 0.16); + + --atest-text-primary: var(--el-text-color-primary, #f9fafb); + --atest-text-secondary: var(--el-text-color-secondary, #e5e7eb); + --atest-text-regular: var(--el-text-color-regular, #d1d5db); + --atest-text-placeholder: var(--el-text-color-placeholder, #94a3b8); + + --atest-shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.45); + --atest-shadow-md: 0 12px 32px rgba(0, 0, 0, 0.55); + --atest-shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.6); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..d341a53 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,84 @@ +import type { Ref } from 'vue' + +/** + * Context passed from main app to plugin + * Provides access to main app's i18n, API, and Cache + */ +export interface AppContext { + i18n: { + t: (key: string) => string + locale: Ref + } + API: any // Main app's API object from net.ts + Cache: any // Main app's Cache object from cache.ts +} + +/** + * AI configuration + * Note: Language is managed by main app, not stored in plugin config + * + * Provider values: + * - 'ollama': Local Ollama service (user-facing option) + * - 'openai': OpenAI cloud service + * - 'deepseek': DeepSeek cloud service + * - 'local': Internal alias for 'ollama' (backward compatibility only, not shown in UI) + */ +export type DatabaseDialect = 'mysql' | 'postgresql' | 'sqlite' + +export interface AIConfig { + provider: 'ollama' | 'local' | 'openai' | 'deepseek' + endpoint: string + model: string + apiKey: string + timeout: number + maxTokens: number + status: 'connected' | 'disconnected' | 'connecting' + databaseDialect: DatabaseDialect +} + +/** + * AI Model + */ +export interface Model { + id: string + name: string + size: string +} + +/** + * Message in chat + */ +export interface Message { + id: string + type: 'user' | 'ai' | 'error' + content: string + sql?: string + meta?: any + timestamp: number +} + +/** + * Query request + */ +export interface QueryRequest { + model: string + prompt: string + provider: string + endpoint: string + apiKey: string + timeout: number + maxTokens: number + includeExplanation: boolean + databaseDialect: DatabaseDialect +} + +/** + * Query response + */ +export interface QueryResponse { + success: boolean + sql?: string + explanation?: string + meta?: any + error?: string +} diff --git a/frontend/src/utils/config.ts b/frontend/src/utils/config.ts new file mode 100644 index 0000000..dfe95ca --- /dev/null +++ b/frontend/src/utils/config.ts @@ -0,0 +1,165 @@ +import type { AIConfig, Model, DatabaseDialect } from '@/types' + +export type Provider = 'ollama' | 'openai' | 'deepseek' | 'local' + +/** + * Load configuration from localStorage + */ +export function loadConfig(): AIConfig { + const globalConfig = localStorage.getItem('atest-ai-global-config') + let provider: Provider = 'ollama' + + if (globalConfig) { + const parsed = JSON.parse(globalConfig) + provider = (parsed.provider as Provider) || provider + } + + return loadConfigForProvider(provider) +} + +/** + * Load configuration for a specific provider from localStorage + */ +export function loadConfigForProvider(provider: Provider): AIConfig { + const defaults = getDefaultConfig(provider) + const providerConfig = localStorage.getItem(`atest-ai-config-${provider}`) + const stored = providerConfig ? JSON.parse(providerConfig) : {} + + const isLocalEndpoint = (value: unknown) => { + if (typeof value !== 'string') { + return false + } + const lower = value.trim().toLowerCase() + return lower.startsWith('http://localhost') || + lower.startsWith('http://127.0.0.1') || + lower.startsWith('https://localhost') || + lower.startsWith('https://127.0.0.1') + } + + const config: AIConfig = { + provider, + endpoint: (() => { + const value = stored.endpoint ?? defaults.endpoint ?? '' + const fallback = defaults.endpoint ?? '' + if (provider !== 'ollama' && isLocalEndpoint(value)) { + return normalizeEndpoint(provider, String(fallback)) + } + if (!value) { + return normalizeEndpoint(provider, String(fallback)) + } + return normalizeEndpoint(provider, String(value)) + })(), + model: stored.model ?? defaults.model ?? '', + apiKey: stored.apiKey ?? defaults.apiKey ?? '', + timeout: Number.isFinite(stored.timeout) ? Number(stored.timeout) : (defaults.timeout ?? 120), + maxTokens: stored.maxTokens ?? defaults.maxTokens ?? 2048, + status: stored.status ?? 'disconnected', + databaseDialect: (stored.databaseDialect ?? defaults.databaseDialect ?? 'mysql') as DatabaseDialect + } + + return config +} + +/** + * Save configuration to localStorage + * Note: Language is managed by main app, not saved here + */ +export function saveConfig(config: AIConfig): void { + // Save global config (only provider) + localStorage.setItem('atest-ai-global-config', JSON.stringify({ + provider: config.provider + })) + + // Save provider-specific config + const { provider, status, ...rest } = config + const normalizedProvider = (provider === 'local' ? 'ollama' : provider) as Provider + const defaults = getDefaultConfig(normalizedProvider) + const providerConfig = { + endpoint: normalizeEndpoint(normalizedProvider, (rest.endpoint && String(rest.endpoint).trim()) || defaults.endpoint || ''), + model: rest.model ?? defaults.model ?? '', + apiKey: rest.apiKey ?? defaults.apiKey ?? '', + timeout: (typeof rest.timeout === 'number' && rest.timeout > 0 ? rest.timeout : defaults.timeout ?? 120), + maxTokens: rest.maxTokens ?? defaults.maxTokens ?? 2048, + databaseDialect: (rest.databaseDialect ?? defaults.databaseDialect ?? 'mysql') + } + + localStorage.setItem( + `atest-ai-config-${normalizedProvider}`, + JSON.stringify(providerConfig) + ) +} + +/** + * Get default configuration for provider + */ +export function getDefaultConfig(provider: string): Partial { + const defaults: Record> = { + ollama: { endpoint: 'http://localhost:11434', apiKey: '', timeout: 120 }, + openai: { endpoint: 'https://api.openai.com', apiKey: '', timeout: 120 }, + deepseek: { endpoint: 'https://api.deepseek.com', apiKey: '', timeout: 180 } + } + + return { + ...(defaults[provider] || defaults.ollama), + model: '', + timeout: (defaults[provider] || defaults.ollama)?.timeout ?? 120, + maxTokens: 2048, + status: 'disconnected', + databaseDialect: 'mysql' + } +} + +/** + * Get mock models when API fails + */ +export function getMockModels(provider: string): Model[] { + const mocks: Record = { + ollama: [ + { id: 'llama3.2:3b', name: 'Llama 3.2 3B', size: '2GB' }, + { id: 'gemma2:9b', name: 'Gemma 2 9B', size: '5GB' } + ], + openai: [ + { id: 'gpt-5', name: 'GPT-5 ⭐', size: 'Cloud' }, + { id: 'gpt-5-mini', name: 'GPT-5 Mini', size: 'Cloud' }, + { id: 'gpt-5-nano', name: 'GPT-5 Nano', size: 'Cloud' }, + { id: 'gpt-5-pro', name: 'GPT-5 Pro', size: 'Cloud' }, + { id: 'gpt-4.1', name: 'GPT-4.1', size: 'Cloud' } + ], + deepseek: [ + { id: 'deepseek-chat', name: 'DeepSeek Chat', size: 'Cloud' }, + { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', size: 'Cloud' } + ] + } + return mocks[provider] || [] +} + +/** + * Generate unique ID + */ +export function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +function normalizeEndpoint(provider: Provider, endpoint: string): string { + const normalizedProvider: Provider = (provider === 'local' ? 'ollama' : provider) as Provider + if (!endpoint) { + return endpoint + } + + let value = endpoint.trim() + while (value.endsWith('/')) { + value = value.slice(0, -1) + } + + if (normalizedProvider === 'openai' || normalizedProvider === 'deepseek') { + const lower = value.toLowerCase() + if (lower.endsWith('/v1')) { + value = value.slice(0, value.length - 3) + while (value.endsWith('/')) { + value = value.slice(0, -1) + } + } + } + + return value +} diff --git a/frontend/src/utils/i18n.ts b/frontend/src/utils/i18n.ts new file mode 100644 index 0000000..a02b5a9 --- /dev/null +++ b/frontend/src/utils/i18n.ts @@ -0,0 +1,59 @@ +import type { AppContext } from '@/types' +import en from '@/locales/en.json' +import zh from '@/locales/zh.json' + +const messages: Record = { + en, + zh +} + +export function normalizeLocale(locale: string | undefined): string { + if (!locale) { + return 'en' + } + + const lower = locale.toLowerCase() + + if (lower.startsWith('zh')) { + return 'zh' + } + + if (lower.startsWith('en')) { + return 'en' + } + + const [base] = lower.split('-') + return base || 'en' +} + +function resolveMessage(locale: string, key: string): string | undefined { + const segments = key.split('.') + const localeMessages = messages[locale] || messages.en + let current: any = localeMessages + + for (const segment of segments) { + if (current && typeof current === 'object' && segment in current) { + current = current[segment] + } else { + return undefined + } + } + + return typeof current === 'string' ? current : undefined +} + +/** + * Create translator that falls back to plugin-local messages when host app + * does not provide a translation key. + */ +export function createTranslator(i18n: AppContext['i18n']) { + return (key: string): string => { + const hostValue = i18n.t(key) + if (hostValue !== key) { + return hostValue + } + + const locale = normalizeLocale(i18n.locale.value) + return resolveMessage(locale, key) ?? resolveMessage('en', key) ?? key + } +} diff --git a/frontend/src/utils/pluginContext.ts b/frontend/src/utils/pluginContext.ts new file mode 100644 index 0000000..bad5dbe --- /dev/null +++ b/frontend/src/utils/pluginContext.ts @@ -0,0 +1,51 @@ +import { ref, computed, type Ref } from 'vue' +import type { AppContext } from '@/types' +import { createTranslator, normalizeLocale } from './i18n' + +function detectBrowserLocale(): string { + if (typeof navigator === 'undefined' || !navigator.language) { + return 'en' + } + return normalizeLocale(navigator.language) +} + +export interface PluginContextBridge { + context: AppContext + setLocale: (locale: string) => void + locale: Ref +} + +export function createPluginContextBridge(provided?: AppContext): PluginContextBridge { + const fallbackLocale = ref(detectBrowserLocale()) + const localeRef = provided?.i18n?.locale ?? fallbackLocale + const baseI18n = provided?.i18n ?? { + t: (key: string) => key, + locale: localeRef + } + + const translator = computed(() => { + // ensure dependency tracking on locale changes + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + baseI18n.locale.value + return createTranslator(baseI18n) + }) + + const context: AppContext = { + i18n: { + locale: localeRef, + t: (key: string) => translator.value(key) + }, + API: provided?.API ?? {}, + Cache: provided?.Cache ?? {} + } + + const setLocale = (locale: string) => { + localeRef.value = normalizeLocale(locale) + } + + return { + context, + setLocale, + locale: localeRef + } +} diff --git a/frontend/tests/components/AIChatHeader.test.ts b/frontend/tests/components/AIChatHeader.test.ts new file mode 100644 index 0000000..7c85082 --- /dev/null +++ b/frontend/tests/components/AIChatHeader.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import AIChatHeader from '@/components/AIChatHeader.vue' +import type { AppContext } from '@/types' + +describe('AIChatHeader', () => { + let mockContext: AppContext + + beforeEach(() => { + mockContext = { + i18n: { + t: (key: string) => key, + locale: { value: 'en' } as any + }, + API: {}, + Cache: {} + } + }) + + it('renders title and subtitle', () => { + const wrapper = mount(AIChatHeader, { + props: { + provider: 'ollama', + status: 'disconnected' + }, + global: { + provide: { + appContext: mockContext + } + } + }) + + expect(wrapper.text()).toContain('ai.title') + expect(wrapper.text()).toContain('ai.subtitle') + }) + + it('shows provider label and status indicator', () => { + const wrapper = mount(AIChatHeader, { + props: { + provider: 'deepseek', + status: 'connecting' + }, + global: { + provide: { + appContext: mockContext + } + } + }) + + const indicator = wrapper.find('.status-indicator') + expect(indicator.exists()).toBe(true) + expect(indicator.classes()).toContain('connecting') + expect(indicator.text()).toContain('ai.status.connecting') + expect(wrapper.text()).toContain('ai.providerLabel') + }) + + it('applies correct status classes', () => { + const createWrapper = (status: 'connected' | 'connecting' | 'disconnected') => mount(AIChatHeader, { + props: { + provider: 'openai', + status + }, + global: { + provide: { + appContext: mockContext + } + } + }) + + expect(createWrapper('connected').find('.status-indicator').classes()).toContain('connected') + expect(createWrapper('connecting').find('.status-indicator').classes()).toContain('connecting') + expect(createWrapper('disconnected').find('.status-indicator').classes()).toContain('disconnected') + }) +}) diff --git a/frontend/tests/composables/useAIChat.test.ts b/frontend/tests/composables/useAIChat.test.ts new file mode 100644 index 0000000..f79a952 --- /dev/null +++ b/frontend/tests/composables/useAIChat.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useAIChat } from '@/composables/useAIChat' +import { aiService } from '@/services/aiService' +import type { AppContext } from '@/types' + +// Mock aiService +vi.mock('@/services/aiService', () => ({ + aiService: { + fetchModels: vi.fn(), + testConnection: vi.fn(), + generateSQL: vi.fn(), + saveConfig: vi.fn() + } +})) + +describe('useAIChat', () => { + let mockContext: AppContext + + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + + // Set default mock behavior for fetchModels (called during initialization) + vi.mocked(aiService.fetchModels).mockResolvedValue([]) + + mockContext = { + i18n: { + t: (key: string) => key, + locale: { value: 'en' } as any + }, + API: {}, + Cache: {} + } + }) + + describe('initialization', () => { + it('should initialize with default config', () => { + const { config, isConfigured, messages, isLoading } = useAIChat(mockContext) + + expect(config.value.provider).toBe('ollama') + expect(config.value.endpoint).toBe('http://localhost:11434') + expect(config.value.timeout).toBe(120) + expect(isConfigured.value).toBe(false) + expect(messages.value).toEqual([]) + expect(isLoading.value).toBe(false) + }) + + it('should be configured when provider is ollama with endpoint and model', () => { + localStorage.setItem('atest-ai-config-ollama', JSON.stringify({ + endpoint: 'http://localhost:11434', + model: 'llama3.2:3b', + timeout: 150, + maxTokens: 2048, + apiKey: '', + status: 'disconnected' + })) + + const { isConfigured } = useAIChat(mockContext) + expect(isConfigured.value).toBe(true) + }) + + it('should be configured when provider is openai with apiKey and model', () => { + localStorage.setItem('atest-ai-global-config', JSON.stringify({ + provider: 'openai' + })) + localStorage.setItem('atest-ai-config-openai', JSON.stringify({ + endpoint: 'https://api.openai.com', + model: 'gpt-5', + timeout: 200, + maxTokens: 2048, + apiKey: 'sk-test123', + status: 'disconnected' + })) + + const { isConfigured } = useAIChat(mockContext) + expect(isConfigured.value).toBe(true) + }) + }) + + describe('handleQuery', () => { + it('should add user message and handle successful response', async () => { + const mockResponse = { + success: true, + sql: 'SELECT * FROM users', + meta: { model: 'llama3.2:3b', duration: 150 } + } + + vi.mocked(aiService.generateSQL).mockResolvedValue(mockResponse) + + const { config, messages, handleQuery } = useAIChat(mockContext) + + await handleQuery('show all users', { includeExplanation: false }) + + expect(messages.value).toHaveLength(2) + expect(messages.value[0].type).toBe('user') + expect(messages.value[0].content).toBe('show all users') + expect(messages.value[1].type).toBe('ai') + expect(messages.value[1].sql).toBe('SELECT * FROM users') + expect(aiService.generateSQL).toHaveBeenCalledWith(expect.objectContaining({ + timeout: config.value.timeout + })) + }) + + it('should add error message when API fails', async () => { + vi.mocked(aiService.generateSQL).mockRejectedValue(new Error('API error: 500')) + + const { messages, handleQuery } = useAIChat(mockContext) + + await handleQuery('show all users', { includeExplanation: false }) + + expect(messages.value).toHaveLength(2) + expect(messages.value[0].type).toBe('user') + expect(messages.value[1].type).toBe('error') + }) + + it('should set loading state correctly', async () => { + const mockResponse = { + success: true, + sql: 'SELECT * FROM users' + } + + let resolvePromise: any + vi.mocked(aiService.generateSQL).mockReturnValue( + new Promise((resolve) => { + resolvePromise = resolve + }) + ) + + const { isLoading, handleQuery } = useAIChat(mockContext) + + const queryPromise = handleQuery('test', { includeExplanation: false }) + + expect(isLoading.value).toBe(true) + + resolvePromise(mockResponse) + + await queryPromise + expect(isLoading.value).toBe(false) + }) + }) + + describe('handleTestConnection', () => { + it('should set status to connected on success', async () => { + vi.mocked(aiService.testConnection).mockResolvedValue({ + success: true, + message: 'ok', + provider: 'ollama' + }) + + const { config, handleTestConnection } = useAIChat(mockContext) + + const result = await handleTestConnection() + + expect(result.success).toBe(true) + expect(config.value.status).toBe('connected') + }) + + it('should set status to disconnected on failure', async () => { + vi.mocked(aiService.testConnection).mockRejectedValue(new Error('Network error')) + + const { config, handleTestConnection } = useAIChat(mockContext) + + const result = await handleTestConnection() + + expect(result.success).toBe(false) + expect(result.error).toBe('Network error') + expect(config.value.status).toBe('disconnected') + }) + }) + + describe('refreshModels', () => { + it('should fetch and set available models', async () => { + const mockModels = [ + { id: 'model1', name: 'Model 1', size: '2GB' }, + { id: 'model2', name: 'Model 2', size: '5GB' } + ] + + vi.mocked(aiService.fetchModels).mockResolvedValue(mockModels) + + const { availableModels, refreshModels } = useAIChat(mockContext) + + await refreshModels() + + expect(availableModels.value).toHaveLength(2) + expect(availableModels.value[0].id).toBe('model1') + }) + + it('should use mock models when API fails', async () => { + vi.mocked(aiService.fetchModels).mockRejectedValue(new Error('Network error')) + + const { availableModels, refreshModels } = useAIChat(mockContext) + + await refreshModels() + + expect(availableModels.value.length).toBeGreaterThan(0) + expect(availableModels.value[0].id).toBe('llama3.2:3b') + }) + + it('should auto-select first model if none selected', async () => { + const mockModels = [ + { id: 'auto-model', name: 'Auto Model', size: '1GB' } + ] + + vi.mocked(aiService.fetchModels).mockResolvedValue(mockModels) + + const { config, refreshModels } = useAIChat(mockContext) + + expect(config.value.model).toBe('') + + await refreshModels() + + expect(config.value.model).toBe('auto-model') + }) + }) + + describe('config persistence', () => { + it('should save config to localStorage when changed', async () => { + const { config } = useAIChat(mockContext) + + config.value.model = 'new-model' + config.value.maxTokens = 4096 + + // Wait for watch to trigger + await new Promise(resolve => setTimeout(resolve, 10)) + + const saved = JSON.parse(localStorage.getItem('atest-ai-config-ollama')!) + expect(saved.model).toBe('new-model') + expect(saved.maxTokens).toBe(4096) + }) + }) +}) diff --git a/frontend/tests/services/aiService.spec.ts b/frontend/tests/services/aiService.spec.ts new file mode 100644 index 0000000..ced0313 --- /dev/null +++ b/frontend/tests/services/aiService.spec.ts @@ -0,0 +1,87 @@ +import { beforeAll, beforeEach, afterAll, describe, expect, it, vi } from 'vitest' +import { aiService } from '@/services/aiService' + +type FetchArgs = Parameters + +type FetchResponse = ReturnType + +function createFetchResponse(data: any): FetchResponse { + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: () => 'application/json' + }, + json: () => Promise.resolve(data) + } as Response) +} + +describe('aiService', () => { + const fetchMock = vi.fn() + + beforeAll(() => { + vi.stubGlobal('fetch', fetchMock) + }) + + afterAll(() => { + vi.unstubAllGlobals() + }) + + beforeEach(() => { + fetchMock.mockReset() + }) + + it('parses successful SQL generation response with boolean success', async () => { + fetchMock.mockImplementationOnce(async (_url: FetchArgs[0], options: FetchArgs[1]) => { + const body = JSON.parse(String(options?.body)) + expect(body).toMatchObject({ + type: 'ai', + key: 'generate' + }) + const payload = JSON.parse(body.sql) + expect(payload.config).toContain('timeout') + + return createFetchResponse({ + data: [ + { key: 'success', value: true }, + { key: 'content', value: 'sql:SELECT 1;\nexplanation:Test query' }, + { key: 'meta', value: '{"confidence":0.9,"model":"demo"}' } + ] + }) + }) + + const response = await aiService.generateSQL({ + provider: 'ollama', + endpoint: 'http://localhost:11434', + apiKey: '', + model: 'demo', + prompt: 'Select data', + timeout: 120, + maxTokens: 256, + includeExplanation: true + }) + + expect(response.success).toBe(true) + expect(response.sql).toBe('SELECT 1;') + expect(response.explanation).toBe('Test query') + expect(response.meta).toEqual({ confidence: 0.9, model: 'demo' }) + }) + + it('parses health check response when backend returns boolean healthy flag', async () => { + fetchMock.mockResolvedValueOnce( + createFetchResponse({ + data: [ + { key: 'healthy', value: true }, + { key: 'provider', value: 'ollama' }, + { key: 'error', value: '' }, + { key: 'timestamp', value: '2025-01-01T00:00:00Z' } + ] + }) + ) + + const health = await aiService.checkHealth('ollama', 5) + expect(health.healthy).toBe(true) + expect(health.provider).toBe('ollama') + }) +}) diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..c65f751 --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1,32 @@ +import { vi } from 'vitest' + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {} + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString() + }, + removeItem: (key: string) => { + delete store[key] + }, + clear: () => { + store = {} + } + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}) + +// Mock fetch +global.fetch = vi.fn() + +// Clear mocks before each test +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() +}) diff --git a/frontend/tests/utils/config.test.ts b/frontend/tests/utils/config.test.ts new file mode 100644 index 0000000..4ef9c09 --- /dev/null +++ b/frontend/tests/utils/config.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + loadConfig, + saveConfig, + getDefaultConfig, + getMockModels, + generateId +} from '@/utils/config' +import type { AIConfig } from '@/types' + +describe('config utils', () => { + beforeEach(() => { + localStorage.clear() + }) + + describe('loadConfig', () => { + it('should load default config when no config exists', () => { + const config = loadConfig() + + expect(config.provider).toBe('ollama') + expect(config.endpoint).toBe('http://localhost:11434') + expect(config.model).toBe('') + expect(config.timeout).toBe(120) + expect(config.maxTokens).toBe(2048) + expect(config.status).toBe('disconnected') + }) + + it('should load saved provider from global config', () => { + localStorage.setItem('atest-ai-global-config', JSON.stringify({ + provider: 'openai' + })) + + const config = loadConfig() + expect(config.provider).toBe('openai') + expect(config.endpoint).toBe('https://api.openai.com') + expect(config.apiKey).toBe('') + expect(config.timeout).toBe(120) + }) + + it('should load provider-specific config', () => { + localStorage.setItem('atest-ai-global-config', JSON.stringify({ + provider: 'ollama' + })) + localStorage.setItem('atest-ai-config-ollama', JSON.stringify({ + endpoint: 'http://localhost:11434', + model: 'llama3.2:3b', + timeout: 180, + maxTokens: 1024, + apiKey: '', + status: 'connected' + })) + + const config = loadConfig() + expect(config.provider).toBe('ollama') + expect(config.model).toBe('llama3.2:3b') + expect(config.timeout).toBe(180) + expect(config.maxTokens).toBe(1024) + expect(config.status).toBe('connected') + }) + + it('should sanitize local endpoint when provider is deepseek', () => { + localStorage.setItem('atest-ai-global-config', JSON.stringify({ + provider: 'deepseek' + })) + localStorage.setItem('atest-ai-config-deepseek', JSON.stringify({ + endpoint: 'http://localhost:11434', + model: 'deepseek-chat', + apiKey: 'sk-test', + maxTokens: 1024, + timeout: 90 + })) + + const config = loadConfig() + expect(config.provider).toBe('deepseek') + expect(config.endpoint).toBe('https://api.deepseek.com') + expect(config.model).toBe('deepseek-chat') + expect(config.timeout).toBe(90) + }) + + it('should normalize trailing version segment for openai endpoint', () => { + localStorage.setItem('atest-ai-global-config', JSON.stringify({ + provider: 'openai' + })) + localStorage.setItem('atest-ai-config-openai', JSON.stringify({ + endpoint: 'https://api.openai.com/v1/', + model: 'gpt-5', + apiKey: 'sk-test' + })) + + const config = loadConfig() + expect(config.endpoint).toBe('https://api.openai.com') + }) + }) + + describe('saveConfig', () => { + it('should save config to localStorage', () => { + const config: AIConfig = { + provider: 'deepseek', + endpoint: 'https://api.deepseek.com', + model: 'deepseek-chat', + apiKey: 'sk-test123', + timeout: 240, + maxTokens: 2048, + status: 'disconnected' + } + + saveConfig(config) + + const globalConfig = JSON.parse(localStorage.getItem('atest-ai-global-config')!) + expect(globalConfig.provider).toBe('deepseek') + + const providerConfig = JSON.parse(localStorage.getItem('atest-ai-config-deepseek')!) + expect(providerConfig.endpoint).toBe('https://api.deepseek.com') + expect(providerConfig.model).toBe('deepseek-chat') + expect(providerConfig.apiKey).toBe('sk-test123') + expect(providerConfig.timeout).toBe(240) + expect(providerConfig.provider).toBeUndefined() + expect(providerConfig.status).toBeUndefined() + }) + + it('should normalize local provider key to ollama', () => { + const config: AIConfig = { + provider: 'local', + endpoint: 'http://localhost:11434', + model: 'llama3.2:3b', + apiKey: '', + timeout: 90, + maxTokens: 1024, + status: 'connected' + } + + saveConfig(config) + + const providerConfig = JSON.parse(localStorage.getItem('atest-ai-config-ollama')!) + expect(providerConfig.endpoint).toBe('http://localhost:11434') + expect(providerConfig.model).toBe('llama3.2:3b') + expect(providerConfig.timeout).toBe(90) + }) + + it('should normalize openai endpoint when saving', () => { + const config: AIConfig = { + provider: 'openai', + endpoint: 'https://api.openai.com/v1', + model: 'gpt-5', + apiKey: 'sk-test', + timeout: 120, + maxTokens: 16384, + status: 'connected' + } + + saveConfig(config) + + const providerConfig = JSON.parse(localStorage.getItem('atest-ai-config-openai')!) + expect(providerConfig.endpoint).toBe('https://api.openai.com') + }) + }) + + describe('getDefaultConfig', () => { + it('should return ollama default config', () => { + const config = getDefaultConfig('ollama') + + expect(config.endpoint).toBe('http://localhost:11434') + expect(config.apiKey).toBe('') + expect(config.timeout).toBe(120) + expect(config.maxTokens).toBe(2048) + }) + + it('should return openai default config', () => { + const config = getDefaultConfig('openai') + + expect(config.endpoint).toBe('https://api.openai.com') + expect(config.apiKey).toBe('') + expect(config.timeout).toBe(120) + }) + + it('should return ollama config for unknown provider', () => { + const config = getDefaultConfig('unknown') + expect(config.endpoint).toBe('http://localhost:11434') + expect(config.timeout).toBe(120) + }) + }) + + describe('getMockModels', () => { + it('should return ollama mock models', () => { + const models = getMockModels('ollama') + + expect(models).toHaveLength(2) + expect(models[0].id).toBe('llama3.2:3b') + expect(models[0].name).toBe('Llama 3.2 3B') + }) + + it('should return openai mock models', () => { + const models = getMockModels('openai') + + expect(models).toHaveLength(5) + expect(models.map(model => model.id)).toEqual([ + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-5-pro', + 'gpt-4.1' + ]) + }) + + it('should return empty array for unknown provider', () => { + const models = getMockModels('unknown') + expect(models).toEqual([]) + }) + }) + + describe('generateId', () => { + it('should generate unique IDs', () => { + const id1 = generateId() + const id2 = generateId() + + expect(id1).not.toBe(id2) + expect(id1).toMatch(/^\d+-[a-z0-9]+$/) + }) + }) +}) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..de2424b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path alias */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..1f4f73b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + + // Define global constants for browser environment + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + + build: { + lib: { + entry: resolve(__dirname, 'src/main.ts'), + name: 'ATestPlugin', + formats: ['umd'], + // Output filename must match what Go embed expects + fileName: () => 'ai-chat.js' + }, + // Output directly to assets directory for Go embed + // The backend uses //go:embed to bundle these files into the binary + outDir: '../pkg/plugin/assets', + emptyOutDir: false, + + rollupOptions: { + // No external dependencies - bundle everything + external: [], + output: { + // UMD format will automatically use correct global object (window) + globals: {}, + // CSS filename must match what Go embed expects + assetFileNames: 'ai-chat.css', + // Inject process polyfill at the start of the UMD bundle + intro: 'var process = { env: { NODE_ENV: "production" } };' + } + } + }, + + resolve: { + alias: { + '@': resolve(__dirname, './src') + } + } +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..709fefa --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'tests/', + '**/*.config.ts', + '**/types/', + 'dist/' + ] + } + }, + + resolve: { + alias: { + '@': resolve(__dirname, './src') + } + } +}) diff --git a/go.mod b/go.mod index cdab68d..cda08a5 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,17 @@ module github.com/linuxsuren/atest-ext-ai -go 1.22.4 +go 1.24.0 -toolchain go1.23.7 +toolchain go1.24.2 -require github.com/tmc/langchaingo v0.1.13 +require ( + github.com/linuxsuren/api-testing v0.0.21-0.20251030015706-f4255ba5a733 + github.com/prometheus/client_golang v1.22.0 + github.com/stretchr/testify v1.11.1 + github.com/tmc/langchaingo v0.1.13 + google.golang.org/grpc v1.76.0 + gopkg.in/yaml.v2 v2.4.0 +) require ( github.com/Masterminds/goutils v1.1.1 // indirect @@ -13,17 +20,18 @@ require ( github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/bufbuild/protocompile v0.6.0 // indirect + github.com/bufbuild/protocompile v0.14.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cucumber/gherkin-go/v19 v19.0.3 // indirect github.com/cucumber/godog v0.12.6 // indirect github.com/cucumber/messages-go/v16 v16.0.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect github.com/evanphx/json-patch v0.5.2 // indirect github.com/expr-lang/expr v1.15.6 // indirect github.com/flopp/go-findfont v0.1.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect @@ -44,33 +52,40 @@ require ( github.com/invopop/jsonschema v0.7.0 // indirect github.com/jhump/protoreflect v1.15.3 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/linuxsuren/api-testing v0.0.19 // indirect - github.com/linuxsuren/go-fake-runtime v0.0.4 // indirect + github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect + github.com/linuxsuren/go-fake-runtime v0.0.5 // indirect github.com/linuxsuren/go-service v0.0.0-20231225060426-efabcd3a5161 // indirect - github.com/linuxsuren/oauth-hub v0.0.0-20240809060240-e78c21b5d8d4 // indirect + github.com/linuxsuren/http-downloader v0.0.99 // indirect + github.com/linuxsuren/oauth-hub v0.0.1 // indirect github.com/linuxsuren/unstructured v0.0.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkoukk/tiktoken-go v0.1.6 // indirect - github.com/prometheus/client_golang v1.19.0 // indirect - github.com/prometheus/client_model v0.6.0 // indirect - github.com/prometheus/common v0.50.0 // indirect - github.com/prometheus/procfs v0.12.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/rivo/uniseg v0.4.3 // indirect + github.com/schollz/progressbar/v3 v3.13.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shopspring/decimal v1.3.1 // indirect - github.com/signintech/gopdf v0.18.0 // indirect - github.com/spf13/cast v1.5.0 // indirect - github.com/spf13/cobra v1.8.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/swaggest/jsonschema-go v0.3.70 // indirect - github.com/swaggest/openapi-go v0.2.50 // indirect - github.com/swaggest/refl v1.3.0 // indirect - github.com/swaggest/rest v0.2.66 // indirect + github.com/signintech/gopdf v0.33.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/swaggest/jsonschema-go v0.3.78 // indirect + github.com/swaggest/openapi-go v0.2.59 // indirect + github.com/swaggest/refl v1.4.0 // indirect + github.com/swaggest/rest v0.2.75 // indirect github.com/swaggest/usecase v1.3.1 // indirect - github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -78,17 +93,16 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/oauth2 v0.25.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect - google.golang.org/grpc v1.71.1 // indirect - google.golang.org/protobuf v1.36.4 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/term v0.33.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 26054c8..be2b478 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,5 @@ github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= @@ -12,12 +11,17 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bufbuild/protocompile v0.6.0 h1:Uu7WiSQ6Yj9DbkdnOe7U4mNKp58y9WDMKDn28/ZlunY= -github.com/bufbuild/protocompile v0.6.0/go.mod h1:YNP35qEYoYGme7QMtz5SBCoN4kL4g12jTtjuzRNdjpE= +github.com/bool64/dev v0.2.34 h1:P9n315P8LdpxusnYQ0X7MP1CZXwBK5ae5RZrd+GdSZE= +github.com/bool64/dev v0.2.34/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/dev v0.2.40 h1:LUSD+Aq+WB3KwVntqXstevJ0wB12ig1bEgoG8ZafsZU= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cucumber/gherkin-go/v19 v19.0.3 h1:mMSKu1077ffLbTJULUfM5HPokgeBcIGboyeNUof1MdE= github.com/cucumber/gherkin-go/v19 v19.0.3/go.mod h1:jY/NP6jUtRSArQQJ5h1FXOUgk5fZK24qtE7vKi776Vw= github.com/cucumber/godog v0.12.6 h1:3IToXviU45G7FgijwTk/LdB4iojn8zUFDfQLj4MMiHc= @@ -36,10 +40,19 @@ github.com/expr-lang/expr v1.15.6 h1:dQFgzj5DBu3wnUz8+PGLZdPMpefAvxaCFTNM3iSjkGA github.com/expr-lang/expr v1.15.6/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= +github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= @@ -53,10 +66,12 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= @@ -65,6 +80,10 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -95,28 +114,53 @@ github.com/jhump/protoreflect v1.15.3/go.mod h1:4ORHmSBmlCW8fh3xHmJMGyul1zNqZK4E github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/linuxsuren/api-testing v0.0.19 h1:lDpXdJzqXZhxycZbGRVmKy7tMZqwk+1zUzg+41Cl6SE= -github.com/linuxsuren/api-testing v0.0.19/go.mod h1:igcyUJb5Q463tMpdEJxgURLLPR2pTcV7HoRyuvyP/2Y= -github.com/linuxsuren/go-fake-runtime v0.0.4 h1:y+tvBuw6MKTCav8Bo5HWwaXhBx1Z//VAvqI3gpOWqvw= -github.com/linuxsuren/go-fake-runtime v0.0.4/go.mod h1:zmh6J78hSnWZo68faMA2eKOdaEp8eFbERHi3ZB9xHCQ= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/linuxsuren/api-testing v0.0.20 h1:kk/EQsJdviEVkrTWKcLw5SLnIkcIgYB7BunFZlZ1Q7s= +github.com/linuxsuren/api-testing v0.0.20/go.mod h1:n1d5exbhCZAVuEw0HCWB3c0L92/6gvv+G6q87r5Fry0= +github.com/linuxsuren/api-testing v0.0.21-0.20251030015706-f4255ba5a733 h1:qwW9P2nvxM4qBEP99dU31uhPqv0K0ZznUAq/pRdx31k= +github.com/linuxsuren/api-testing v0.0.21-0.20251030015706-f4255ba5a733/go.mod h1:8ktTEIsUJbWbCrD8dGYDQ8LH7LnUN+na/qPmjojlpIE= +github.com/linuxsuren/go-fake-runtime v0.0.5 h1:x1qvuGMfly3L4BTwx6Hq5oUcuf/1u0kSVPzQylHHpwI= +github.com/linuxsuren/go-fake-runtime v0.0.5/go.mod h1:hlE6bZp76N3YPDsKi5YKOf1XmcJy4rvf8EtkTLYRYLw= github.com/linuxsuren/go-service v0.0.0-20231225060426-efabcd3a5161 h1:dSL/ah6zaRGqH3FW0ogtMjP6xCFXX5NsgWJTaNIofI4= github.com/linuxsuren/go-service v0.0.0-20231225060426-efabcd3a5161/go.mod h1:QX22v61PxpOfJa4Xug8qzGTbPjclDZFx2j1PlGLknJw= -github.com/linuxsuren/oauth-hub v0.0.0-20240809060240-e78c21b5d8d4 h1:muVmKxx+JneaVgUKHqLc+As5vpgKXZAfVu6h+iyb5LQ= -github.com/linuxsuren/oauth-hub v0.0.0-20240809060240-e78c21b5d8d4/go.mod h1:6K1L5ajpFTNO8iJSsNrxMWAigAqczI0UPfEV9NSE0nc= +github.com/linuxsuren/http-downloader v0.0.99 h1:fEu+HkHdYeLM932c7IfmuaDJqWxVU5sIEnS/Aln8h9o= +github.com/linuxsuren/http-downloader v0.0.99/go.mod h1:OngIAkbOJTMbd+IMRbt3TiWSizVJZvPfjdbTpl6uHLo= +github.com/linuxsuren/oauth-hub v0.0.1 h1:5LAdX9ZlWhaM7P10rdxiXPk26eceYHRyfkFXsym6AxY= +github.com/linuxsuren/oauth-hub v0.0.1/go.mod h1:6K1L5ajpFTNO8iJSsNrxMWAigAqczI0UPfEV9NSE0nc= github.com/linuxsuren/unstructured v0.0.1 h1:ilUA8MUYbR6l9ebo/YPV2bKqlf62bzQursDSE+j00iU= github.com/linuxsuren/unstructured v0.0.1/go.mod h1:KH6aTj+FegzGBzc1vS6mzZx3/duhTUTEVyW5sO7p4as= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 h1:zyWXQ6vu27ETMpYsEMAsisQ+GqJ4e1TPvSNfdOPF0no= github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -126,32 +170,42 @@ github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAc github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= 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/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= -github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= -github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= -github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= -github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ= -github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 h1:levPcBfnazlA1CyCMC3asL/QLZkq9pa8tQZOH513zQw= +github.com/santhosh-tekuri/jsonschema/v3 v3.1.0/go.mod h1:8kzK2TC0k0YjOForaAHdNEa7ik0fokNa2k30BKJ/W7Y= +github.com/schollz/progressbar/v3 v3.13.0 h1:9TeeWRcjW2qd05I8Kf9knPkW4vLM/hYoa6z9ABvxje8= +github.com/schollz/progressbar/v3 v3.13.0/go.mod h1:ZBYnSuLAX2LU8P8UiKN/KgF2DY58AJC8yfVYLPC8Ly4= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/signintech/gopdf v0.18.0 h1:ktQSrhoeQSImPBIIH9Z3vnTJZXCfiGYzgYW2Vy5Ff+c= -github.com/signintech/gopdf v0.18.0/go.mod h1:wrLtZoWaRNrS4hphED0oflFoa6IWkOu6M3nJjm4VbO4= +github.com/signintech/gopdf v0.33.0 h1:VanhSnrO03H9roKp4y4ckVmTmezxk8OzSJL/Sx1WlNg= +github.com/signintech/gopdf v0.33.0/go.mod h1:d23eO35GpEliSrF22eJ4bsM3wVeQJTjXTHq5x5qGKjA= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= -github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -160,20 +214,32 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY= +github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg= github.com/swaggest/jsonschema-go v0.3.70 h1:8Vx5nm5t/6DBFw2+WC0/Vp1ZVe9/4mpuA0tuAe0wwCI= github.com/swaggest/jsonschema-go v0.3.70/go.mod h1:7N43/CwdaWgPUDfYV70K7Qm79tRqe/al7gLSt9YeGIE= +github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw= +github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g= github.com/swaggest/openapi-go v0.2.50 h1:5yQ7N/IhMK9bQSk2yFAEbB75DvoXzyEmji3Q2iS++is= github.com/swaggest/openapi-go v0.2.50/go.mod h1:5R2TWYBz0U7P3vwIwN0ytwSxqONXZnbiAaa+DQ3Sq1k= +github.com/swaggest/openapi-go v0.2.59 h1:9cUlCrSxbWn/Qn78IxitrhB5kaev0hOROfTxwywYLC0= +github.com/swaggest/openapi-go v0.2.59/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk= github.com/swaggest/refl v1.3.0 h1:PEUWIku+ZznYfsoyheF97ypSduvMApYyGkYF3nabS0I= github.com/swaggest/refl v1.3.0/go.mod h1:3Ujvbmh1pfSbDYjC6JGG7nMgPvpG0ehQL4iNonnLNbg= +github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= +github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/swaggest/rest v0.2.66 h1:7jLlNVwzBbDMR/EUfO12wvWN8nKPwWKgqiQfUZYziyQ= github.com/swaggest/rest v0.2.66/go.mod h1:bvqmwxq5B15OIGeHuENEpOCUglznT2gZKsacmY2Bt8E= +github.com/swaggest/rest v0.2.75 h1:MW9zZ3d0kduJ2KdWnSYZIIrZJ1v3Kg+S7QZrDCZcXws= +github.com/swaggest/rest v0.2.75/go.mod h1:yw+PNgpNSdD6W46r60keVXdsBB+7SKt64i2qpeuBsq4= github.com/swaggest/usecase v1.3.1 h1:JdKV30MTSsDxAXxkldLNcEn8O2uf565khyo6gr5sS+w= github.com/swaggest/usecase v1.3.1/go.mod h1:cae3lDd5VDmM36OQcOOOdAlEDg40TiQYIp99S9ejWqA= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -188,7 +254,30 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= @@ -196,58 +285,84 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20240528184218-531527333157 h1:u7WMYrIrVvs0TF5yaKwKNbcJyySYf+HAIFXxWltJOXE= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= -google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= -google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= -google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/grpc.go b/grpc.go deleted file mode 100644 index 0254199..0000000 --- a/grpc.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "github.com/linuxsuren/api-testing/pkg/server" - testing "github.com/linuxsuren/api-testing/pkg/testing" - "github.com/linuxsuren/api-testing/pkg/testing/remote" - grpc "google.golang.org/grpc" -) - -func query(ctx context.Context, store testing.Store, sql string) (data *server.DataQueryResult, err error) { - address := store.Kind.URL - var conn *grpc.ClientConn - if conn, err = grpc.Dial(address, grpc.WithInsecure()); err == nil { - ctx = remote.WithStoreContext(ctx, &store) - writer := &gRPCLoader{ - store: &store, - ctx: ctx, - client: remote.NewLoaderClient(conn), - conn: conn, - } - - data, err = writer.client.Query(ctx, &server.DataQuery{ - Sql: sql, - }) - } else { - err = fmt.Errorf("failed to connect: %s, %v", address, err) - } - return -} - -type gRPCLoader struct { - store *testing.Store - client remote.LoaderClient - ctx context.Context - conn *grpc.ClientConn -} diff --git a/main.go b/main.go deleted file mode 100644 index 8df313e..0000000 --- a/main.go +++ /dev/null @@ -1,241 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "log" - "os" - "slices" - - testing "github.com/linuxsuren/api-testing/pkg/testing" - "github.com/tmc/langchaingo/llms" - "github.com/tmc/langchaingo/llms/ollama" -) - -var flagVerbose = flag.Bool("v", false, "verbose mode") - -func main() { - flag.Parse() - // allow specifying your own model via OLLAMA_TEST_MODEL - // (same as the Ollama unit tests). - model := "llama3.2:1b" - if v := os.Getenv("OLLAMA_TEST_MODEL"); v != "" { - model = v - } - - llm, err := ollama.New( - ollama.WithModel(model), - ollama.WithServerURL("http://192.168.123.58:11434"), - ollama.WithFormat("json"), - ) - if err != nil { - log.Fatal(err) - } - - var msgs []llms.MessageContent - - // system message defines the available tools. - msgs = append(msgs, llms.TextParts(llms.ChatMessageTypeSystem, systemMessage())) - msgs = append(msgs, llms.TextParts(llms.ChatMessageTypeHuman, fmt.Sprintf("get all table names, then return the table similar to %s", "user"))) - - ctx := context.Background() - - for retries := 3; retries > 0; retries = retries - 1 { - resp, err := llm.GenerateContent(ctx, msgs) - if err != nil { - log.Fatal(err) - } - - choice1 := resp.Choices[0] - msgs = append(msgs, llms.TextParts(llms.ChatMessageTypeAI, choice1.Content)) - - if c := unmarshalCall(choice1.Content); c != nil { - log.Printf("Call: %v", c.Tool) - if *flagVerbose { - log.Printf("Call: %v (raw: %v)", c.Tool, choice1.Content) - } - msg, cont := dispatchCall(c) - if !cont { - break - } - msgs = append(msgs, msg) - } else { - // Ollama doesn't always respond with a function call, let it try again. - log.Printf("Not a call: %v", choice1.Content) - msgs = append(msgs, llms.TextParts(llms.ChatMessageTypeHuman, "Sorry, I don't understand. Please try again.")) - } - - if retries == 0 { - log.Fatal("retries exhausted") - } - } -} - -type Call struct { - Tool string `json:"tool"` - Input map[string]any `json:"tool_input"` -} - -func unmarshalCall(input string) *Call { - var c Call - if err := json.Unmarshal([]byte(input), &c); err == nil && c.Tool != "" { - return &c - } - return nil -} - -func dispatchCall(c *Call) (llms.MessageContent, bool) { - // ollama doesn't always respond with a *valid* function call. As we're using prompt - // engineering to inject the tools, it may hallucinate. - if !validTool(c.Tool) { - log.Printf("invalid function call: %#v, prompting model to try again", c) - return llms.TextParts(llms.ChatMessageTypeHuman, - "Tool does not exist, please try again."), true - } - - // we could make this more dynamic, by parsing the function schema. - switch c.Tool { - case "getCurrentWeather": - loc, ok := c.Input["location"].(string) - if !ok { - log.Fatal("invalid input") - } - unit, ok := c.Input["unit"].(string) - if !ok { - log.Fatal("invalid input") - } - - weather, err := getCurrentWeather(loc, unit) - if err != nil { - log.Fatal(err) - } - return llms.TextParts(llms.ChatMessageTypeHuman, weather), true - case "finalResponse": - resp, ok := c.Input["response"].(string) - if !ok { - log.Fatal("invalid input", resp) - } - - log.Printf("Final response: %v", resp) - - return llms.MessageContent{}, false - case "getAllTables": - fmt.Println("try to getAllTables") - store := testing.Store{ - URL: "192.168.10.107:33060", - Username: "root", - Password: "AIUMSDB@123456", - Kind: testing.StoreKind{ - Name: "atest-store-orm", - // URL: `unix:///root/.config/atest/atest-store-orm.sock`, - URL: `127.0.0.1:7071`, - Enabled: true, - }, - Properties: map[string]string{ - "driver": "mysql", - "database": "ai_ums", - }, - } - data, err := query(context.Background(), store, "") - tables := map[string]string{} - if err == nil { - tables["tables"] = fmt.Sprintf("%v", data.Meta.Tables) - } - - b, err := json.Marshal(tables) - if err != nil { - return llms.TextParts(llms.ChatMessageTypeTool, ""), true - } - return llms.TextParts(llms.ChatMessageTypeTool, string(b)), false - default: - // we already checked above if we had a valid tool. - panic("unreachable") - } -} - -func validTool(name string) bool { - var valid []string - for _, v := range functions { - valid = append(valid, v.Name) - } - return slices.Contains(valid, name) -} - -func systemMessage() string { - bs, err := json.Marshal(functions) - if err != nil { - log.Fatal(err) - } - - return fmt.Sprintf(`You have access to the following tools: - -%s - -To use a tool, respond with a JSON object with the following structure: -{ - "tool": , - "tool_input": -} -`, string(bs)) -} - -func getCurrentWeather(location string, unit string) (string, error) { - weatherInfo := map[string]any{ - "location": location, - "temperature": "6", - "unit": unit, - "forecast": []string{"sunny", "windy"}, - } - if unit == "fahrenheit" { - weatherInfo["temperature"] = 43 - } - - b, err := json.Marshal(weatherInfo) - if err != nil { - return "", err - } - return string(b), nil -} - -var functions = []llms.FunctionDefinition{ - { - Name: "getCurrentWeather", - Description: "Get the current weather in a given location", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "location": {"type": "string", "description": "The city and state, e.g. San Francisco, CA"}, - "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]} - }, - "required": ["location", "unit"] - }`), - }, - { - // I found that providing a tool for Ollama to give the final response significantly - // increases the chances of success. - Name: "finalResponse", - Description: "Provide the final response to the user query", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "response": {"type": "string", "description": "The final response to the user query"} - }, - "required": ["response"] - }`), - }, - { - // I found that providing a tool for Ollama to give the final response significantly - // increases the chances of success. - Name: "getAllTables", - Description: "Get all database tables", - Parameters: json.RawMessage(`{ - "type": "object", - "properties": { - "response": {"type": "string", "description": "The final response to the user query"} - }, - "required": ["response"] - }`), - }, -} diff --git a/pkg/ai/capabilities.go b/pkg/ai/capabilities.go new file mode 100644 index 0000000..044acca --- /dev/null +++ b/pkg/ai/capabilities.go @@ -0,0 +1,720 @@ +// Package ai contains the AI engine, capability detection, and provider coordination logic. +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package ai + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/linuxsuren/atest-ext-ai/pkg/config" + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" +) + +// CapabilitiesRequest defines the request structure for capability queries +type CapabilitiesRequest struct { + IncludeModels bool `json:"include_models"` + IncludeDatabases bool `json:"include_databases"` + IncludeFeatures bool `json:"include_features"` + CheckHealth bool `json:"check_health"` +} + +// CapabilitiesResponse defines the complete capability information for the AI plugin +type CapabilitiesResponse struct { + Version string `json:"version"` + Models []ModelCapability `json:"models"` + Databases []DatabaseCapability `json:"databases"` + Features []FeatureCapability `json:"features"` + Health HealthStatusReport `json:"health"` + Limits ResourceLimits `json:"limits"` + LastUpdated time.Time `json:"last_updated"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// ModelCapability represents the capabilities of an AI model +type ModelCapability struct { + Name string `json:"name"` + Provider string `json:"provider"` + Available bool `json:"available"` + Features []string `json:"features"` + Limitations []string `json:"limitations"` + MaxTokens int `json:"max_tokens"` + ContextSize int `json:"context_size"` + CostPer1K *CostInfo `json:"cost_per_1k,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// CostInfo represents pricing information for a model +type CostInfo struct { + InputCost float64 `json:"input_cost"` + OutputCost float64 `json:"output_cost"` + Currency string `json:"currency"` +} + +// DatabaseCapability represents supported database types and features +type DatabaseCapability struct { + Type string `json:"type"` + Versions []string `json:"versions"` + Features []string `json:"features"` + Limitations []string `json:"limitations"` + Supported bool `json:"supported"` +} + +// FeatureCapability represents a specific feature and its status +type FeatureCapability struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Description string `json:"description"` + Version string `json:"version"` + Parameters map[string]string `json:"parameters,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` +} + +// HealthStatusReport provides detailed health information +type HealthStatusReport struct { + Overall bool `json:"overall"` + Components map[string]HealthInfo `json:"components"` + Providers map[string]HealthInfo `json:"providers"` + Timestamp time.Time `json:"timestamp"` +} + +// HealthInfo represents health information for a component +type HealthInfo struct { + Status string `json:"status"` + Healthy bool `json:"healthy"` + ResponseTime time.Duration `json:"response_time"` + LastCheck time.Time `json:"last_check"` + Errors []string `json:"errors,omitempty"` + Message string `json:"message,omitempty"` +} + +// ResourceLimits defines the resource constraints and limits +type ResourceLimits struct { + MaxConcurrentRequests int `json:"max_concurrent_requests"` + RateLimit RateLimitInfo `json:"rate_limit"` + Memory MemoryLimits `json:"memory"` + Processing ProcessingLimits `json:"processing"` +} + +// RateLimitInfo describes rate limiting constraints +type RateLimitInfo struct { + RequestsPerMinute int `json:"requests_per_minute"` + RequestsPerHour int `json:"requests_per_hour"` + TokensPerMinute int `json:"tokens_per_minute"` + TokensPerHour int `json:"tokens_per_hour"` +} + +// MemoryLimits describes memory usage constraints +type MemoryLimits struct { + MaxMemoryMB int `json:"max_memory_mb"` + CacheSizeMB int `json:"cache_size_mb"` + BufferSizeMB int `json:"buffer_size_mb"` +} + +// ProcessingLimits describes processing constraints +type ProcessingLimits struct { + MaxProcessingTimeSeconds int `json:"max_processing_time_seconds"` + MaxQueueSize int `json:"max_queue_size"` + MaxRetryAttempts int `json:"max_retry_attempts"` +} + +// CapabilityDetector handles dynamic capability detection and reporting +type CapabilityDetector struct { + config config.AIConfig + manager *Manager + cache *capabilityCache + healthChecker *CapabilityHealthChecker + mu sync.RWMutex + lastUpdate time.Time + updateInterval time.Duration +} + +// capabilityCache provides caching for capability information +type capabilityCache struct { + data *CapabilitiesResponse + mu sync.RWMutex + ttl time.Duration + timestamp time.Time +} + +// CapabilityHealthChecker manages health checking for various components +type CapabilityHealthChecker struct { + providers map[string]interfaces.AIClient + timeout time.Duration + mu sync.RWMutex +} + +// NewCapabilityDetector creates a new capability detector +func NewCapabilityDetector(cfg config.AIConfig, manager *Manager) *CapabilityDetector { + detector := &CapabilityDetector{ + config: cfg, + manager: manager, + updateInterval: 5 * time.Minute, // Default update interval + cache: &capabilityCache{ + ttl: 5 * time.Minute, // Default cache TTL + }, + healthChecker: &CapabilityHealthChecker{ + providers: make(map[string]interfaces.AIClient), + timeout: 10 * time.Second, + }, + } + + // Initialize health checker with available clients + if manager != nil { + for name, client := range manager.GetAllClients() { + detector.healthChecker.providers[name] = client + } + } + + return detector +} + +// GetCapabilities returns the comprehensive capability information +func (d *CapabilityDetector) GetCapabilities(ctx context.Context, req *CapabilitiesRequest) (*CapabilitiesResponse, error) { + d.mu.RLock() + // Check if we have cached data that's still valid + if d.cache.isValid() { + d.mu.RUnlock() + return d.getCachedCapabilities(req) + } + d.mu.RUnlock() + + // Need to refresh capabilities + d.mu.Lock() + defer d.mu.Unlock() + + // Double-check after acquiring write lock + if d.cache.isValid() { + return d.getCachedCapabilities(req) + } + + // Build fresh capability response + response := &CapabilitiesResponse{ + Version: "1.0.0", + LastUpdated: time.Now(), + Metadata: make(map[string]interface{}), + } + + // Collect models if requested + if req.IncludeModels { + models, err := d.detectModelCapabilities(ctx) + if err != nil { + return nil, fmt.Errorf("failed to detect model capabilities: %w", err) + } + response.Models = models + } + + // Collect database capabilities if requested + if req.IncludeDatabases { + response.Databases = d.detectDatabaseCapabilities() + } + + // Collect feature capabilities if requested + if req.IncludeFeatures { + response.Features = d.detectFeatureCapabilities() + } + + // Perform health checks if requested + if req.CheckHealth { + health, err := d.performHealthChecks(ctx) + if err != nil { + return nil, fmt.Errorf("failed to perform health checks: %w", err) + } + response.Health = *health + } + + // Always include resource limits + response.Limits = d.getResourceLimits() + + // Update cache + d.cache.update(response) + d.lastUpdate = time.Now() + + return response, nil +} + +// detectModelCapabilities discovers available AI models and their capabilities +func (d *CapabilityDetector) detectModelCapabilities(ctx context.Context) ([]ModelCapability, error) { + var capabilities []ModelCapability + var errs []error + + if d.manager == nil { + // Return default capabilities if no manager available + return []ModelCapability{ + { + Name: "basic", + Provider: "ollama", + Available: true, + Features: []string{"sql-generation", "text-generation"}, + Limitations: []string{"limited-model-capabilities"}, + MaxTokens: 4096, + ContextSize: 4096, + }, + }, nil + } + + // Get all available clients + clients := d.manager.GetAllClients() + for providerName, client := range clients { + // Get capabilities from each provider + clientCaps, err := client.GetCapabilities(ctx) + if err != nil { + // Log error but continue with other providers + capabilities = append(capabilities, ModelCapability{ + Name: providerName, + Provider: providerName, + Available: false, + Limitations: []string{fmt.Sprintf("capability_detection_error: %v", err)}, + }) + errs = append(errs, fmt.Errorf("provider %s capability detection failed: %w", providerName, err)) + continue + } + + // Convert provider capabilities to our format + for _, model := range clientCaps.Models { + capability := ModelCapability{ + Name: model.ID, + Provider: clientCaps.Provider, + Available: true, + Features: model.Capabilities, + MaxTokens: model.MaxTokens, + ContextSize: model.MaxTokens, + Metadata: map[string]string{ + "description": model.Description, + "name": model.Name, + }, + } + + // Add cost information if available + if model.InputCostPer1K > 0 || model.OutputCostPer1K > 0 { + capability.CostPer1K = &CostInfo{ + InputCost: model.InputCostPer1K, + OutputCost: model.OutputCostPer1K, + Currency: "USD", + } + } + + capabilities = append(capabilities, capability) + } + } + + if len(errs) > 0 { + return capabilities, errors.Join(errs...) + } + + return capabilities, nil +} + +// detectDatabaseCapabilities returns supported database types and features +func (d *CapabilityDetector) detectDatabaseCapabilities() []DatabaseCapability { + // Static database capabilities - could be enhanced with dynamic detection + return []DatabaseCapability{ + { + Type: "mysql", + Versions: []string{"5.7", "8.0", "8.1"}, + Features: []string{"joins", "subqueries", "cte", "window-functions", "stored-procedures"}, + Supported: true, + }, + { + Type: "postgresql", + Versions: []string{"12", "13", "14", "15", "16"}, + Features: []string{"joins", "subqueries", "cte", "window-functions", "stored-procedures", "json-functions", "arrays"}, + Supported: true, + }, + { + Type: "sqlite", + Versions: []string{"3.x"}, + Features: []string{"joins", "subqueries", "cte", "window-functions", "json-functions"}, + Supported: true, + }, + { + Type: "oracle", + Versions: []string{"11g", "12c", "19c", "21c"}, + Features: []string{"joins", "subqueries", "cte", "window-functions", "stored-procedures", "pl-sql"}, + Supported: false, + Limitations: []string{"not-implemented"}, + }, + { + Type: "sqlserver", + Versions: []string{"2016", "2017", "2019", "2022"}, + Features: []string{"joins", "subqueries", "cte", "window-functions", "stored-procedures", "t-sql"}, + Supported: false, + Limitations: []string{"not-implemented"}, + }, + } +} + +// detectFeatureCapabilities returns available plugin features +func (d *CapabilityDetector) detectFeatureCapabilities() []FeatureCapability { + features := []FeatureCapability{ + { + Name: "sql-generation", + Enabled: true, + Description: "Generate SQL queries from natural language descriptions", + Version: "1.0.0", + Parameters: map[string]string{ + "max_tokens": "2000", + "temperature": "0.3", + "database_type": "mysql,postgresql,sqlite", + }, + }, + { + Name: "sql-optimization", + Enabled: false, + Description: "Optimize existing SQL queries for better performance", + Version: "0.9.0", + Dependencies: []string{"sql-generation"}, + }, + { + Name: "sql-validation", + Enabled: true, + Description: "Validate generated SQL queries for syntax and logical correctness", + Version: "1.0.0", + Parameters: map[string]string{ + "strict_mode": "true", + }, + }, + { + Name: "schema-analysis", + Enabled: false, + Description: "Analyze database schema and suggest improvements", + Version: "0.8.0", + Dependencies: []string{"sql-generation", "sql-validation"}, + }, + { + Name: "query-explanation", + Enabled: true, + Description: "Provide detailed explanations for generated SQL queries", + Version: "1.0.0", + }, + { + Name: "multi-language-support", + Enabled: true, + Description: "Support for multiple natural languages in queries", + Version: "1.0.0", + Parameters: map[string]string{ + "supported_languages": "en,zh,es,fr,de,ja", + }, + }, + } + + // Dynamically adjust feature status based on configuration + if d.manager != nil && d.manager.GetPrimaryClient() != nil { + // If we have a working AI client, enable more features + for i := range features { + if features[i].Name == "sql-optimization" && len(features[i].Dependencies) == 1 { + features[i].Enabled = true + } + } + } + + return features +} + +// performHealthChecks executes health checks on all components +func (d *CapabilityDetector) performHealthChecks(ctx context.Context) (*HealthStatusReport, error) { + report := &HealthStatusReport{ + Overall: true, + Components: make(map[string]HealthInfo), + Providers: make(map[string]HealthInfo), + Timestamp: time.Now(), + } + + // Check component health + report.Components["engine"] = d.checkEngineHealth() + report.Components["cache"] = d.checkCacheHealth() + report.Components["config"] = d.checkConfigHealth() + + // Check provider health + d.healthChecker.mu.RLock() + for name, client := range d.healthChecker.providers { + report.Providers[name] = d.checkProviderHealth(ctx, client) + } + d.healthChecker.mu.RUnlock() + + // Determine overall health and collect error details + var errs []error + for name, health := range report.Components { + if !health.Healthy { + report.Overall = false + errs = append(errs, fmt.Errorf("component %s unhealthy: %s", name, summarizeHealth(health))) + } + } + + for name, health := range report.Providers { + if !health.Healthy { + report.Overall = false + errs = append(errs, fmt.Errorf("provider %s unhealthy: %s", name, summarizeHealth(health))) + } + } + + if len(errs) > 0 { + return report, errors.Join(errs...) + } + + return report, nil +} + +func summarizeHealth(health HealthInfo) string { + if len(health.Errors) == 0 { + return health.Message + } + + message := health.Message + if message == "" { + return strings.Join(health.Errors, "; ") + } + + return fmt.Sprintf("%s (%s)", message, strings.Join(health.Errors, "; ")) +} + +// checkEngineHealth checks the health of the AI engine +func (d *CapabilityDetector) checkEngineHealth() HealthInfo { + start := time.Now() + + if d.manager == nil { + return HealthInfo{ + Status: "unavailable", + Healthy: false, + ResponseTime: time.Since(start), + LastCheck: time.Now(), + Errors: []string{"no AI manager available"}, + Message: "AI manager not initialized", + } + } + + primaryClient := d.manager.GetPrimaryClient() + if primaryClient == nil { + return HealthInfo{ + Status: "degraded", + Healthy: false, + ResponseTime: time.Since(start), + LastCheck: time.Now(), + Errors: []string{"no primary client available"}, + Message: "Primary AI client not available", + } + } + + return HealthInfo{ + Status: "healthy", + Healthy: true, + ResponseTime: time.Since(start), + LastCheck: time.Now(), + Message: "AI engine operational", + } +} + +// checkCacheHealth checks the health of the capability cache +func (d *CapabilityDetector) checkCacheHealth() HealthInfo { + start := time.Now() + + d.cache.mu.RLock() + defer d.cache.mu.RUnlock() + + return HealthInfo{ + Status: "healthy", + Healthy: true, + ResponseTime: time.Since(start), + LastCheck: time.Now(), + Message: "Capability cache operational", + } +} + +// checkConfigHealth checks the health of the configuration +func (d *CapabilityDetector) checkConfigHealth() HealthInfo { + start := time.Now() + + var errors []string + + if d.config.DefaultService == "" { + errors = append(errors, "no default service configured") + } + + healthy := len(errors) == 0 + status := "healthy" + message := "Configuration valid" + + if !healthy { + status = "unhealthy" + message = "Configuration issues detected" + } + + return HealthInfo{ + Status: status, + Healthy: healthy, + ResponseTime: time.Since(start), + LastCheck: time.Now(), + Errors: errors, + Message: message, + } +} + +// checkProviderHealth checks the health of an AI provider +func (d *CapabilityDetector) checkProviderHealth(ctx context.Context, client interfaces.AIClient) HealthInfo { + start := time.Now() + + // Create context with timeout + healthCtx, cancel := context.WithTimeout(ctx, d.healthChecker.timeout) + defer cancel() + + healthStatus, err := client.HealthCheck(healthCtx) + responseTime := time.Since(start) + + if err != nil { + return HealthInfo{ + Status: "unhealthy", + Healthy: false, + ResponseTime: responseTime, + LastCheck: time.Now(), + Errors: []string{err.Error()}, + Message: "Health check failed", + } + } + + if healthStatus == nil { + return HealthInfo{ + Status: "unknown", + Healthy: false, + ResponseTime: responseTime, + LastCheck: time.Now(), + Errors: []string{"no health status returned"}, + Message: "Health status unavailable", + } + } + + status := "healthy" + if !healthStatus.Healthy { + status = "unhealthy" + } + + return HealthInfo{ + Status: status, + Healthy: healthStatus.Healthy, + ResponseTime: responseTime, + LastCheck: time.Now(), + Message: healthStatus.Status, + } +} + +// getResourceLimits returns current resource limits +func (d *CapabilityDetector) getResourceLimits() ResourceLimits { + return ResourceLimits{ + MaxConcurrentRequests: 10, // Could be configurable + RateLimit: RateLimitInfo{ + RequestsPerMinute: 60, + RequestsPerHour: 1000, + TokensPerMinute: 100000, + TokensPerHour: 500000, + }, + Memory: MemoryLimits{ + MaxMemoryMB: 512, + CacheSizeMB: 64, + BufferSizeMB: 32, + }, + Processing: ProcessingLimits{ + MaxProcessingTimeSeconds: 30, + MaxQueueSize: 100, + MaxRetryAttempts: 3, + }, + } +} + +// getCachedCapabilities returns filtered cached capabilities +func (d *CapabilityDetector) getCachedCapabilities(req *CapabilitiesRequest) (*CapabilitiesResponse, error) { + d.cache.mu.RLock() + defer d.cache.mu.RUnlock() + + if d.cache.data == nil { + return nil, fmt.Errorf("no cached data available") + } + + // Create a filtered response based on request + response := &CapabilitiesResponse{ + Version: d.cache.data.Version, + LastUpdated: d.cache.data.LastUpdated, + Metadata: d.cache.data.Metadata, + } + + if req.IncludeModels { + response.Models = d.cache.data.Models + } + if req.IncludeDatabases { + response.Databases = d.cache.data.Databases + } + if req.IncludeFeatures { + response.Features = d.cache.data.Features + } + if req.CheckHealth { + response.Health = d.cache.data.Health + } + + // Always include limits + response.Limits = d.cache.data.Limits + + return response, nil +} + +// isValid checks if cached data is still valid +func (c *capabilityCache) isValid() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.data != nil && time.Since(c.timestamp) < c.ttl +} + +// update updates the cached data +func (c *capabilityCache) update(data *CapabilitiesResponse) { + c.mu.Lock() + defer c.mu.Unlock() + + c.data = data + c.timestamp = time.Now() +} + +// InvalidateCache forces a cache invalidation +func (d *CapabilityDetector) InvalidateCache() { + d.mu.Lock() + defer d.mu.Unlock() + + d.cache.mu.Lock() + defer d.cache.mu.Unlock() + + d.cache.data = nil + d.cache.timestamp = time.Time{} +} + +// SetCacheTTL updates the cache TTL +func (d *CapabilityDetector) SetCacheTTL(ttl time.Duration) { + d.mu.Lock() + defer d.mu.Unlock() + + d.cache.mu.Lock() + defer d.cache.mu.Unlock() + + d.cache.ttl = ttl +} + +// GetLastUpdate returns the timestamp of the last capability update +func (d *CapabilityDetector) GetLastUpdate() time.Time { + d.mu.RLock() + defer d.mu.RUnlock() + + return d.lastUpdate +} diff --git a/pkg/ai/discovery/doc.go b/pkg/ai/discovery/doc.go new file mode 100644 index 0000000..b41c110 --- /dev/null +++ b/pkg/ai/discovery/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package discovery locates reachable AI endpoints for the manager at runtime. +package discovery diff --git a/pkg/ai/discovery/ollama.go b/pkg/ai/discovery/ollama.go new file mode 100644 index 0000000..43f709d --- /dev/null +++ b/pkg/ai/discovery/ollama.go @@ -0,0 +1,69 @@ +// Package discovery discovers local or remote Ollama endpoints for the AI manager. +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package discovery + +import ( + "context" + "net/http" + "time" +) + +// DefaultOllamaEndpoint is used when GUI configuration does not provide an endpoint. +const DefaultOllamaEndpoint = "http://localhost:11434" + +// OllamaDiscovery handles Ollama service discovery +// It only checks if Ollama is available, not model management. +// Model information should be retrieved through the AIClient interface. +type OllamaDiscovery struct { + baseURL string + httpClient *http.Client +} + +// NewOllamaDiscovery creates a new Ollama discovery instance +func NewOllamaDiscovery(baseURL string) *OllamaDiscovery { + if baseURL == "" { + baseURL = DefaultOllamaEndpoint + } + + return &OllamaDiscovery{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 5 * time.Second, + }, + } +} + +// IsAvailable checks if Ollama service is running +func (od *OllamaDiscovery) IsAvailable(ctx context.Context) bool { + req, err := http.NewRequestWithContext(ctx, "GET", od.baseURL+"/api/tags", nil) + if err != nil { + return false + } + + resp, err := od.httpClient.Do(req) + if err != nil { + return false + } + defer func() { _ = resp.Body.Close() }() + + return resp.StatusCode == http.StatusOK +} + +// GetBaseURL returns the configured Ollama base URL +func (od *OllamaDiscovery) GetBaseURL() string { + return od.baseURL +} diff --git a/pkg/ai/discovery/ollama_discovery_test.go b/pkg/ai/discovery/ollama_discovery_test.go new file mode 100644 index 0000000..96b951a --- /dev/null +++ b/pkg/ai/discovery/ollama_discovery_test.go @@ -0,0 +1,54 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package discovery + +import ( + "context" + "fmt" + "testing" +) + +// TestOllamaDiscovery tests Ollama service discovery functionality +func TestOllamaDiscovery(t *testing.T) { + discovery := NewOllamaDiscovery("http://localhost:11434") + ctx := context.Background() + + // Test IsAvailable - this is a connectivity test, may fail if Ollama is not running + available := discovery.IsAvailable(ctx) + if !available { + t.Log("Ollama is not available - this is expected if Ollama is not running") + } else { + fmt.Println("Ollama service is available") + } + + // Test GetBaseURL + baseURL := discovery.GetBaseURL() + if baseURL != "http://localhost:11434" { + t.Errorf("Expected base URL 'http://localhost:11434', got '%s'", baseURL) + } +} + +// TestOllamaDiscoveryWithCustomEndpoint tests discovery with custom endpoint +func TestOllamaDiscoveryWithCustomEndpoint(t *testing.T) { + customEndpoint := "http://custom-host:8080" + discovery := NewOllamaDiscovery(customEndpoint) + + baseURL := discovery.GetBaseURL() + if baseURL != customEndpoint { + t.Errorf("Expected base URL '%s', got '%s'", customEndpoint, baseURL) + } +} diff --git a/pkg/ai/doc.go b/pkg/ai/doc.go new file mode 100644 index 0000000..daf5948 --- /dev/null +++ b/pkg/ai/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package ai orchestrates AI provider clients, capability discovery, and SQL generation. +package ai diff --git a/pkg/ai/endpoints.go b/pkg/ai/endpoints.go new file mode 100644 index 0000000..cea1466 --- /dev/null +++ b/pkg/ai/endpoints.go @@ -0,0 +1,24 @@ +package ai + +import "strings" + +// normalizeProviderEndpoint trims provider endpoints to a canonical form so that +// universal clients can safely append API paths without duplicating segments like /v1. +func normalizeProviderEndpoint(provider, endpoint string) string { + trimmed := strings.TrimSpace(endpoint) + if trimmed == "" { + return "" + } + + trimmed = strings.TrimRight(trimmed, "/") + normalized := strings.ToLower(strings.TrimSpace(provider)) + + if normalized == "openai" || normalized == "deepseek" { + for strings.HasSuffix(trimmed, "/v1") { + trimmed = strings.TrimSuffix(trimmed, "/v1") + trimmed = strings.TrimRight(trimmed, "/") + } + } + + return trimmed +} diff --git a/pkg/ai/engine.go b/pkg/ai/engine.go new file mode 100644 index 0000000..c73ce26 --- /dev/null +++ b/pkg/ai/engine.go @@ -0,0 +1,318 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ai + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "github.com/linuxsuren/atest-ext-ai/pkg/config" + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" + "github.com/linuxsuren/atest-ext-ai/pkg/logging" +) + +// shouldIncludeDebugInfo checks if debug information should be included in responses +func shouldIncludeDebugInfo() bool { + return os.Getenv("APP_ENV") == "development" || os.Getenv("LOG_LEVEL") == "debug" +} + +// addDebugInfo conditionally adds debug information based on environment +func addDebugInfo(existing []string, info string) []string { + if shouldIncludeDebugInfo() { + return append(existing, info) + } + return existing +} + +// IsProviderNotSupported checks if an error is due to an unsupported provider +func IsProviderNotSupported(err error) bool { + if err == nil { + return false + } + // Check if the error is from the client factory + return errors.Is(err, ErrProviderNotSupported) +} + +// Engine defines the interface for AI SQL generation +type Engine interface { + GenerateSQL(ctx context.Context, req *GenerateSQLRequest) (*GenerateSQLResponse, error) + GetCapabilities() *SQLCapabilities + IsHealthy() bool + Close() +} + +// GenerateSQLRequest represents an AI SQL generation request +type GenerateSQLRequest struct { + NaturalLanguage string `json:"natural_language"` + DatabaseType string `json:"database_type"` + Context map[string]string `json:"context,omitempty"` +} + +// GenerateSQLResponse represents an AI SQL generation response +type GenerateSQLResponse struct { + SQL string `json:"sql"` + Explanation string `json:"explanation"` + ConfidenceScore float32 `json:"confidence_score"` + ProcessingTime time.Duration `json:"processing_time"` + RequestID string `json:"request_id"` + ModelUsed string `json:"model_used"` + DebugInfo []string `json:"debug_info,omitempty"` +} + +// SQLCapabilities represents AI engine capabilities for SQL generation +type SQLCapabilities struct { + SupportedDatabases []string `json:"supported_databases"` + Features []SQLFeature `json:"features"` +} + +// SQLFeature represents a specific AI SQL feature +type SQLFeature struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Description string `json:"description"` + Parameters map[string]string `json:"parameters,omitempty"` +} + +// aiEngine is the AI engine implementation using AI clients +type aiEngine struct { + config config.AIConfig + generator *SQLGenerator + aiClient interfaces.AIClient + manager *Manager +} + +// NewEngine creates a new AI engine based on configuration. +func NewEngine(cfg config.AIConfig) (Engine, error) { + manager, err := NewAIManager(cfg) + if err != nil { + if IsProviderNotSupported(err) { + logging.Logger.Error("Provider not supported - please use one of: openai, local, deepseek, custom", "error", err, "provider", cfg.DefaultService) + return nil, fmt.Errorf("unsupported AI provider '%s': %w. Supported providers: openai, local (ollama), deepseek, custom", cfg.DefaultService, err) + } + logging.Logger.Error("Failed to create AI manager", "error", err, "provider", cfg.DefaultService) + return nil, fmt.Errorf("failed to create AI manager for provider '%s': %w", cfg.DefaultService, err) + } + + engine, err := newEngineFromManager(manager, cfg) + if err != nil { + _ = manager.Close() + return nil, err + } + return engine, nil +} + +func newEngineFromManager(manager *Manager, cfg config.AIConfig) (Engine, error) { + var aiClient interfaces.AIClient + var err error + + if cfg.DefaultService != "" { + aiClient, err = manager.GetClient(cfg.DefaultService) + if err != nil { + logging.Logger.Error("No primary AI client available - check your configuration", "provider", cfg.DefaultService) + return nil, fmt.Errorf("no primary AI client available for provider '%s' - please check your configuration: %w", cfg.DefaultService, err) + } + } else { + clients := manager.GetAllClients() + for _, client := range clients { + aiClient = client + break + } + if aiClient == nil { + return nil, fmt.Errorf("no AI clients available - please check your configuration") + } + } + + generator, err := NewSQLGenerator(aiClient, cfg) + if err != nil { + logging.Logger.Error("Failed to create SQL generator", "error", err, "provider", cfg.DefaultService) + return nil, fmt.Errorf("failed to create SQL generator for provider '%s': %w", cfg.DefaultService, err) + } + + logging.Logger.Info("AI engine created successfully", "provider", cfg.DefaultService) + return &aiEngine{ + config: cfg, + generator: generator, + aiClient: aiClient, + manager: manager, + }, nil +} + +// NewEngineWithManager constructs an Engine using a pre-configured Manager. +func NewEngineWithManager(manager *Manager, cfg config.AIConfig) (Engine, error) { + if manager == nil { + return nil, fmt.Errorf("manager cannot be nil") + } + + engine, err := newEngineFromManager(manager, cfg) + if err != nil { + _ = manager.Close() + return nil, err + } + return engine, nil +} + +// NewOllamaEngine creates an Ollama-based AI engine +func NewOllamaEngine(cfg config.AIConfig) (Engine, error) { + return NewEngine(cfg) +} + +// NewOpenAIEngine creates an OpenAI-based AI engine +func NewOpenAIEngine(cfg config.AIConfig) (Engine, error) { + return NewEngine(cfg) +} + +// NewClaudeEngine creates a Claude-based AI engine +func NewClaudeEngine(cfg config.AIConfig) (Engine, error) { + return NewEngine(cfg) +} + +// GenerateSQL implements Engine.GenerateSQL with full AI integration +func (e *aiEngine) GenerateSQL(ctx context.Context, req *GenerateSQLRequest) (*GenerateSQLResponse, error) { + if e.generator == nil { + return nil, fmt.Errorf("SQL generator not initialized") + } + + // Get default max tokens from configuration + defaultMaxTokens := 2000 // fallback if config not available + if service, ok := e.config.Services[e.config.DefaultService]; ok && service.MaxTokens > 0 { + defaultMaxTokens = service.MaxTokens + } + + // Convert request to generator options + options := &GenerateOptions{ + DatabaseType: req.DatabaseType, + ValidateSQL: true, + OptimizeQuery: false, + IncludeExplanation: true, + SafetyMode: true, + MaxTokens: defaultMaxTokens, + } + + // Add context if provided and extract preferred_model and runtime config + var runtimeConfig map[string]interface{} + if len(req.Context) > 0 { + options.Context = make([]string, 0, len(req.Context)) + for key, value := range req.Context { + switch key { + case "preferred_model": + // Set the preferred model directly in options + options.Model = value + logging.Logger.Debug("AI engine: setting model from context", "model", value) + case "config": + // Parse runtime configuration for API keys etc. + if err := json.Unmarshal([]byte(value), &runtimeConfig); err != nil { + logging.Logger.Warn("Failed to parse runtime config", "error", err) + break + } + + logging.Logger.Debug("AI engine: parsed runtime config", + "provider", runtimeConfig["provider"], + "has_api_key", runtimeConfig["api_key"] != nil) + + // Extract configuration for dynamic client creation + if provider, ok := runtimeConfig["provider"].(string); ok { + // Map "local" to "ollama" for consistency + if provider == "local" { + provider = "ollama" + } + options.Provider = provider + } + if apiKey, ok := runtimeConfig["api_key"].(string); ok && apiKey != "" { + options.APIKey = apiKey + } + if endpoint, ok := runtimeConfig["endpoint"].(string); ok && endpoint != "" { + options.Endpoint = endpoint + } + if maxTokens, ok := runtimeConfig["max_tokens"].(float64); ok { + options.MaxTokens = int(maxTokens) + } else if maxTokens, ok := runtimeConfig["max_tokens"].(int); ok { + options.MaxTokens = maxTokens + } else if runtimeConfig["max_tokens"] != nil { + logging.Logger.Warn("Invalid max_tokens type in runtime config, using default", + "type", fmt.Sprintf("%T", runtimeConfig["max_tokens"]), + "value", runtimeConfig["max_tokens"], + "default", options.MaxTokens) + } + default: + // Add other context as strings + options.Context = append(options.Context, fmt.Sprintf("%s: %s", key, value)) + } + } + } + + // Generate SQL using the generator + result, err := e.generator.Generate(ctx, req.NaturalLanguage, options) + if err != nil { + return nil, fmt.Errorf("failed to generate SQL: %w", err) + } + + // Convert generator result to engine response + return &GenerateSQLResponse{ + SQL: result.SQL, + Explanation: result.Explanation, + ConfidenceScore: float32(result.ConfidenceScore), + ProcessingTime: result.Metadata.ProcessingTime, + RequestID: result.Metadata.RequestID, + ModelUsed: result.Metadata.ModelUsed, + DebugInfo: addDebugInfo(result.Metadata.DebugInfo, fmt.Sprintf("Query complexity: %s", result.Metadata.Complexity)), + }, nil +} + +// GetCapabilities implements Engine.GetCapabilities for AI engine +func (e *aiEngine) GetCapabilities() *SQLCapabilities { + if e.generator != nil { + return e.generator.GetCapabilities() + } + // Fallback to basic capabilities + return &SQLCapabilities{ + SupportedDatabases: []string{"mysql", "postgresql", "sqlite"}, + Features: []SQLFeature{ + { + Name: "SQL Generation", + Enabled: true, + Description: "AI-powered SQL generation from natural language", + }, + }, + } +} + +// IsHealthy implements Engine.IsHealthy for AI engine +func (e *aiEngine) IsHealthy() bool { + if e.manager != nil && e.aiClient != nil { + // Check if primary client is healthy + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + healthStatus, err := e.aiClient.HealthCheck(ctx) + return err == nil && healthStatus != nil && healthStatus.Healthy + } + return false +} + +// Close implements Engine.Close for AI engine +func (e *aiEngine) Close() { + if e.generator != nil { + e.generator.Close() + } + if e.manager != nil { + _ = e.manager.Close() + } +} diff --git a/pkg/ai/generator.go b/pkg/ai/generator.go new file mode 100644 index 0000000..6804a92 --- /dev/null +++ b/pkg/ai/generator.go @@ -0,0 +1,777 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ai + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/linuxsuren/atest-ext-ai/pkg/ai/providers/universal" + "github.com/linuxsuren/atest-ext-ai/pkg/config" + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" + "github.com/linuxsuren/atest-ext-ai/pkg/logging" +) + +// SQLGenerator handles SQL generation from natural language +type SQLGenerator struct { + aiClient interfaces.AIClient + sqlDialects map[string]SQLDialect + config config.AIConfig + capabilities *SQLCapabilities + runtimeClients map[string]interfaces.AIClient + runtimeMu sync.RWMutex +} + +// Table represents a database table structure +type Table struct { + Name string `json:"name"` + Columns []Column `json:"columns"` + PrimaryKey []string `json:"primary_key,omitempty"` + ForeignKeys []ForeignKey `json:"foreign_keys,omitempty"` + Indexes []Index `json:"indexes,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Column represents a table column +type Column struct { + Name string `json:"name"` + Type string `json:"type"` + Nullable bool `json:"nullable"` + DefaultValue string `json:"default_value,omitempty"` + Comment string `json:"comment,omitempty"` + MaxLength int `json:"max_length,omitempty"` + Precision int `json:"precision,omitempty"` + Scale int `json:"scale,omitempty"` +} + +// ForeignKey represents a foreign key relationship +type ForeignKey struct { + Name string `json:"name"` + Columns []string `json:"columns"` + ReferencedTable string `json:"referenced_table"` + ReferencedColumns []string `json:"referenced_columns"` + OnDelete string `json:"on_delete,omitempty"` + OnUpdate string `json:"on_update,omitempty"` +} + +// Index represents a table index +type Index struct { + Name string `json:"name"` + Columns []string `json:"columns"` + Unique bool `json:"unique"` + Type string `json:"type,omitempty"` +} + +// GenerateOptions contains options for SQL generation +type GenerateOptions struct { + DatabaseType string `json:"database_type"` + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` // Runtime provider override + APIKey string `json:"api_key,omitempty"` // Runtime API key + Endpoint string `json:"endpoint,omitempty"` // Runtime endpoint override + Schema map[string]Table `json:"schema,omitempty"` + Context []string `json:"context,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + ValidateSQL bool `json:"validate_sql"` + OptimizeQuery bool `json:"optimize_query"` + IncludeExplanation bool `json:"include_explanation"` + SafetyMode bool `json:"safety_mode"` + CustomPrompts map[string]string `json:"custom_prompts,omitempty"` +} + +// GenerationResult contains the complete result of SQL generation +type GenerationResult struct { + SQL string `json:"sql"` + Explanation string `json:"explanation"` + ConfidenceScore float64 `json:"confidence_score"` + Warnings []string `json:"warnings"` + Suggestions []string `json:"suggestions"` + Metadata GenerationMetadata `json:"metadata"` + ValidationResults []ValidationResult `json:"validation_results,omitempty"` +} + +// GenerationMetadata contains metadata about the generation process +type GenerationMetadata struct { + RequestID string `json:"request_id"` + ProcessingTime time.Duration `json:"processing_time"` + ModelUsed string `json:"model_used"` + DatabaseDialect string `json:"database_dialect"` + QueryType string `json:"query_type"` + TablesInvolved []string `json:"tables_involved,omitempty"` + Complexity string `json:"complexity"` + DebugInfo []string `json:"debug_info,omitempty"` +} + +// ValidationResult contains SQL validation information +type ValidationResult struct { + Type string `json:"type"` + Level string `json:"level"` // info, warning, error + Message string `json:"message"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + Suggestion string `json:"suggestion,omitempty"` +} + +// NewSQLGenerator creates a new SQL generator instance +func NewSQLGenerator(aiClient interfaces.AIClient, config config.AIConfig) (*SQLGenerator, error) { + if aiClient == nil { + return nil, fmt.Errorf("AI client cannot be nil") + } + + generator := &SQLGenerator{ + aiClient: aiClient, + config: config, + sqlDialects: make(map[string]SQLDialect), + runtimeClients: make(map[string]interfaces.AIClient), + } + + // Initialize SQL dialects + generator.initializeDialects() + + // Initialize capabilities + generator.capabilities = &SQLCapabilities{ + SupportedDatabases: []string{"mysql", "postgresql", "sqlite"}, + Features: []SQLFeature{ + { + Name: "Natural Language to SQL", + Enabled: true, + Description: "Convert natural language queries to SQL", + }, + { + Name: "Multi-dialect Support", + Enabled: true, + Description: "Support for MySQL, PostgreSQL, and SQLite", + }, + { + Name: "Schema-aware Generation", + Enabled: true, + Description: "Generate SQL based on provided database schema", + }, + { + Name: "Query Optimization", + Enabled: true, + Description: "Optimize generated SQL for performance", + }, + { + Name: "SQL Validation", + Enabled: true, + Description: "Validate generated SQL syntax", + }, + }, + } + + return generator, nil +} + +// Generate generates SQL from natural language input +func (g *SQLGenerator) Generate(ctx context.Context, naturalLanguage string, options *GenerateOptions) (*GenerationResult, error) { + start := time.Now() + requestID := fmt.Sprintf("sql_%d", start.UnixNano()) + + if naturalLanguage == "" { + return nil, fmt.Errorf("natural language query cannot be empty") + } + + if options == nil { + options = &GenerateOptions{ + DatabaseType: "mysql", + ValidateSQL: true, + OptimizeQuery: false, + IncludeExplanation: true, + SafetyMode: true, + MaxTokens: 2000, + } + } + + // Get SQL dialect + dialect, exists := g.sqlDialects[options.DatabaseType] + if !exists { + return nil, fmt.Errorf("unsupported database type: %s", options.DatabaseType) + } + + // Prepare the prompt for AI + prompt := g.buildPrompt(naturalLanguage, options, dialect) + + // Create AI request + aiRequest := &interfaces.GenerateRequest{ + Prompt: prompt, + Model: options.Model, + MaxTokens: options.MaxTokens, + SystemPrompt: g.getSystemPrompt(options.DatabaseType), + } + + // Select AI client - use runtime client if provider/API key specified, otherwise use default + aiClient := g.aiClient + + // Check if we need to create a runtime client with API key + if options.Provider != "" && options.APIKey != "" { + logging.Logger.Debug("Attempting to use runtime AI client", + "provider", options.Provider, + "has_api_key", options.APIKey != "", + "endpoint", options.Endpoint) + + runtimeClient, reused, err := g.getOrCreateRuntimeClient(options) + if err != nil { + logging.Logger.Error("Failed to prepare runtime client", + "provider", options.Provider, + "error", err) + return nil, fmt.Errorf("runtime client creation failed for provider %s: %w", + options.Provider, err) + } + + aiClient = runtimeClient + if reused { + logging.Logger.Debug("Reusing cached runtime AI client", + "provider", options.Provider, + "endpoint", options.Endpoint) + } else { + logging.Logger.Info("Runtime AI client created and cached", + "provider", options.Provider, + "endpoint", options.Endpoint) + } + } + + // Call AI service + aiResponse, err := aiClient.Generate(ctx, aiRequest) + if err != nil { + return nil, fmt.Errorf("AI generation failed: %w", err) + } + + // Parse and validate the response + result := g.parseAIResponse(aiResponse, options, dialect, requestID, start) + return result, nil +} + +// initializeDialects initializes SQL dialect support +func (g *SQLGenerator) initializeDialects() { + // Initialize MySQL dialect + g.sqlDialects["mysql"] = &MySQLDialect{} + + // Initialize PostgreSQL dialect + g.sqlDialects["postgresql"] = &PostgreSQLDialect{} + g.sqlDialects["postgres"] = &PostgreSQLDialect{} + + // Initialize SQLite dialect + g.sqlDialects["sqlite"] = &SQLiteDialect{} + +} + +// buildPrompt constructs the AI prompt for SQL generation +func (g *SQLGenerator) buildPrompt(naturalLanguage string, options *GenerateOptions, dialect SQLDialect) string { + var promptBuilder strings.Builder + + // Add custom prompt if provided + if customPrompt, exists := options.CustomPrompts["sql_generation"]; exists { + promptBuilder.WriteString(customPrompt + "\n\n") + } else { + // Default SQL generation prompt + promptBuilder.WriteString("Generate a SQL query based on the following natural language description.\n\n") + } + + // Add database-specific context + promptBuilder.WriteString(fmt.Sprintf("Database Type: %s\n", options.DatabaseType)) + promptBuilder.WriteString(fmt.Sprintf("SQL Dialect: %s\n\n", dialect.Name())) + + // Add schema information if provided + if len(options.Schema) > 0 { + promptBuilder.WriteString("Database Schema:\n") + for tableName, table := range options.Schema { + promptBuilder.WriteString(fmt.Sprintf("Table: %s\n", tableName)) + for _, column := range table.Columns { + nullable := "NOT NULL" + if column.Nullable { + nullable = "NULL" + } + promptBuilder.WriteString(fmt.Sprintf(" - %s %s %s", column.Name, column.Type, nullable)) + if column.Comment != "" { + promptBuilder.WriteString(fmt.Sprintf(" -- %s", column.Comment)) + } + promptBuilder.WriteString("\n") + } + promptBuilder.WriteString("\n") + } + } + + // Add context information + if len(options.Context) > 0 { + promptBuilder.WriteString("Additional Context:\n") + for _, ctx := range options.Context { + promptBuilder.WriteString(fmt.Sprintf("- %s\n", ctx)) + } + promptBuilder.WriteString("\n") + } + + // Add safety constraints if enabled + if options.SafetyMode { + promptBuilder.WriteString("Safety Requirements:\n") + promptBuilder.WriteString("- Do not generate DROP, DELETE, or TRUNCATE statements unless explicitly requested\n") + promptBuilder.WriteString("- Include appropriate WHERE clauses to prevent accidental data modification\n") + promptBuilder.WriteString("- Use prepared statement placeholders for user inputs\n") + promptBuilder.WriteString("- Validate that the query follows security best practices\n\n") + } + + // Add the natural language query + promptBuilder.WriteString("Natural Language Query:\n") + promptBuilder.WriteString(naturalLanguage) + promptBuilder.WriteString("\n\n") + + // Add format requirements + promptBuilder.WriteString("Response Format:\n") + promptBuilder.WriteString("Please provide the response in the following simple format:\n") + promptBuilder.WriteString("sql:\n") + if options.IncludeExplanation { + promptBuilder.WriteString("explanation:\n") + } + promptBuilder.WriteString("\nExample:\n") + promptBuilder.WriteString("sql:SELECT * FROM users WHERE age > 18;\n") + if options.IncludeExplanation { + promptBuilder.WriteString("explanation:This query selects all users older than 18 years.\n") + } + + return promptBuilder.String() +} + +// getSystemPrompt returns the system prompt for SQL generation +func (g *SQLGenerator) getSystemPrompt(databaseType string) string { + return fmt.Sprintf(`You are an expert SQL database assistant specializing in %s. +Your task is to convert natural language queries into accurate, efficient SQL statements. + +Key principles: +1. Generate syntactically correct SQL for %s +2. Follow security best practices +3. Optimize for readability and performance +4. Provide clear explanations when requested +5. Include appropriate error handling +6. Use standard SQL when possible, dialect-specific features only when necessary + +Always respond in the exact format requested: sql: explanation:`, databaseType, databaseType) +} + +// parseAIResponse parses and validates the AI response +func (g *SQLGenerator) parseAIResponse(aiResponse *interfaces.GenerateResponse, options *GenerateOptions, dialect SQLDialect, requestID string, startTime time.Time) *GenerationResult { + // Try to extract JSON from the response + sqlResult := g.extractSQLFromResponse(aiResponse.Text) + + // Create generation result + result := &GenerationResult{ + SQL: sqlResult.SQL, + Explanation: sqlResult.Explanation, + ConfidenceScore: sqlResult.Confidence, + Warnings: sqlResult.Warnings, + Suggestions: sqlResult.Suggestions, + Metadata: GenerationMetadata{ + RequestID: requestID, + ProcessingTime: time.Since(startTime), + ModelUsed: aiResponse.Model, + DatabaseDialect: options.DatabaseType, + QueryType: sqlResult.QueryType, + TablesInvolved: sqlResult.TablesInvolved, + Complexity: g.assessComplexity(sqlResult.SQL), + }, + } + + // Validate SQL if requested + if options.ValidateSQL { + validationResults, err := dialect.ValidateSQL(sqlResult.SQL) + if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("SQL validation failed: %v", err)) + } else { + result.ValidationResults = validationResults + } + } + + // Optimize query if requested + if options.OptimizeQuery { + optimizedSQL, suggestions, err := dialect.OptimizeSQL(sqlResult.SQL) + if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("SQL optimization failed: %v", err)) + } else { + result.SQL = optimizedSQL + result.Suggestions = append(result.Suggestions, suggestions...) + } + } + + return result +} + +// SQLResponse represents the structured response from AI +type SQLResponse struct { + SQL string `json:"sql"` + Explanation string `json:"explanation,omitempty"` + Confidence float64 `json:"confidence"` + QueryType string `json:"query_type"` + TablesInvolved []string `json:"tables_involved"` + Warnings []string `json:"warnings"` + Suggestions []string `json:"suggestions"` +} + +// extractSQLFromResponse extracts structured SQL information from AI response +func (g *SQLGenerator) extractSQLFromResponse(responseText string) *SQLResponse { + responseText = strings.TrimSpace(responseText) + + // DEBUG: Log the raw AI response to understand what we're getting + logging.Logger.Debug("AI response received", "response_length", len(responseText), "response_preview", truncateString(responseText, 100)) + + // First try to parse the new simple format: "sql:...\nexplanation:..." + if strings.HasPrefix(responseText, "sql:") { + // Try with newline separator first + parts := strings.SplitN(responseText, "\nexplanation:", 2) + if len(parts) == 1 { + // Fallback to space separator for backward compatibility + parts = strings.SplitN(responseText, " explanation:", 2) + } + + sql := strings.TrimSpace(strings.TrimPrefix(parts[0], "sql:")) + + explanation := "Generated SQL query based on natural language input" + if len(parts) > 1 { + explanation = strings.TrimSpace(parts[1]) + } + + return &SQLResponse{ + SQL: sql, + Explanation: explanation, + Confidence: 0.8, + QueryType: g.detectQueryType(sql), + TablesInvolved: g.extractTableNames(sql), + Warnings: []string{}, + Suggestions: []string{}, + } + } + + // Fallback: Check if it looks like JSON (for backward compatibility) + if strings.HasPrefix(responseText, "{") && strings.HasSuffix(responseText, "}") { + var jsonResponse SQLResponse + if err := json.Unmarshal([]byte(responseText), &jsonResponse); err == nil { + // Successfully parsed JSON + if jsonResponse.SQL != "" { + // Clean up the SQL + sql := strings.TrimSpace(jsonResponse.SQL) + sql = strings.TrimPrefix(sql, "```sql") + sql = strings.TrimPrefix(sql, "```json") + sql = strings.TrimPrefix(sql, "```") + sql = strings.TrimSuffix(sql, "```") + sql = strings.TrimSpace(sql) + + // Extract explanation + explanation := strings.TrimSpace(jsonResponse.Explanation) + if explanation == "" { + explanation = "Generated SQL query based on natural language input" + } + + // Return a simplified SQLResponse with only SQL and explanation + return &SQLResponse{ + SQL: sql, + Explanation: explanation, + Confidence: 0.8, + QueryType: g.detectQueryType(sql), + TablesInvolved: g.extractTableNames(sql), + Warnings: []string{}, + Suggestions: []string{}, + } + } + } + } + + // If neither format worked, try to extract SQL from plain text + sql := strings.TrimSpace(responseText) + + // Remove common prefixes and suffixes + sql = strings.TrimPrefix(sql, "```sql") + sql = strings.TrimPrefix(sql, "```json") + sql = strings.TrimPrefix(sql, "```") + sql = strings.TrimSuffix(sql, "```") + sql = strings.TrimSpace(sql) + + // If it's still empty, provide a default + if sql == "" { + sql = "SELECT 1 as placeholder;" + } + + return &SQLResponse{ + SQL: sql, + Explanation: "Generated SQL query based on natural language input", + Confidence: 0.8, + QueryType: g.detectQueryType(sql), + TablesInvolved: g.extractTableNames(sql), + Warnings: []string{}, + Suggestions: []string{}, + } +} + +// detectQueryType determines the type of SQL query +func (g *SQLGenerator) detectQueryType(sql string) string { + upper := strings.ToUpper(strings.TrimSpace(sql)) + + switch { + case strings.HasPrefix(upper, "SELECT"): + return "SELECT" + case strings.HasPrefix(upper, "INSERT"): + return "INSERT" + case strings.HasPrefix(upper, "UPDATE"): + return "UPDATE" + case strings.HasPrefix(upper, "DELETE"): + return "DELETE" + case strings.HasPrefix(upper, "CREATE"): + return "CREATE" + case strings.HasPrefix(upper, "DROP"): + return "DROP" + case strings.HasPrefix(upper, "ALTER"): + return "ALTER" + } + + return "UNKNOWN" +} + +// extractTableNames extracts table names from SQL query +func (g *SQLGenerator) extractTableNames(sql string) []string { + // Simplified table extraction - in practice, you'd want more sophisticated parsing + tables := []string{} + + // Look for FROM and JOIN keywords + upper := strings.ToUpper(sql) + words := strings.Fields(upper) + + for i, word := range words { + if (word == "FROM" || word == "JOIN" || word == "UPDATE" || word == "INTO") && i+1 < len(words) { + tableName := words[i+1] + // Remove common SQL keywords and punctuation + tableName = strings.TrimSuffix(tableName, ",") + tableName = strings.TrimSuffix(tableName, "(") + if tableName != "" && !contains(tables, tableName) { + tables = append(tables, tableName) + } + } + } + + return tables +} + +// assessComplexity assesses the complexity of the generated SQL +func (g *SQLGenerator) assessComplexity(sql string) string { + upper := strings.ToUpper(sql) + + // Count complex features + complexity := 0 + + if strings.Contains(upper, "JOIN") { + complexity++ + } + if strings.Contains(upper, "SUBQUERY") || strings.Count(upper, "(SELECT") > 0 { + complexity++ + } + if strings.Contains(upper, "GROUP BY") { + complexity++ + } + if strings.Contains(upper, "HAVING") { + complexity++ + } + if strings.Contains(upper, "UNION") { + complexity++ + } + if strings.Contains(upper, "WITH") { // CTE + complexity++ + } + if strings.Contains(upper, "WINDOW") || strings.Contains(upper, "OVER") { + complexity++ + } + + switch { + case complexity == 0: + return "simple" + case complexity <= 2: + return "moderate" + case complexity <= 4: + return "complex" + default: + return "very_complex" + } +} + +// GetCapabilities returns the SQL generation capabilities +func (g *SQLGenerator) GetCapabilities() *SQLCapabilities { + return g.capabilities +} + +// contains checks if a string slice contains a specific string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func runtimeClientKey(options *GenerateOptions) string { + hasher := sha256.New() + hasher.Write([]byte(options.Provider)) + hasher.Write([]byte("|")) + hasher.Write([]byte(options.Endpoint)) + hasher.Write([]byte("|")) + hasher.Write([]byte(options.Model)) + hasher.Write([]byte("|")) + hasher.Write([]byte(options.APIKey)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +func (g *SQLGenerator) getOrCreateRuntimeClient(options *GenerateOptions) (interfaces.AIClient, bool, error) { + key := runtimeClientKey(options) + g.runtimeMu.RLock() + if client, ok := g.runtimeClients[key]; ok { + g.runtimeMu.RUnlock() + return client, true, nil + } + g.runtimeMu.RUnlock() + + runtimeConfig := map[string]any{ + "provider": options.Provider, + "api_key": options.APIKey, + } + if options.Endpoint != "" { + runtimeConfig["base_url"] = options.Endpoint + } + if options.Model != "" { + runtimeConfig["model"] = options.Model + } + if options.MaxTokens > 0 { + runtimeConfig["max_tokens"] = options.MaxTokens + } + + client, err := createRuntimeClient(options.Provider, runtimeConfig) + if err != nil { + return nil, false, err + } + + g.runtimeMu.Lock() + if existing, ok := g.runtimeClients[key]; ok { + g.runtimeMu.Unlock() + _ = client.Close() + return existing, true, nil + } + g.runtimeClients[key] = client + g.runtimeMu.Unlock() + + return client, false, nil +} + +// Close releases all cached runtime clients held by the generator. +func (g *SQLGenerator) Close() { + g.runtimeMu.Lock() + defer g.runtimeMu.Unlock() + for key, client := range g.runtimeClients { + _ = client.Close() + delete(g.runtimeClients, key) + } +} + +// createRuntimeClient creates an AI client from runtime configuration +func createRuntimeClient(provider string, runtimeConfig map[string]any) (interfaces.AIClient, error) { + // Normalize provider name (local -> ollama) + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "local" { + provider = "ollama" + } + + // Extract common configuration values + apiKey := "" + if val, ok := runtimeConfig["api_key"].(string); ok { + apiKey = val + } + + baseURL := "" + if val, ok := runtimeConfig["base_url"].(string); ok { + baseURL = val + } + + model := "" + if val, ok := runtimeConfig["model"].(string); ok { + model = val + } + + maxTokens := 2000 + if val, ok := runtimeConfig["max_tokens"].(float64); ok { + maxTokens = int(val) + } else if val, ok := runtimeConfig["max_tokens"].(int); ok { + maxTokens = val + } else if runtimeConfig["max_tokens"] != nil { + logging.Logger.Warn("Invalid max_tokens type, using default", + "type", fmt.Sprintf("%T", runtimeConfig["max_tokens"]), + "value", runtimeConfig["max_tokens"], + "default", maxTokens) + } + + // Create client based on provider type + normalizedProvider := normalizeProviderName(provider) + + switch normalizedProvider { + case "openai", "deepseek", "custom": + config := &universal.Config{ + Provider: normalizedProvider, + Endpoint: normalizeProviderEndpoint(normalizedProvider, baseURL), + APIKey: apiKey, + Model: model, + MaxTokens: maxTokens, + } + + if config.Endpoint == "" { + switch normalizedProvider { + case "openai": + config.Endpoint = "https://api.openai.com" + case "deepseek": + config.Endpoint = "https://api.deepseek.com" + case "custom": + return nil, fmt.Errorf("endpoint is required for custom provider") + } + } + + return universal.NewUniversalClient(config) + + case "ollama": + // Create Ollama client (using universal provider) + config := &universal.Config{ + Provider: "ollama", + Endpoint: normalizeProviderEndpoint("ollama", baseURL), + Model: model, + MaxTokens: maxTokens, + } + + // Default endpoint for Ollama + if config.Endpoint == "" { + config.Endpoint = "http://localhost:11434" + } + + return universal.NewUniversalClient(config) + + default: + return nil, fmt.Errorf("%w: %s", ErrProviderNotSupported, provider) + } +} + +// truncateString truncates a string to the specified length, adding "..." if truncated +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/pkg/ai/generator_test.go b/pkg/ai/generator_test.go new file mode 100644 index 0000000..ab0e129 --- /dev/null +++ b/pkg/ai/generator_test.go @@ -0,0 +1,37 @@ +package ai + +import ( + "testing" + + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" + "github.com/stretchr/testify/require" +) + +func TestRuntimeClientReuseAndClose(t *testing.T) { + generator := &SQLGenerator{ + runtimeClients: make(map[string]interfaces.AIClient), + } + + options := &GenerateOptions{ + Provider: "ollama", + APIKey: "test-key", + Endpoint: "http://localhost:11434", + MaxTokens: 512, + } + + client1, reused1, err := generator.getOrCreateRuntimeClient(options) + require.NoError(t, err) + require.False(t, reused1) + require.NotNil(t, client1) + + client2, reused2, err := generator.getOrCreateRuntimeClient(options) + require.NoError(t, err) + require.True(t, reused2) + require.Equal(t, client1, client2) + + generator.Close() + + _, reused3, err := generator.getOrCreateRuntimeClient(options) + require.NoError(t, err) + require.False(t, reused3) +} diff --git a/pkg/ai/manager.go b/pkg/ai/manager.go new file mode 100644 index 0000000..da911d7 --- /dev/null +++ b/pkg/ai/manager.go @@ -0,0 +1,746 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ai + +import ( + "context" + cryptorand "crypto/rand" + "errors" + "fmt" + "math/big" + "net" + "strings" + "sync" + "syscall" + "time" + + "github.com/linuxsuren/atest-ext-ai/pkg/ai/discovery" + "github.com/linuxsuren/atest-ext-ai/pkg/ai/providers/universal" + "github.com/linuxsuren/atest-ext-ai/pkg/config" + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" + "github.com/linuxsuren/atest-ext-ai/pkg/logging" +) + +var ( + // ErrProviderNotSupported is returned when an unsupported provider is requested + ErrProviderNotSupported = errors.New("provider not supported") + + // ErrNoHealthyClients is returned when no healthy clients are available + ErrNoHealthyClients = errors.New("no healthy clients available") + + // ErrClientNotFound is returned when a specific client is not found + ErrClientNotFound = errors.New("client not found") + + // ErrInvalidConfig is returned when the configuration is invalid + ErrInvalidConfig = errors.New("invalid configuration") +) + +// ProviderInfo represents information about an AI provider +type ProviderInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Available bool `json:"available"` + Endpoint string `json:"endpoint"` + Models []interfaces.ModelInfo `json:"models"` + LastChecked time.Time `json:"last_checked"` + Config map[string]interface{} `json:"config,omitempty"` + Health *interfaces.HealthStatus `json:"health,omitempty"` +} + +// ConnectionTestResult represents the result of a connection test +type ConnectionTestResult struct { + Success bool `json:"success"` + Message string `json:"message"` + ResponseTime time.Duration `json:"response_time"` + Provider string `json:"provider"` + Model string `json:"model,omitempty"` + Error string `json:"error,omitempty"` +} + +// AddClientOptions configures how a client is added to the manager +type AddClientOptions struct { + SkipHealthCheck bool // If true, skip health check during client addition + HealthCheckTimeout time.Duration // Timeout for health check (default: 5 seconds) +} + +// Manager is the unified manager for all AI clients. +// It merges the functionality of ClientManager and ProviderManager. +type Manager struct { + clients map[string]interfaces.AIClient + config config.AIConfig + discovery *discovery.OllamaDiscovery + mu sync.RWMutex +} + +// NewAIManager creates a new unified AI manager. +func NewAIManager(cfg config.AIConfig) (*Manager, error) { + // The GUI drives provider configuration, so we only consume data from cfg. + endpoint := discovery.DefaultOllamaEndpoint + if ollamaSvc, ok := cfg.Services["ollama"]; ok { + if ep := strings.TrimSpace(ollamaSvc.Endpoint); ep != "" { + endpoint = ep + } + } + + manager := &Manager{ + clients: make(map[string]interfaces.AIClient), + config: cfg, + discovery: discovery.NewOllamaDiscovery(endpoint), + } + + // Initialize configured clients + if err := manager.initializeClients(); err != nil { + return nil, fmt.Errorf("failed to initialize clients: %w", err) + } + + return manager, nil +} + +// ===== Client Management (from ClientManager) ===== + +// initializeClients creates clients for all enabled services +func (m *Manager) initializeClients() error { + m.mu.Lock() + defer m.mu.Unlock() + + for name, svc := range m.config.Services { + if !svc.Enabled { + continue + } + + client, err := createClient(name, svc) + if err != nil { + return fmt.Errorf("failed to create client %s: %w", name, err) + } + + m.clients[name] = client + } + + return nil +} + +// Generate executes an AI generation request with inline retry logic +func (m *Manager) Generate(ctx context.Context, req *interfaces.GenerateRequest) (*interfaces.GenerateResponse, error) { + var lastErr error + maxAttempts := 3 + + // Apply retry configuration if available + if m.config.Retry.MaxAttempts > 0 { + maxAttempts = m.config.Retry.MaxAttempts + } + + for attempt := 0; attempt < maxAttempts; attempt++ { + // Calculate backoff delay for retry attempts + if attempt > 0 { + delay := calculateBackoff(attempt, m.config.Retry) + + select { + case <-time.After(delay): + // Continue with retry + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + // Select a healthy client + client := m.selectHealthyClient() + if client == nil { + lastErr = ErrNoHealthyClients + continue + } + + // Execute the generation request + resp, err := client.Generate(ctx, req) + if err != nil { + // Check if error is retryable + if !isRetryableError(err) { + return nil, err + } + lastErr = err + continue + } + + return resp, nil + } + + return nil, fmt.Errorf("all retry attempts failed: %w", lastErr) +} + +// selectHealthyClient selects the best available client +func (m *Manager) selectHealthyClient() interfaces.AIClient { + m.mu.RLock() + defer m.mu.RUnlock() + + // Try default service first + if m.config.DefaultService != "" { + if client, ok := m.clients[m.config.DefaultService]; ok { + return client + } + } + + // Return any available client + for _, client := range m.clients { + return client + } + + return nil +} + +// GetClient returns a specific client by name +func (m *Manager) GetClient(name string) (interfaces.AIClient, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + client, exists := m.clients[name] + if !exists { + return nil, fmt.Errorf("%w: %s", ErrClientNotFound, name) + } + + return client, nil +} + +// GetAllClients returns all available clients +func (m *Manager) GetAllClients() map[string]interfaces.AIClient { + m.mu.RLock() + defer m.mu.RUnlock() + + // Create a copy to avoid concurrent access issues + clients := make(map[string]interfaces.AIClient) + for name, client := range m.clients { + clients[name] = client + } + + return clients +} + +// GetPrimaryClient returns the primary (default) client +func (m *Manager) GetPrimaryClient() interfaces.AIClient { + m.mu.RLock() + defer m.mu.RUnlock() + + // Try to get default service client + if m.config.DefaultService != "" { + if client, ok := m.clients[m.config.DefaultService]; ok { + return client + } + } + + // Return any available client as fallback + for _, client := range m.clients { + return client + } + + return nil +} + +// AddClient adds a new client with the given configuration +func (m *Manager) AddClient(ctx context.Context, name string, svc config.AIService, opts *AddClientOptions) error { + // Set default options if not provided + if opts == nil { + opts = &AddClientOptions{ + SkipHealthCheck: false, + HealthCheckTimeout: 5 * time.Second, + } + } + + // Set default timeout if not specified + if opts.HealthCheckTimeout == 0 { + opts.HealthCheckTimeout = 5 * time.Second + } + + client, err := createClient(name, svc) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Optional health check + if !opts.SkipHealthCheck { + healthCtx, cancel := context.WithTimeout(ctx, opts.HealthCheckTimeout) + defer cancel() + + health, err := client.HealthCheck(healthCtx) + if err != nil { + logging.Logger.Warn("Health check failed during client addition", + "client", name, + "error", err, + "action", "client will be added but may be unhealthy") + // Don't return error, just log warning + } else if health != nil && !health.Healthy { + logging.Logger.Warn("Client added but reports unhealthy status", + "client", name, + "status", health.Status) + } + } + + m.mu.Lock() + defer m.mu.Unlock() + + // Close old client if exists + if oldClient, exists := m.clients[name]; exists { + _ = oldClient.Close() + } + + m.clients[name] = client + logging.Logger.Info("AI client added successfully", + "client", name, + "skip_health_check", opts.SkipHealthCheck) + + return nil +} + +// RemoveClient removes a client +func (m *Manager) RemoveClient(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + client, exists := m.clients[name] + if !exists { + return fmt.Errorf("%w: %s", ErrClientNotFound, name) + } + + _ = client.Close() + delete(m.clients, name) + return nil +} + +// ===== Provider Discovery (from ProviderManager) ===== + +// DiscoverProviders discovers available AI providers +func (m *Manager) DiscoverProviders(ctx context.Context) ([]*ProviderInfo, error) { + var providers []*ProviderInfo + + // Check for Ollama + if m.discovery.IsAvailable(ctx) { + endpoint := m.discovery.GetBaseURL() + + // Create temporary Ollama client for discovery + config := &universal.Config{ + Provider: "ollama", + Endpoint: endpoint, + Model: "llama2", + MaxTokens: 4096, + } + + client, err := universal.NewUniversalClient(config) + if err == nil { + // Get models + var models []interfaces.ModelInfo + if caps, err := client.GetCapabilities(ctx); err == nil { + models = caps.Models + } + + provider := &ProviderInfo{ + Name: "ollama", + Type: "local", + Available: true, + Endpoint: endpoint, + Models: models, + LastChecked: time.Now(), + } + + providers = append(providers, provider) + _ = client.Close() + } + } + + // Add online providers + providers = append(providers, m.getOnlineProviders()...) + + return providers, nil +} + +// GetModels returns models for a specific provider +func (m *Manager) GetModels(ctx context.Context, providerName string) ([]interfaces.ModelInfo, error) { + // Normalize provider name (local -> ollama) + providerName = normalizeProviderName(providerName) + + m.mu.RLock() + client, exists := m.clients[providerName] + m.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("provider %s not found", providerName) + } + + caps, err := client.GetCapabilities(ctx) + if err != nil { + return nil, err + } + + return caps.Models, nil +} + +// TestConnection tests the connection to a provider +func (m *Manager) TestConnection(ctx context.Context, cfg *universal.Config) (*ConnectionTestResult, error) { + start := time.Now() + + if cfg == nil { + return &ConnectionTestResult{ + Success: false, + Message: "Invalid configuration", + ResponseTime: time.Since(start), + Error: "configuration cannot be nil", + }, nil + } + + client, err := universal.NewUniversalClient(cfg) + if err != nil { + return &ConnectionTestResult{ + Success: false, + Message: "Failed to create client", + ResponseTime: time.Since(start), + Provider: cfg.Provider, + Error: err.Error(), + }, nil + } + defer func() { _ = client.Close() }() + + health, err := client.HealthCheck(ctx) + if err != nil { + return &ConnectionTestResult{ + Success: false, + Message: "Health check failed", + ResponseTime: time.Since(start), + Provider: cfg.Provider, + Model: cfg.Model, + Error: err.Error(), + }, nil + } + + message := "Connection successful" + if !health.Healthy { + message = health.Status + } + + return &ConnectionTestResult{ + Success: health.Healthy, + Message: message, + ResponseTime: health.ResponseTime, + Provider: cfg.Provider, + Model: cfg.Model, + }, nil +} + +// ===== On-Demand Health Checking ===== + +// HealthCheck checks health of a specific provider +func (m *Manager) HealthCheck(ctx context.Context, provider string) (*interfaces.HealthStatus, error) { + provider = normalizeProviderName(provider) + + m.mu.RLock() + client, exists := m.clients[provider] + m.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("provider not found: %s", provider) + } + + return client.HealthCheck(ctx) +} + +// HealthCheckAll checks health of all providers +func (m *Manager) HealthCheckAll(ctx context.Context) map[string]*interfaces.HealthStatus { + m.mu.RLock() + clients := make(map[string]interfaces.AIClient) + for name, client := range m.clients { + clients[name] = client + } + m.mu.RUnlock() + + results := make(map[string]*interfaces.HealthStatus) + + // Check each client concurrently + var wg sync.WaitGroup + var mu sync.Mutex + + for name, client := range clients { + wg.Add(1) + + go func(name string, client interfaces.AIClient) { + defer wg.Done() + + status, err := client.HealthCheck(ctx) + if err != nil { + status = &interfaces.HealthStatus{ + Healthy: false, + Status: err.Error(), + } + } + + mu.Lock() + results[name] = status + mu.Unlock() + }(name, client) + } + + wg.Wait() + return results +} + +// Close closes all clients +func (m *Manager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + var errors []error + for name, client := range m.clients { + if err := client.Close(); err != nil { + errors = append(errors, fmt.Errorf("failed to close client %s: %w", name, err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("errors occurred while closing clients: %v", errors) + } + + return nil +} + +// ===== Helper Functions ===== + +// createClient creates a client based on provider name and configuration +func createClient(provider string, cfg config.AIService) (interfaces.AIClient, error) { + // Normalize provider name + provider = normalizeProviderName(provider) + + switch provider { + case "openai", "deepseek", "custom": + return createOpenAICompatibleClient(provider, cfg) + + case "ollama": + return createOllamaClient(cfg) + + default: + return nil, fmt.Errorf("%w: %s", ErrProviderNotSupported, provider) + } +} + +// createOpenAICompatibleClient creates an OpenAI-compatible client +func createOpenAICompatibleClient(provider string, cfg config.AIService) (interfaces.AIClient, error) { + normalized := strings.ToLower(provider) + + uniCfg := &universal.Config{ + Provider: normalized, + Endpoint: normalizeProviderEndpoint(normalized, cfg.Endpoint), + APIKey: cfg.APIKey, + Model: cfg.Model, + MaxTokens: cfg.MaxTokens, + Timeout: cfg.Timeout.Value(), + } + + if uniCfg.Endpoint == "" { + switch normalized { + case "openai": + uniCfg.Endpoint = "https://api.openai.com" + case "deepseek": + uniCfg.Endpoint = "https://api.deepseek.com" + case "custom": + return nil, fmt.Errorf("endpoint is required for custom provider") + } + } + + return universal.NewUniversalClient(uniCfg) +} + +// createOllamaClient creates an Ollama client +func createOllamaClient(cfg config.AIService) (interfaces.AIClient, error) { + config := &universal.Config{ + Provider: "ollama", + Endpoint: cfg.Endpoint, + Model: cfg.Model, + MaxTokens: cfg.MaxTokens, + Timeout: cfg.Timeout.Value(), + } + + // Default endpoint + if config.Endpoint == "" { + config.Endpoint = "http://localhost:11434" + } + + return universal.NewUniversalClient(config) +} + +// normalizeProviderName normalizes provider name (local -> ollama) +func normalizeProviderName(provider string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "local" { + return "ollama" + } + return provider +} + +// getOnlineProviders returns predefined online providers +func (m *Manager) getOnlineProviders() []*ProviderInfo { + return []*ProviderInfo{ + { + Name: "deepseek", + Type: "online", + Available: true, + Endpoint: "https://api.deepseek.com", + Models: []interfaces.ModelInfo{ + {ID: "deepseek-chat", Name: "DeepSeek Chat", Description: "DeepSeek's flagship conversational AI model", MaxTokens: 32768}, + {ID: "deepseek-reasoner", Name: "DeepSeek Reasoner", Description: "DeepSeek's reasoning model with thinking capabilities", MaxTokens: 32768}, + }, + LastChecked: time.Now(), + Config: map[string]interface{}{ + "requires_api_key": true, + "provider_type": "online", + }, + }, + { + Name: "openai", + Type: "online", + Available: true, + Endpoint: "https://api.openai.com", + Models: []interfaces.ModelInfo{ + {ID: "gpt-5", Name: "GPT-5", Description: "OpenAI's flagship GPT-5 model", MaxTokens: 200000}, + {ID: "gpt-5-mini", Name: "GPT-5 Mini", Description: "Optimized GPT-5 model for lower latency workloads", MaxTokens: 80000}, + {ID: "gpt-5-nano", Name: "GPT-5 Nano", Description: "Cost-efficient GPT-5 variant for lightweight tasks", MaxTokens: 40000}, + {ID: "gpt-5-pro", Name: "GPT-5 Pro", Description: "High performance GPT-5 model with extended reasoning", MaxTokens: 240000}, + {ID: "gpt-4.1", Name: "GPT-4.1", Description: "Balanced GPT-4 series model with strong multimodal support", MaxTokens: 128000}, + }, + LastChecked: time.Now(), + Config: map[string]interface{}{ + "requires_api_key": true, + "provider_type": "online", + }, + }, + } +} + +// ===== Retry Logic ===== + +// calculateBackoff calculates exponential backoff delay +func calculateBackoff(attempt int, retryCfg config.RetryConfig) time.Duration { + if attempt == 0 { + return 0 + } + + // Use configured values or defaults + baseDelay := 1 * time.Second + maxDelay := 10 * time.Second + multiplier := 2.0 + if retryCfg.InitialDelay.Duration > 0 { + baseDelay = retryCfg.InitialDelay.Duration + } + if retryCfg.MaxDelay.Duration > 0 { + maxDelay = retryCfg.MaxDelay.Duration + } + if retryCfg.Multiplier > 0 { + multiplier = float64(retryCfg.Multiplier) + } + jitter := retryCfg.Jitter + + // Calculate exponential backoff + delay := baseDelay + for i := 1; i < attempt; i++ { + delay = time.Duration(float64(delay) * multiplier) + if delay > maxDelay { + delay = maxDelay + break + } + } + + // Add jitter + if jitter { + jitterRange := delay / 4 + if jitterRange > 0 { + rangeLimit := big.NewInt(int64(jitterRange)) + n, err := cryptorand.Int(cryptorand.Reader, rangeLimit) + if err != nil { + logging.Logger.Debug("failed to generate crypto jitter, using deterministic midpoint", "error", err) + delay += jitterRange / 2 + } else { + delay += time.Duration(n.Int64()) + } + } + } + + return delay +} + +// isRetryableError determines if an error is retryable +func isRetryableError(err error) bool { + if err == nil { + return false + } + + // Context errors are not retryable + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + + // Network errors are retryable + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + + // DNS errors are retryable + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return true + } + + // Connection errors are retryable + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Op == "dial" { + return true + } + + // System call errors + var syscallErr *syscall.Errno + if errors.As(err, &syscallErr) { + switch *syscallErr { + case syscall.ECONNREFUSED, syscall.ECONNRESET, syscall.ETIMEDOUT: + return true + } + } + + // Check error message for retryable patterns + errMsg := strings.ToLower(err.Error()) + + // Retryable errors + retryablePatterns := []string{ + "rate limit", "too many requests", "quota exceeded", + "service unavailable", "bad gateway", "gateway timeout", + "connection refused", "connection reset", + "500", "502", "503", "504", "429", + } + + for _, pattern := range retryablePatterns { + if strings.Contains(errMsg, pattern) { + return true + } + } + + // Non-retryable errors + nonRetryablePatterns := []string{ + "unauthorized", "forbidden", "invalid api key", + "authentication failed", "bad request", "malformed", + "400", "401", "403", "404", + } + + for _, pattern := range nonRetryablePatterns { + if strings.Contains(errMsg, pattern) { + return false + } + } + + // Default: not retryable + return false +} diff --git a/pkg/ai/providers/openai/client.go b/pkg/ai/providers/openai/client.go new file mode 100644 index 0000000..c4984e7 --- /dev/null +++ b/pkg/ai/providers/openai/client.go @@ -0,0 +1,438 @@ +// Package openai integrates OpenAI-compatible models with the AI manager. +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package openai + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" + "github.com/linuxsuren/atest-ext-ai/pkg/logging" + "github.com/tmc/langchaingo/llms" + "github.com/tmc/langchaingo/llms/openai" +) + +// Client implements the AIClient interface for OpenAI +type Client struct { + config *Config + llm *openai.LLM +} + +// Config holds OpenAI-specific configuration +type Config struct { + APIKey string `json:"api_key"` + BaseURL string `json:"base_url"` + Timeout time.Duration `json:"timeout"` + MaxTokens int `json:"max_tokens"` + Model string `json:"model"` + OrgID string `json:"org_id,omitempty"` + + // Legacy fields for backward compatibility + UserAgent string `json:"user_agent,omitempty"` + MaxIdleConns int `json:"max_idle_conns,omitempty"` + MaxConnsPerHost int `json:"max_conns_per_host,omitempty"` + IdleConnTimeout time.Duration `json:"idle_conn_timeout,omitempty"` +} + +// NewClient creates a new OpenAI client using langchaingo +func NewClient(config *Config) (*Client, error) { + if config == nil { + return nil, fmt.Errorf("config cannot be nil") + } + + // Set API key from environment if not provided + if config.APIKey == "" { + // Try standardized environment variable first, with fallback for compatibility + if envKey := os.Getenv("ATEST_EXT_AI_OPENAI_API_KEY"); envKey != "" { + config.APIKey = envKey + } else if envKey := os.Getenv("OPENAI_API_KEY"); envKey != "" { + // Legacy compatibility + config.APIKey = envKey + } else { + return nil, fmt.Errorf("API key is required (set ATEST_EXT_AI_OPENAI_API_KEY or OPENAI_API_KEY environment variable or provide in config)") + } + } + + // Set defaults + if config.BaseURL == "" { + config.BaseURL = "https://api.openai.com/v1" + } + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + if config.MaxTokens == 0 { + config.MaxTokens = 4096 + } + if config.Model == "" { + config.Model = "gpt-3.5-turbo" + } + + // Get organization ID from environment if not provided + if config.OrgID == "" { + config.OrgID = os.Getenv("OPENAI_ORG_ID") + } + + // Build langchaingo options + opts := []openai.Option{ + openai.WithToken(config.APIKey), + openai.WithModel(config.Model), + } + + // Add optional configurations + if config.BaseURL != "" && config.BaseURL != "https://api.openai.com/v1" { + opts = append(opts, openai.WithBaseURL(config.BaseURL)) + } + if config.OrgID != "" { + opts = append(opts, openai.WithOrganization(config.OrgID)) + } + + // Create langchaingo OpenAI LLM + llm, err := openai.New(opts...) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI LLM: %w", err) + } + + client := &Client{ + config: config, + llm: llm, + } + + return client, nil +} + +// Generate executes a generation request using langchaingo +func (c *Client) Generate(ctx context.Context, req *interfaces.GenerateRequest) (*interfaces.GenerateResponse, error) { + start := time.Now() + + // Apply timeout if configured + if c.config.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.config.Timeout) + defer cancel() + } + + // Build messages in proper MessageContent format + messages := c.buildMessages(req) + + // Build generation options + opts := c.buildGenerationOptions(req) + + var responseText string + var requestID string + var err error + + if req.Stream { + // Handle streaming + responseText, requestID, err = c.generateStream(ctx, messages, opts) + } else { + // Non-streaming generation using GenerateContent + responseText, requestID, err = c.generateContent(ctx, messages, opts) + } + + if err != nil { + return nil, fmt.Errorf("generation failed: %w", err) + } + + // Build response + aiResponse := &interfaces.GenerateResponse{ + Text: responseText, + Model: c.getModel(req), + ProcessingTime: time.Since(start), + RequestID: requestID, + ConfidenceScore: 1.0, + Metadata: map[string]any{ + "streaming": req.Stream, + "provider": "openai", + }, + } + + return aiResponse, nil +} + +// GetCapabilities returns the capabilities of the OpenAI client +func (c *Client) GetCapabilities(ctx context.Context) (*interfaces.Capabilities, error) { + if ctx != nil { + if err := ctx.Err(); err != nil { + return nil, err + } + } + + return &interfaces.Capabilities{ + Provider: "openai", + MaxTokens: c.config.MaxTokens, + Models: []interfaces.ModelInfo{ + { + ID: "gpt-4", + Name: "GPT-4", + Description: "Most capable GPT-4 model", + MaxTokens: 8192, + InputCostPer1K: 0.03, + OutputCostPer1K: 0.06, + Capabilities: []string{"text_generation", "code_generation", "analysis"}, + }, + { + ID: "gpt-4-turbo", + Name: "GPT-4 Turbo", + Description: "Latest GPT-4 model with improved performance", + MaxTokens: 128000, + InputCostPer1K: 0.01, + OutputCostPer1K: 0.03, + Capabilities: []string{"text_generation", "code_generation", "analysis", "long_context"}, + }, + { + ID: "gpt-3.5-turbo", + Name: "GPT-3.5 Turbo", + Description: "Fast and efficient GPT-3.5 model", + MaxTokens: 4096, + InputCostPer1K: 0.0015, + OutputCostPer1K: 0.002, + Capabilities: []string{"text_generation", "code_generation"}, + }, + }, + Features: []interfaces.Feature{ + { + Name: "chat_completions", + Enabled: true, + Description: "Chat-based text generation", + Version: "v1", + }, + { + Name: "streaming", + Enabled: true, + Description: "Streaming response support", + Version: "v1", + }, + }, + SupportedLanguages: []string{"en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh"}, + RateLimits: &interfaces.RateLimits{ + RequestsPerMinute: 3500, + TokensPerMinute: 90000, + RequestsPerDay: -1, // No daily limit + TokensPerDay: -1, // No daily limit + }, + }, nil +} + +// HealthCheck performs a health check +func (c *Client) HealthCheck(ctx context.Context) (*interfaces.HealthStatus, error) { + start := time.Now() + + reqCtx := ctx + if c.config.Timeout > 0 { + var cancel context.CancelFunc + reqCtx, cancel = context.WithTimeout(ctx, c.config.Timeout) + defer cancel() + } + + endpoint := strings.TrimRight(c.config.BaseURL, "/") + if endpoint == "" { + endpoint = "https://api.openai.com/v1" + } + healthURL := endpoint + "/models" + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, healthURL, nil) + if err != nil { + return &interfaces.HealthStatus{ + Healthy: false, + Status: fmt.Sprintf("Failed to construct health request: %v", err), + ResponseTime: time.Since(start), + LastChecked: time.Now(), + Errors: []string{err.Error()}, + Metadata: map[string]any{ + "provider": "openai", + "endpoint": c.config.BaseURL, + }, + }, nil + } + + if c.config.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.config.APIKey) + } + + client := &http.Client{} + if c.config.Timeout > 0 { + client.Timeout = c.config.Timeout + } + + resp, err := client.Do(req) + responseTime := time.Since(start) + + status := &interfaces.HealthStatus{ + ResponseTime: responseTime, + LastChecked: time.Now(), + Metadata: map[string]any{ + "provider": "openai", + "endpoint": c.config.BaseURL, + }, + } + + if err != nil { + status.Healthy = false + status.Status = "Service unreachable" + status.Errors = []string{err.Error()} + return status, nil + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + logging.Logger.Warn("Failed to close OpenAI health check response body", "error", cerr) + } + }() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + status.Healthy = true + status.Status = "OK" + } else { + status.Healthy = false + status.Status = fmt.Sprintf("Unhealthy (status: %d)", resp.StatusCode) + } + + return status, nil +} + +// Close releases any resources held by the client +func (c *Client) Close() error { + // No resources to clean up with langchaingo + return nil +} + +// buildMessages constructs chat messages from the request in proper MessageContent format +func (c *Client) buildMessages(req *interfaces.GenerateRequest) []llms.MessageContent { + var messages []llms.MessageContent + + // Add system prompt if provided + if req.SystemPrompt != "" { + messages = append(messages, llms.TextParts(llms.ChatMessageTypeSystem, req.SystemPrompt)) + } + + // Add context messages as alternating user/assistant messages + for i, contextMsg := range req.Context { + role := llms.ChatMessageTypeHuman + if i%2 == 1 { + // Alternate between user and assistant for conversation context + role = llms.ChatMessageTypeAI + } + messages = append(messages, llms.TextParts(role, contextMsg)) + } + + // Add the main prompt as user message + messages = append(messages, llms.TextParts(llms.ChatMessageTypeHuman, req.Prompt)) + + return messages +} + +// buildGenerationOptions constructs generation options from the request +func (c *Client) buildGenerationOptions(req *interfaces.GenerateRequest) []llms.CallOption { + opts := []llms.CallOption{} + + // Set max tokens + maxTokens := c.getMaxTokens(req) + if maxTokens > 0 { + opts = append(opts, llms.WithMaxTokens(maxTokens)) + } + + // Set model if specified in request + if req.Model != "" { + opts = append(opts, llms.WithModel(req.Model)) + } + + return opts +} + +// generateContent handles non-streaming generation using GenerateContent +func (c *Client) generateContent(ctx context.Context, messages []llms.MessageContent, opts []llms.CallOption) (string, string, error) { + resp, err := c.llm.GenerateContent(ctx, messages, opts...) + if err != nil { + return "", "", fmt.Errorf("GenerateContent failed: %w", err) + } + + // Extract response text and metadata + var responseText string + var requestID string + + if len(resp.Choices) > 0 { + responseText = resp.Choices[0].Content + + // Try to extract request ID from generation info + if genInfo := resp.Choices[0].GenerationInfo; genInfo != nil { + if id, ok := genInfo["request_id"].(string); ok { + requestID = id + } else if id, ok := genInfo["RequestID"].(string); ok { + requestID = id + } + } + } + + return responseText, requestID, nil +} + +// generateStream handles streaming generation using langchaingo +func (c *Client) generateStream(ctx context.Context, messages []llms.MessageContent, opts []llms.CallOption) (string, string, error) { + var responseText strings.Builder + + // Add streaming callback + streamingFunc := func(ctx context.Context, chunk []byte) error { + if ctx != nil { + if err := ctx.Err(); err != nil { + return err + } + } + responseText.Write(chunk) + return nil + } + + opts = append(opts, llms.WithStreamingFunc(streamingFunc)) + + // Call GenerateContent with streaming enabled + resp, err := c.llm.GenerateContent(ctx, messages, opts...) + if err != nil { + return "", "", fmt.Errorf("streaming generation failed: %w", err) + } + + // Extract request ID if available + var requestID string + if len(resp.Choices) > 0 { + if genInfo := resp.Choices[0].GenerationInfo; genInfo != nil { + if id, ok := genInfo["request_id"].(string); ok { + requestID = id + } else if id, ok := genInfo["RequestID"].(string); ok { + requestID = id + } + } + } + + return responseText.String(), requestID, nil +} + +// getModel returns the model to use for the request +func (c *Client) getModel(req *interfaces.GenerateRequest) string { + if req.Model != "" { + return req.Model + } + return c.config.Model +} + +// getMaxTokens returns the max tokens for the request +func (c *Client) getMaxTokens(req *interfaces.GenerateRequest) int { + if req.MaxTokens > 0 { + return req.MaxTokens + } + return c.config.MaxTokens +} diff --git a/pkg/ai/providers/openai/client_test.go b/pkg/ai/providers/openai/client_test.go new file mode 100644 index 0000000..50be6a8 --- /dev/null +++ b/pkg/ai/providers/openai/client_test.go @@ -0,0 +1,53 @@ +package openai + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestHealthCheckSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/models", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + t.Cleanup(server.Close) + + client := &Client{ + config: &Config{ + APIKey: "test", + BaseURL: server.URL, + Timeout: time.Second, + }, + } + + status, err := client.HealthCheck(context.Background()) + require.NoError(t, err) + require.NotNil(t, status) + require.True(t, status.Healthy) + require.Equal(t, "OK", status.Status) +} + +func TestHealthCheckFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(server.Close) + + client := &Client{ + config: &Config{ + APIKey: "test", + BaseURL: server.URL, + }, + } + + status, err := client.HealthCheck(context.Background()) + require.NoError(t, err) + require.NotNil(t, status) + require.False(t, status.Healthy) +} diff --git a/pkg/ai/providers/openai/doc.go b/pkg/ai/providers/openai/doc.go new file mode 100644 index 0000000..65610a9 --- /dev/null +++ b/pkg/ai/providers/openai/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package openai integrates OpenAI-compatible large language models with the manager. +package openai diff --git a/pkg/ai/providers/universal/client.go b/pkg/ai/providers/universal/client.go new file mode 100644 index 0000000..498b6a7 --- /dev/null +++ b/pkg/ai/providers/universal/client.go @@ -0,0 +1,388 @@ +// Package universal offers provider strategies for OpenAI-compatible HTTP APIs. +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package universal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" + "github.com/linuxsuren/atest-ext-ai/pkg/logging" +) + +// Global HTTP client pool for connection reuse across providers +// Using sync.Map for concurrent-safe access without explicit locking on read +var ( + httpClientPool = &sync.Map{} // key: provider name (string), value: *http.Client + httpClientMu sync.Mutex // Mutex for client creation to prevent duplicate creation +) + +// getOrCreateHTTPClient retrieves an existing HTTP client from the pool or creates a new one +// This implements connection pooling to improve performance and resource utilization +// Based on Go net/http best practices for Transport configuration +func getOrCreateHTTPClient(provider string, timeout time.Duration) *http.Client { + // Try to get existing client from pool (fast path, no locking) + if client, ok := httpClientPool.Load(provider); ok { + logging.Logger.Debug("Reusing HTTP client from pool", + "provider", provider) + return client.(*http.Client) + } + + // Client not found, need to create (slow path with locking) + httpClientMu.Lock() + defer httpClientMu.Unlock() + + // Double-check: another goroutine might have created the client while we waited for the lock + if client, ok := httpClientPool.Load(provider); ok { + logging.Logger.Debug("HTTP client created by another goroutine", + "provider", provider) + return client.(*http.Client) + } + + // Create new HTTP client with optimized transport settings + // Configuration follows Go net/http best practices: + // - MaxIdleConns: Total maximum idle connections across all hosts + // - MaxIdleConnsPerHost: Maximum idle connections per host (important for AI APIs) + // - IdleConnTimeout: How long idle connections remain in the pool + // - DisableCompression: Disabled for better compatibility with AI APIs + client := &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + MaxIdleConns: 100, // Total pool size across all hosts + MaxIdleConnsPerHost: 10, // Per-host idle connection limit (AI APIs typically use 1 host) + IdleConnTimeout: 90 * time.Second, // Keep idle connections for 90s + DisableCompression: false, // Enable compression for better bandwidth utilization + // Additional recommended settings for production use: + MaxConnsPerHost: 0, // No limit on active connections (0 = unlimited) + ResponseHeaderTimeout: 30 * time.Second, // Timeout for reading response headers + ExpectContinueTimeout: 1 * time.Second, // Timeout for 100-Continue handshake + ForceAttemptHTTP2: true, // Enable HTTP/2 when available + DisableKeepAlives: false, // Enable keep-alives for connection reuse + TLSHandshakeTimeout: 10 * time.Second, // Timeout for TLS handshake + }, + } + + // Store in pool for reuse + httpClientPool.Store(provider, client) + + logging.Logger.Info("Created new HTTP client with connection pooling", + "provider", provider, + "timeout", timeout, + "max_idle_conns", 100, + "max_idle_conns_per_host", 10, + "idle_conn_timeout", "90s") + + return client +} + +// Client implements a universal OpenAI-compatible API client. +type Client struct { + config *Config + httpClient *http.Client + strategy ProviderStrategy // Strategy pattern to handle provider-specific logic +} + +// Config holds configuration for the universal client +type Config struct { + Provider string `json:"provider"` // Provider name (e.g., "ollama", "openai", "custom") + Endpoint string `json:"endpoint"` // API endpoint URL + APIKey string `json:"api_key,omitempty"` // API key (optional for local services) + Model string `json:"model"` // Default model to use + MaxTokens int `json:"max_tokens"` // Maximum tokens for generation + Timeout time.Duration `json:"timeout"` // Request timeout + Headers map[string]string `json:"headers,omitempty"` // Additional headers + Parameters map[string]any `json:"parameters,omitempty"` // Provider-specific parameters + CompletionPath string `json:"completion_path"` // API path for completions (default: /v1/chat/completions) + ModelsPath string `json:"models_path"` // API path for models (default: /v1/models) + HealthPath string `json:"health_path"` // API path for health check + StreamSupported bool `json:"stream_supported"` // Whether streaming is supported +} + +// NewUniversalClient creates a new universal OpenAI-compatible client +func NewUniversalClient(config *Config) (*Client, error) { + if config == nil { + return nil, fmt.Errorf("config cannot be nil") + } + + // Validate required fields + if config.Endpoint == "" { + return nil, fmt.Errorf("endpoint is required") + } + + // Set defaults based on provider type + if config.Provider == "" { + config.Provider = "custom" + } + + // Get strategy for this provider + strategy := GetStrategy(config.Provider) + + // Apply provider-specific defaults using strategy + paths := strategy.GetDefaultPaths() + if config.CompletionPath == "" { + config.CompletionPath = paths.CompletionPath + } + if config.ModelsPath == "" { + config.ModelsPath = paths.ModelsPath + } + if config.HealthPath == "" { + config.HealthPath = paths.HealthPath + } + config.StreamSupported = strategy.SupportsStreaming() + + // Apply endpoint defaults for specific providers + if config.Provider == "openai" && config.Endpoint == "" { + config.Endpoint = "https://api.openai.com" + } else if config.Provider == "deepseek" && config.Endpoint == "" { + config.Endpoint = "https://api.deepseek.com" + } + + // Set other defaults + if config.Timeout == 0 { + // Increase timeout for reasoning/thinking models + if strings.Contains(strings.ToLower(config.Model), "think") || strings.Contains(strings.ToLower(config.Model), "reason") { + config.Timeout = 300 * time.Second // 5 minutes for thinking models + } else { + config.Timeout = 120 * time.Second // 2 minutes for regular models + } + } + if config.MaxTokens == 0 { + config.MaxTokens = 4096 + } + if config.Headers == nil { + config.Headers = make(map[string]string) + } + + // Create HTTP client using connection pool for better performance + // This reuses connections across requests to the same provider + httpClient := getOrCreateHTTPClient(config.Provider, config.Timeout) + + client := &Client{ + config: config, + strategy: strategy, + httpClient: httpClient, + } + + logging.Logger.Debug("Universal client created", + "provider", config.Provider, + "endpoint", config.Endpoint, + "model", config.Model, + "timeout", config.Timeout) + + return client, nil +} + +// Generate executes a generation request +func (c *Client) Generate(ctx context.Context, req *interfaces.GenerateRequest) (*interfaces.GenerateResponse, error) { + start := time.Now() + + // Build request using strategy pattern + requestBody, err := c.strategy.BuildRequest(req, c.config) + if err != nil { + return nil, fmt.Errorf("failed to build request: %w", err) + } + + // Marshal request + jsonBody, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // Create HTTP request + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.config.Endpoint+c.config.CompletionPath, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + httpReq.Header.Set("Content-Type", "application/json") + if c.config.APIKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.config.APIKey) + } + for k, v := range c.config.Headers { + httpReq.Header.Set(k, v) + } + + // Execute request + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Check status + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + // Parse response using strategy pattern + response, err := c.strategy.ParseResponse(resp.Body, req.Model) + if err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + response.ProcessingTime = time.Since(start) + return response, nil +} + +// GetCapabilities returns the capabilities of this AI client +func (c *Client) GetCapabilities(ctx context.Context) (*interfaces.Capabilities, error) { + caps := &interfaces.Capabilities{ + Provider: c.config.Provider, + MaxTokens: c.config.MaxTokens, + Features: []interfaces.Feature{ + { + Name: "text-generation", + Enabled: true, + Description: "Text generation capability", + }, + }, + SupportedLanguages: []string{"en", "zh", "es", "fr", "de", "ja", "ko"}, + } + + // Try to get models list + models, err := c.getModels(ctx) + if err == nil { + caps.Models = models + } else { + // If we can't get models, provide default models for the provider + caps.Models = c.getDefaultModelsForProvider() + } + + // Add streaming feature if supported + if c.config.StreamSupported { + caps.Features = append(caps.Features, interfaces.Feature{ + Name: "streaming", + Enabled: true, + Description: "Streaming response support", + }) + } + + return caps, nil +} + +// HealthCheck performs a health check on the AI service +func (c *Client) HealthCheck(ctx context.Context) (*interfaces.HealthStatus, error) { + start := time.Now() + + // Try to get models as a health check + healthPath := c.config.HealthPath + if healthPath == "" { + healthPath = c.config.ModelsPath + } + + req, err := http.NewRequestWithContext(ctx, "GET", c.config.Endpoint+healthPath, nil) + if err != nil { + return &interfaces.HealthStatus{ + Healthy: false, + Status: "Failed to create health check request", + ResponseTime: time.Since(start), + LastChecked: time.Now(), + Errors: []string{err.Error()}, + }, nil + } + + if c.config.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.config.APIKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return &interfaces.HealthStatus{ + Healthy: false, + Status: "Service unreachable", + ResponseTime: time.Since(start), + LastChecked: time.Now(), + Errors: []string{err.Error()}, + }, nil + } + defer func() { _ = resp.Body.Close() }() + + healthy := resp.StatusCode == http.StatusOK + status := "Healthy" + if !healthy { + status = fmt.Sprintf("Unhealthy (status: %d)", resp.StatusCode) + } + + return &interfaces.HealthStatus{ + Healthy: healthy, + Status: status, + ResponseTime: time.Since(start), + LastChecked: time.Now(), + Metadata: map[string]any{ + "provider": c.config.Provider, + "endpoint": c.config.Endpoint, + "model": c.config.Model, + }, + }, nil +} + +// Close releases any resources held by the client +func (c *Client) Close() error { + // No persistent connections to close + return nil +} + +// getModels retrieves available models from the API +func (c *Client) getModels(ctx context.Context) ([]interfaces.ModelInfo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.config.Endpoint+c.config.ModelsPath, nil) + if err != nil { + return nil, err + } + + if c.config.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.config.APIKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get models: status %d", resp.StatusCode) + } + + // Parse response using strategy pattern + return c.strategy.ParseModels(resp.Body, c.config.MaxTokens) +} + +// getDefaultModelsForProvider returns default models using strategy pattern +func (c *Client) getDefaultModelsForProvider() []interfaces.ModelInfo { + models := c.strategy.GetDefaultModels(c.config.MaxTokens) + + // If strategy returns empty list and we have a configured model, use it as fallback + if len(models) == 0 && c.config.Model != "" { + return []interfaces.ModelInfo{ + { + ID: c.config.Model, + Name: c.config.Model, + Description: "Default configured model", + MaxTokens: c.config.MaxTokens, + }, + } + } + + return models +} diff --git a/pkg/ai/providers/universal/doc.go b/pkg/ai/providers/universal/doc.go new file mode 100644 index 0000000..b20fdf3 --- /dev/null +++ b/pkg/ai/providers/universal/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package universal offers provider strategies for OpenAI-compatible HTTP APIs. +package universal diff --git a/pkg/ai/providers/universal/strategy.go b/pkg/ai/providers/universal/strategy.go new file mode 100644 index 0000000..7d0fbaa --- /dev/null +++ b/pkg/ai/providers/universal/strategy.go @@ -0,0 +1,63 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package universal + +import ( + "io" + + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" +) + +// ProviderStrategy defines the interface for provider-specific implementations +// This strategy pattern eliminates hardcoded provider checks throughout the codebase +type ProviderStrategy interface { + // BuildRequest builds provider-specific request body + BuildRequest(req *interfaces.GenerateRequest, config *Config) (any, error) + + // ParseResponse parses provider-specific response + ParseResponse(body io.Reader, requestedModel string) (*interfaces.GenerateResponse, error) + + // ParseModels parses provider-specific models list + ParseModels(body io.Reader, maxTokens int) ([]interfaces.ModelInfo, error) + + // GetDefaultPaths returns default API paths for this provider + GetDefaultPaths() ProviderPaths + + // GetDefaultModels returns default models when API call fails + GetDefaultModels(maxTokens int) []interfaces.ModelInfo + + // SupportsStreaming indicates if this provider supports streaming + SupportsStreaming() bool +} + +// ProviderPaths contains provider-specific API paths +type ProviderPaths struct { + CompletionPath string + ModelsPath string + HealthPath string +} + +// GetStrategy returns the appropriate strategy for a provider +func GetStrategy(provider string) ProviderStrategy { + switch provider { + case "ollama": + return &OllamaStrategy{} + default: + // OpenAI-compatible strategy for: openai, deepseek, custom, etc. + return &OpenAIStrategy{provider: provider} + } +} diff --git a/pkg/ai/providers/universal/strategy_ollama.go b/pkg/ai/providers/universal/strategy_ollama.go new file mode 100644 index 0000000..0d99b98 --- /dev/null +++ b/pkg/ai/providers/universal/strategy_ollama.go @@ -0,0 +1,175 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package universal + +import ( + "encoding/json" + "fmt" + "io" + "time" + + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" +) + +// OllamaStrategy implements ProviderStrategy for Ollama +type OllamaStrategy struct{} + +// BuildRequest builds an Ollama-specific request +func (s *OllamaStrategy) BuildRequest(req *interfaces.GenerateRequest, config *Config) (any, error) { + model := req.Model + if model == "" { + model = config.Model + } + + maxTokens := req.MaxTokens + if maxTokens == 0 { + maxTokens = config.MaxTokens + } + + // Build messages for chat format + messages := []map[string]string{} + + if req.SystemPrompt != "" { + messages = append(messages, map[string]string{ + "role": "system", + "content": req.SystemPrompt, + }) + } + + // Add context as previous messages + for _, ctx := range req.Context { + messages = append(messages, map[string]string{ + "role": "assistant", + "content": ctx, + }) + } + + // Add the main prompt + messages = append(messages, map[string]string{ + "role": "user", + "content": req.Prompt, + }) + + return map[string]any{ + "model": model, + "messages": messages, + "stream": req.Stream, + "options": map[string]any{ + "num_predict": maxTokens, + }, + }, nil +} + +// ParseResponse parses an Ollama API response +func (s *OllamaStrategy) ParseResponse(body io.Reader, requestedModel string) (*interfaces.GenerateResponse, error) { + var resp struct { + Model string `json:"model"` + Message struct { + Content string `json:"content"` + } `json:"message"` + Done bool `json:"done"` + TotalDuration int64 `json:"total_duration"` + LoadDuration int64 `json:"load_duration"` + PromptEvalCount int `json:"prompt_eval_count"` + PromptEvalDuration int64 `json:"prompt_eval_duration"` + EvalCount int `json:"eval_count"` + EvalDuration int64 `json:"eval_duration"` + } + + if err := json.NewDecoder(body).Decode(&resp); err != nil { + return nil, err + } + + if resp.Model == "" && requestedModel != "" { + resp.Model = requestedModel + } + + return &interfaces.GenerateResponse{ + Text: resp.Message.Content, + Model: resp.Model, + RequestID: fmt.Sprintf("ollama-%d", time.Now().Unix()), + Metadata: map[string]any{ + "total_duration": resp.TotalDuration, + "load_duration": resp.LoadDuration, + "prompt_eval_time": resp.PromptEvalDuration, + "eval_time": resp.EvalDuration, + // Token usage information available in metadata if needed + "prompt_eval_count": resp.PromptEvalCount, + "eval_count": resp.EvalCount, + }, + }, nil +} + +// ParseModels parses Ollama's model list response +func (s *OllamaStrategy) ParseModels(body io.Reader, maxTokens int) ([]interfaces.ModelInfo, error) { + var resp struct { + Models []struct { + Name string `json:"name"` + ModifiedAt string `json:"modified_at"` + Size int64 `json:"size"` + Details struct { + ParameterSize string `json:"parameter_size"` + } `json:"details"` + } `json:"models"` + } + + if err := json.NewDecoder(body).Decode(&resp); err != nil { + return nil, err + } + + models := make([]interfaces.ModelInfo, 0, len(resp.Models)) + for _, m := range resp.Models { + models = append(models, interfaces.ModelInfo{ + ID: m.Name, + Name: m.Name, + Description: fmt.Sprintf("Ollama model (size: %.2f GB)", float64(m.Size)/(1024*1024*1024)), + MaxTokens: maxTokens, + }) + } + + return models, nil +} + +// GetDefaultPaths returns default API paths for Ollama +func (s *OllamaStrategy) GetDefaultPaths() ProviderPaths { + return ProviderPaths{ + CompletionPath: "/api/chat", + ModelsPath: "/api/tags", + HealthPath: "/api/tags", + } +} + +// GetDefaultModels returns default models when API call fails +func (s *OllamaStrategy) GetDefaultModels(maxTokens int) []interfaces.ModelInfo { + if maxTokens <= 0 { + maxTokens = 4096 + } + + return []interfaces.ModelInfo{ + { + ID: "ollama-generic", + Name: "Generic Ollama Model", + Description: "Fallback entry used when the Ollama model list cannot be retrieved", + MaxTokens: maxTokens, + }, + } +} + +// SupportsStreaming indicates if Ollama supports streaming +func (s *OllamaStrategy) SupportsStreaming() bool { + return true +} diff --git a/pkg/ai/providers/universal/strategy_openai.go b/pkg/ai/providers/universal/strategy_openai.go new file mode 100644 index 0000000..c8e6cf3 --- /dev/null +++ b/pkg/ai/providers/universal/strategy_openai.go @@ -0,0 +1,274 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package universal + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" +) + +// OpenAIStrategy implements ProviderStrategy for OpenAI-compatible APIs +// This includes: openai, deepseek, custom, and other OpenAI-compatible providers +type OpenAIStrategy struct { + provider string +} + +// BuildRequest builds an OpenAI-compatible request +func (s *OpenAIStrategy) BuildRequest(req *interfaces.GenerateRequest, config *Config) (any, error) { + model := req.Model + if model == "" { + model = config.Model + } + + maxTokens := req.MaxTokens + if maxTokens == 0 { + maxTokens = config.MaxTokens + } + + // Build messages + messages := []map[string]string{} + + if req.SystemPrompt != "" { + messages = append(messages, map[string]string{ + "role": "system", + "content": req.SystemPrompt, + }) + } + + // Add context + for _, ctx := range req.Context { + messages = append(messages, map[string]string{ + "role": "assistant", + "content": ctx, + }) + } + + // Add the main prompt + messages = append(messages, map[string]string{ + "role": "user", + "content": req.Prompt, + }) + + request := map[string]any{ + "model": model, + "messages": messages, + "max_tokens": maxTokens, + "stream": req.Stream, + } + + // Add any additional parameters from config + for k, v := range config.Parameters { + if _, exists := request[k]; !exists { + request[k] = v + } + } + + return request, nil +} + +// ParseResponse parses an OpenAI-compatible API response +func (s *OpenAIStrategy) ParseResponse(body io.Reader, requestedModel string) (*interfaces.GenerateResponse, error) { + var resp struct { + ID string `json:"id"` + Model string `json:"model"` + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + } + + if err := json.NewDecoder(body).Decode(&resp); err != nil { + return nil, err + } + + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("no choices in response") + } + + if resp.Model == "" && requestedModel != "" { + resp.Model = requestedModel + } + + return &interfaces.GenerateResponse{ + Text: resp.Choices[0].Message.Content, + Model: resp.Model, + RequestID: resp.ID, + Metadata: map[string]any{ + "finish_reason": resp.Choices[0].FinishReason, + // Token usage information available in metadata if needed + "prompt_tokens": resp.Usage.PromptTokens, + "completion_tokens": resp.Usage.CompletionTokens, + "total_tokens": resp.Usage.TotalTokens, + }, + }, nil +} + +// ParseModels parses OpenAI's model list response +func (s *OpenAIStrategy) ParseModels(body io.Reader, maxTokens int) ([]interfaces.ModelInfo, error) { + var resp struct { + Data []struct { + ID string `json:"id"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + } `json:"data"` + } + + if err := json.NewDecoder(body).Decode(&resp); err != nil { + return nil, err + } + + models := make([]interfaces.ModelInfo, 0, len(resp.Data)) + for _, m := range resp.Data { + // Include models that are likely to be chat/completion models + if isValidChatModel(m.ID) { + models = append(models, interfaces.ModelInfo{ + ID: m.ID, + Name: m.ID, + Description: fmt.Sprintf("AI model (owner: %s)", m.OwnedBy), + MaxTokens: maxTokens, + }) + } + } + + return models, nil +} + +// GetDefaultPaths returns default API paths for OpenAI-compatible providers +func (s *OpenAIStrategy) GetDefaultPaths() ProviderPaths { + return ProviderPaths{ + CompletionPath: "/v1/chat/completions", + ModelsPath: "/v1/models", + HealthPath: "/v1/models", + } +} + +// GetDefaultModels returns default models for specific providers +func (s *OpenAIStrategy) GetDefaultModels(maxTokens int) []interfaces.ModelInfo { + switch s.provider { + case "deepseek": + return []interfaces.ModelInfo{ + { + ID: "deepseek-chat", + Name: "DeepSeek Chat", + Description: "DeepSeek's flagship conversational AI model", + MaxTokens: 32768, + }, + { + ID: "deepseek-reasoner", + Name: "DeepSeek Reasoner", + Description: "DeepSeek's reasoning model with thinking capabilities", + MaxTokens: 32768, + }, + } + case "openai": + return []interfaces.ModelInfo{ + { + ID: "gpt-5", + Name: "GPT-5", + Description: "OpenAI's flagship GPT-5 model", + MaxTokens: 200000, + }, + { + ID: "gpt-5-mini", + Name: "GPT-5 Mini", + Description: "Optimized GPT-5 model for latency-sensitive workloads", + MaxTokens: 80000, + }, + { + ID: "gpt-5-nano", + Name: "GPT-5 Nano", + Description: "Cost efficient GPT-5 variant for lightweight tasks", + MaxTokens: 40000, + }, + { + ID: "gpt-5-pro", + Name: "GPT-5 Pro", + Description: "High performance GPT-5 model with extended reasoning", + MaxTokens: 240000, + }, + { + ID: "gpt-4.1", + Name: "GPT-4.1", + Description: "Balanced GPT-4 series model with strong multimodal support", + MaxTokens: 128000, + }, + } + default: + // Generic fallback + return []interfaces.ModelInfo{ + { + ID: "default", + Name: "Default Model", + Description: "Default model for this provider", + MaxTokens: maxTokens, + }, + } + } +} + +// SupportsStreaming indicates if this provider supports streaming +func (s *OpenAIStrategy) SupportsStreaming() bool { + return true +} + +// isValidChatModel determines if a model ID represents a valid chat/completion model +func isValidChatModel(modelID string) bool { + modelID = strings.ToLower(modelID) + + // Common patterns for chat/completion models + chatKeywords := []string{ + "gpt", "chat", "turbo", "instruct", + "deepseek", "moonshot", "glm", "chatglm", + "baichuan", "qwen", "claude", "llama", + "yi", "internlm", "mistral", "gemma", + "codeqwen", "codechat", "assistant", + "completion", "text", "dialogue", + } + + for _, keyword := range chatKeywords { + if strings.Contains(modelID, keyword) { + return true + } + } + + // Exclude models that are clearly not for chat/completion + excludeKeywords := []string{ + "embedding", "whisper", "dall-e", "tts", + "moderation", "edit", "similarity", + "search", "classification", "fine-tune", + } + + for _, keyword := range excludeKeywords { + if strings.Contains(modelID, keyword) { + return false + } + } + + // If no specific patterns match, include by default for compatibility + return true +} diff --git a/pkg/ai/sql.go b/pkg/ai/sql.go new file mode 100644 index 0000000..b1478b6 --- /dev/null +++ b/pkg/ai/sql.go @@ -0,0 +1,623 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ai + +import ( + "fmt" + "regexp" + "strings" +) + +// SQLDialect defines the interface for database-specific SQL handling +type SQLDialect interface { + // Name returns the name of the SQL dialect + Name() string + + // ValidateSQL validates SQL syntax for this dialect + ValidateSQL(sql string) ([]ValidationResult, error) + + // OptimizeSQL optimizes SQL query for this dialect + OptimizeSQL(sql string) (string, []string, error) + + // FormatSQL formats SQL query according to dialect conventions + FormatSQL(sql string) (string, error) + + // GetDataTypes returns supported data types for this dialect + GetDataTypes() []DataType + + // GetFunctions returns supported functions for this dialect + GetFunctions() []Function + + // GetKeywords returns reserved keywords for this dialect + GetKeywords() []string + + // TransformSQL transforms SQL from one dialect to another + TransformSQL(sql string, targetDialect string) (string, error) +} + +// DataType represents a database data type +type DataType struct { + Name string `json:"name"` + Category string `json:"category"` // numeric, string, date, boolean, etc. + Aliases []string `json:"aliases,omitempty"` + MaxLength int `json:"max_length,omitempty"` + DefaultSize int `json:"default_size,omitempty"` + Precision int `json:"precision,omitempty"` + Scale int `json:"scale,omitempty"` +} + +// Function represents a database function +type Function struct { + Name string `json:"name"` + Category string `json:"category"` // aggregate, string, date, math, etc. + Description string `json:"description"` + Syntax string `json:"syntax"` + Examples []string `json:"examples,omitempty"` +} + +// MySQLDialect implements SQLDialect for MySQL +type MySQLDialect struct{} + +// Name implements SQLDialect.Name for MySQL. +func (d *MySQLDialect) Name() string { + return "MySQL" +} + +// ValidateSQL implements SQLDialect.ValidateSQL with MySQL-specific heuristics. +func (d *MySQLDialect) ValidateSQL(sql string) ([]ValidationResult, error) { + var results []ValidationResult + + // Basic syntax validation + sql = strings.TrimSpace(sql) + if sql == "" { + return []ValidationResult{{ + Type: "syntax", + Level: "error", + Message: "Empty SQL statement", + }}, nil + } + + // Check for common MySQL syntax issues + upper := strings.ToUpper(sql) + + // Check for proper statement termination + if !strings.HasSuffix(strings.TrimSpace(sql), ";") { + results = append(results, ValidationResult{ + Type: "syntax", + Level: "warning", + Message: "SQL statement should end with semicolon", + Suggestion: "Add ';' at the end of the statement", + }) + } + + // Check for MySQL-specific issues + if strings.Contains(upper, "LIMIT") && !regexp.MustCompile(`LIMIT\s+\d+(\s*,\s*\d+)?`).MatchString(upper) { + results = append(results, ValidationResult{ + Type: "syntax", + Level: "error", + Message: "Invalid LIMIT syntax for MySQL", + }) + } + + // Check for reserved keywords used as identifiers (not as SQL commands) + // We'll only check for keywords that might be used as table or column names + problematicKeywords := []string{"ORDER", "GROUP", "KEY", "INDEX", "TABLE", "DATABASE"} + for _, keyword := range problematicKeywords { + if strings.Contains(upper, keyword+" ") && !strings.Contains(upper, "`"+keyword+"`") { + // More sophisticated check to see if it's used as identifier + if !isKeywordUsedAsCommand(upper, keyword) { + results = append(results, ValidationResult{ + Type: "naming", + Level: "warning", + Message: fmt.Sprintf("'%s' might be a reserved keyword in MySQL", keyword), + Suggestion: fmt.Sprintf("Use backticks if using as identifier: `%s`", keyword), + }) + } + } + } + + return results, nil +} + +// OptimizeSQL implements SQLDialect.OptimizeSQL for MySQL statements. +func (d *MySQLDialect) OptimizeSQL(sql string) (string, []string, error) { + var suggestions []string + optimizedSQL := sql + + upper := strings.ToUpper(sql) + + // Suggest using LIMIT for potentially large result sets + if strings.Contains(upper, "SELECT") && !strings.Contains(upper, "LIMIT") && !strings.Contains(upper, "WHERE") { + suggestions = append(suggestions, "Consider adding a LIMIT clause to prevent large result sets") + } + + // Suggest using indexes for WHERE clauses + if strings.Contains(upper, "WHERE") { + suggestions = append(suggestions, "Ensure appropriate indexes exist for WHERE clause columns") + } + + // Suggest using EXISTS instead of IN for subqueries + if strings.Contains(upper, "IN (SELECT") { + suggestions = append(suggestions, "Consider using EXISTS instead of IN with subqueries for better performance") + } + + return optimizedSQL, suggestions, nil +} + +// FormatSQL provides basic formatting for MySQL queries. +func (d *MySQLDialect) FormatSQL(sql string) (string, error) { + // Basic SQL formatting - indent and add line breaks + formatted := strings.TrimSpace(sql) + + // Add line breaks after major keywords + keywords := []string{"SELECT", "FROM", "WHERE", "GROUP BY", "HAVING", "ORDER BY", "LIMIT"} + for _, keyword := range keywords { + pattern := regexp.MustCompile(`(?i)\b` + keyword + `\b`) + formatted = pattern.ReplaceAllString(formatted, "\n"+keyword) + } + + return strings.TrimSpace(formatted), nil +} + +// GetDataTypes lists supported MySQL data types. +func (d *MySQLDialect) GetDataTypes() []DataType { + return []DataType{ + {Name: "INT", Category: "numeric", Aliases: []string{"INTEGER"}}, + {Name: "BIGINT", Category: "numeric"}, + {Name: "DECIMAL", Category: "numeric", Precision: 65, Scale: 30}, + {Name: "FLOAT", Category: "numeric"}, + {Name: "DOUBLE", Category: "numeric"}, + {Name: "VARCHAR", Category: "string", MaxLength: 65535}, + {Name: "CHAR", Category: "string", MaxLength: 255}, + {Name: "TEXT", Category: "string"}, + {Name: "LONGTEXT", Category: "string"}, + {Name: "DATE", Category: "date"}, + {Name: "DATETIME", Category: "date"}, + {Name: "TIMESTAMP", Category: "date"}, + {Name: "BOOLEAN", Category: "boolean", Aliases: []string{"BOOL"}}, + {Name: "JSON", Category: "json"}, + {Name: "BLOB", Category: "binary"}, + } +} + +// GetFunctions enumerates common MySQL functions. +func (d *MySQLDialect) GetFunctions() []Function { + return []Function{ + {Name: "COUNT", Category: "aggregate", Description: "Count rows", Syntax: "COUNT(column)", Examples: []string{"COUNT(*)", "COUNT(id)"}}, + {Name: "SUM", Category: "aggregate", Description: "Sum values", Syntax: "SUM(column)", Examples: []string{"SUM(amount)"}}, + {Name: "AVG", Category: "aggregate", Description: "Average values", Syntax: "AVG(column)", Examples: []string{"AVG(price)"}}, + {Name: "MAX", Category: "aggregate", Description: "Maximum value", Syntax: "MAX(column)", Examples: []string{"MAX(created_at)"}}, + {Name: "MIN", Category: "aggregate", Description: "Minimum value", Syntax: "MIN(column)", Examples: []string{"MIN(price)"}}, + {Name: "CONCAT", Category: "string", Description: "Concatenate strings", Syntax: "CONCAT(str1, str2, ...)", Examples: []string{"CONCAT(first_name, ' ', last_name)"}}, + {Name: "LENGTH", Category: "string", Description: "String length", Syntax: "LENGTH(str)", Examples: []string{"LENGTH(description)"}}, + {Name: "SUBSTRING", Category: "string", Description: "Extract substring", Syntax: "SUBSTRING(str, pos, len)", Examples: []string{"SUBSTRING(name, 1, 10)"}}, + {Name: "NOW", Category: "date", Description: "Current timestamp", Syntax: "NOW()", Examples: []string{"NOW()"}}, + {Name: "DATE", Category: "date", Description: "Extract date part", Syntax: "DATE(datetime)", Examples: []string{"DATE(created_at)"}}, + } +} + +// GetKeywords returns reserved keywords relevant to MySQL. +func (d *MySQLDialect) GetKeywords() []string { + return []string{ + "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", + "TABLE", "INDEX", "DATABASE", "SCHEMA", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", + "PRIMARY", "FOREIGN", "KEY", "UNIQUE", "NOT", "NULL", "DEFAULT", "AUTO_INCREMENT", + "AND", "OR", "IN", "LIKE", "BETWEEN", "EXISTS", "IS", "CASE", "WHEN", "THEN", "ELSE", + "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET", "UNION", "JOIN", "LEFT", "RIGHT", + "INNER", "OUTER", "ON", "AS", "DISTINCT", "ALL", "ASC", "DESC", + } +} + +// TransformSQL converts a MySQL query into another dialect when supported. +func (d *MySQLDialect) TransformSQL(sql string, targetDialect string) (string, error) { + switch targetDialect { + case "postgresql": + return d.transformToPostgreSQL(sql) + case "sqlite": + return d.transformToSQLite(sql) + default: + return sql, fmt.Errorf("unsupported target dialect: %s", targetDialect) + } +} + +func (d *MySQLDialect) transformToPostgreSQL(sql string) (string, error) { + // Transform MySQL-specific syntax to PostgreSQL + transformed := sql + + // Replace backticks with double quotes + transformed = strings.ReplaceAll(transformed, "`", "\"") + + // Replace LIMIT x, y with LIMIT y OFFSET x + limitPattern := regexp.MustCompile(`(?i)LIMIT\s+(\d+)\s*,\s*(\d+)`) + transformed = limitPattern.ReplaceAllString(transformed, "LIMIT $2 OFFSET $1") + + // Replace AUTO_INCREMENT with SERIAL + transformed = strings.ReplaceAll(strings.ToUpper(transformed), "AUTO_INCREMENT", "SERIAL") + + return transformed, nil +} + +func (d *MySQLDialect) transformToSQLite(sql string) (string, error) { + // Transform MySQL-specific syntax to SQLite + transformed := sql + + // Remove backticks + transformed = strings.ReplaceAll(transformed, "`", "") + + // Replace some MySQL functions with SQLite equivalents + transformed = strings.ReplaceAll(strings.ToUpper(transformed), "NOW()", "DATETIME('now')") + + return transformed, nil +} + +// PostgreSQLDialect implements SQLDialect for PostgreSQL +type PostgreSQLDialect struct{} + +// Name implements SQLDialect.Name for PostgreSQL. +func (d *PostgreSQLDialect) Name() string { + return "PostgreSQL" +} + +// ValidateSQL implements SQLDialect.ValidateSQL with PostgreSQL-specific rules. +func (d *PostgreSQLDialect) ValidateSQL(sql string) ([]ValidationResult, error) { + var results []ValidationResult + + sql = strings.TrimSpace(sql) + if sql == "" { + return []ValidationResult{{ + Type: "syntax", + Level: "error", + Message: "Empty SQL statement", + }}, nil + } + + upper := strings.ToUpper(sql) + + // Check for proper statement termination + if !strings.HasSuffix(strings.TrimSpace(sql), ";") { + results = append(results, ValidationResult{ + Type: "syntax", + Level: "warning", + Message: "SQL statement should end with semicolon", + Suggestion: "Add ';' at the end of the statement", + }) + } + + // Check for PostgreSQL-specific issues + if strings.Contains(upper, "LIMIT") && strings.Contains(upper, ",") { + results = append(results, ValidationResult{ + Type: "syntax", + Level: "error", + Message: "PostgreSQL uses LIMIT x OFFSET y syntax, not LIMIT x, y", + Suggestion: "Use LIMIT count OFFSET start format", + }) + } + + // Check for identifier quoting + if strings.Contains(sql, "`") { + results = append(results, ValidationResult{ + Type: "syntax", + Level: "warning", + Message: "PostgreSQL uses double quotes for identifiers, not backticks", + Suggestion: "Use double quotes (\") instead of backticks (`)", + }) + } + + return results, nil +} + +// OptimizeSQL provides tuning suggestions for PostgreSQL queries. +func (d *PostgreSQLDialect) OptimizeSQL(sql string) (string, []string, error) { + var suggestions []string + optimizedSQL := sql + + upper := strings.ToUpper(sql) + + // Suggest using LIMIT for potentially large result sets + if strings.Contains(upper, "SELECT") && !strings.Contains(upper, "LIMIT") { + suggestions = append(suggestions, "Consider adding a LIMIT clause to prevent large result sets") + } + + // Suggest using indexes + if strings.Contains(upper, "WHERE") { + suggestions = append(suggestions, "Ensure appropriate indexes exist for WHERE clause columns") + } + + // Suggest using EXISTS instead of IN for subqueries + if strings.Contains(upper, "IN (SELECT") { + suggestions = append(suggestions, "Consider using EXISTS instead of IN with subqueries for better performance") + } + + return optimizedSQL, suggestions, nil +} + +// FormatSQL formats SQL according to PostgreSQL conventions. +func (d *PostgreSQLDialect) FormatSQL(sql string) (string, error) { + // Basic SQL formatting + formatted := strings.TrimSpace(sql) + + keywords := []string{"SELECT", "FROM", "WHERE", "GROUP BY", "HAVING", "ORDER BY", "LIMIT", "OFFSET"} + for _, keyword := range keywords { + pattern := regexp.MustCompile(`(?i)\b` + keyword + `\b`) + formatted = pattern.ReplaceAllString(formatted, "\n"+keyword) + } + + return strings.TrimSpace(formatted), nil +} + +// GetDataTypes lists PostgreSQL data types. +func (d *PostgreSQLDialect) GetDataTypes() []DataType { + return []DataType{ + {Name: "INTEGER", Category: "numeric", Aliases: []string{"INT", "INT4"}}, + {Name: "BIGINT", Category: "numeric", Aliases: []string{"INT8"}}, + {Name: "DECIMAL", Category: "numeric", Aliases: []string{"NUMERIC"}}, + {Name: "REAL", Category: "numeric", Aliases: []string{"FLOAT4"}}, + {Name: "DOUBLE PRECISION", Category: "numeric", Aliases: []string{"FLOAT8"}}, + {Name: "VARCHAR", Category: "string", Aliases: []string{"CHARACTER VARYING"}}, + {Name: "CHAR", Category: "string", Aliases: []string{"CHARACTER"}}, + {Name: "TEXT", Category: "string"}, + {Name: "DATE", Category: "date"}, + {Name: "TIMESTAMP", Category: "date"}, + {Name: "TIMESTAMPTZ", Category: "date", Aliases: []string{"TIMESTAMP WITH TIME ZONE"}}, + {Name: "BOOLEAN", Category: "boolean", Aliases: []string{"BOOL"}}, + {Name: "JSON", Category: "json"}, + {Name: "JSONB", Category: "json"}, + {Name: "UUID", Category: "uuid"}, + {Name: "SERIAL", Category: "numeric"}, + {Name: "BIGSERIAL", Category: "numeric"}, + } +} + +// GetFunctions enumerates PostgreSQL functions used by the generator. +func (d *PostgreSQLDialect) GetFunctions() []Function { + return []Function{ + {Name: "COUNT", Category: "aggregate", Description: "Count rows", Syntax: "COUNT(column)", Examples: []string{"COUNT(*)", "COUNT(id)"}}, + {Name: "SUM", Category: "aggregate", Description: "Sum values", Syntax: "SUM(column)", Examples: []string{"SUM(amount)"}}, + {Name: "AVG", Category: "aggregate", Description: "Average values", Syntax: "AVG(column)", Examples: []string{"AVG(price)"}}, + {Name: "MAX", Category: "aggregate", Description: "Maximum value", Syntax: "MAX(column)", Examples: []string{"MAX(created_at)"}}, + {Name: "MIN", Category: "aggregate", Description: "Minimum value", Syntax: "MIN(column)", Examples: []string{"MIN(price)"}}, + {Name: "CONCAT", Category: "string", Description: "Concatenate strings", Syntax: "CONCAT(str1, str2, ...)", Examples: []string{"CONCAT(first_name, ' ', last_name)"}}, + {Name: "LENGTH", Category: "string", Description: "String length", Syntax: "LENGTH(str)", Examples: []string{"LENGTH(description)"}}, + {Name: "SUBSTRING", Category: "string", Description: "Extract substring", Syntax: "SUBSTRING(str FROM pos FOR len)", Examples: []string{"SUBSTRING(name FROM 1 FOR 10)"}}, + {Name: "NOW", Category: "date", Description: "Current timestamp", Syntax: "NOW()", Examples: []string{"NOW()"}}, + {Name: "CURRENT_DATE", Category: "date", Description: "Current date", Syntax: "CURRENT_DATE", Examples: []string{"CURRENT_DATE"}}, + {Name: "EXTRACT", Category: "date", Description: "Extract date part", Syntax: "EXTRACT(field FROM source)", Examples: []string{"EXTRACT(YEAR FROM created_at)"}}, + } +} + +// GetKeywords returns PostgreSQL reserved words. +func (d *PostgreSQLDialect) GetKeywords() []string { + return []string{ + "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", + "TABLE", "INDEX", "DATABASE", "SCHEMA", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", + "PRIMARY", "FOREIGN", "KEY", "UNIQUE", "NOT", "NULL", "DEFAULT", "SERIAL", "BIGSERIAL", + "AND", "OR", "IN", "LIKE", "ILIKE", "BETWEEN", "EXISTS", "IS", "CASE", "WHEN", "THEN", "ELSE", + "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET", "UNION", "JOIN", "LEFT", "RIGHT", + "INNER", "OUTER", "FULL", "ON", "AS", "DISTINCT", "ALL", "ASC", "DESC", + } +} + +// TransformSQL adapts PostgreSQL queries to other dialects when possible. +func (d *PostgreSQLDialect) TransformSQL(sql string, targetDialect string) (string, error) { + switch targetDialect { + case "mysql": + return d.transformToMySQL(sql) + case "sqlite": + return d.transformToSQLite(sql) + default: + return sql, fmt.Errorf("unsupported target dialect: %s", targetDialect) + } +} + +func (d *PostgreSQLDialect) transformToMySQL(sql string) (string, error) { + transformed := sql + + // Replace double quotes with backticks for identifiers + // This is a simplified transformation + identifierPattern := regexp.MustCompile(`"([^"]+)"`) + transformed = identifierPattern.ReplaceAllString(transformed, "`$1`") + + // Transform LIMIT OFFSET to MySQL format + limitPattern := regexp.MustCompile(`(?i)LIMIT\s+(\d+)\s+OFFSET\s+(\d+)`) + transformed = limitPattern.ReplaceAllString(transformed, "LIMIT $2, $1") + + return transformed, nil +} + +func (d *PostgreSQLDialect) transformToSQLite(sql string) (string, error) { + transformed := sql + + // Remove double quotes for simpler identifiers + transformed = strings.ReplaceAll(transformed, "\"", "") + + // Replace PostgreSQL-specific functions + transformed = strings.ReplaceAll(transformed, "CURRENT_DATE", "DATE('now')") + transformed = strings.ReplaceAll(transformed, "NOW()", "DATETIME('now')") + + return transformed, nil +} + +// SQLiteDialect implements SQLDialect for SQLite +type SQLiteDialect struct{} + +// Name implements SQLDialect.Name for SQLite. +func (d *SQLiteDialect) Name() string { + return "SQLite" +} + +// ValidateSQL implements SQLDialect.ValidateSQL for SQLite syntax. +func (d *SQLiteDialect) ValidateSQL(sql string) ([]ValidationResult, error) { + var results []ValidationResult + + sql = strings.TrimSpace(sql) + if sql == "" { + return []ValidationResult{{ + Type: "syntax", + Level: "error", + Message: "Empty SQL statement", + }}, nil + } + + upper := strings.ToUpper(sql) + + // Check for proper statement termination + if !strings.HasSuffix(strings.TrimSpace(sql), ";") { + results = append(results, ValidationResult{ + Type: "syntax", + Level: "warning", + Message: "SQL statement should end with semicolon", + Suggestion: "Add ';' at the end of the statement", + }) + } + + // Check for SQLite limitations + if strings.Contains(upper, "RIGHT JOIN") || strings.Contains(upper, "FULL JOIN") { + results = append(results, ValidationResult{ + Type: "syntax", + Level: "error", + Message: "SQLite does not support RIGHT JOIN or FULL OUTER JOIN", + Suggestion: "Use LEFT JOIN or restructure the query", + }) + } + + return results, nil +} + +// OptimizeSQL provides suggestions tailored to SQLite. +func (d *SQLiteDialect) OptimizeSQL(sql string) (string, []string, error) { + var suggestions []string + optimizedSQL := sql + + upper := strings.ToUpper(sql) + + // SQLite-specific optimization suggestions + if strings.Contains(upper, "SELECT") && !strings.Contains(upper, "LIMIT") { + suggestions = append(suggestions, "Consider adding a LIMIT clause for better performance") + } + + if strings.Contains(upper, "WHERE") { + suggestions = append(suggestions, "Ensure appropriate indexes exist for WHERE clause columns") + } + + return optimizedSQL, suggestions, nil +} + +// FormatSQL reformats SQL to align with SQLite practices. +func (d *SQLiteDialect) FormatSQL(sql string) (string, error) { + // Basic SQL formatting + formatted := strings.TrimSpace(sql) + + keywords := []string{"SELECT", "FROM", "WHERE", "GROUP BY", "HAVING", "ORDER BY", "LIMIT"} + for _, keyword := range keywords { + pattern := regexp.MustCompile(`(?i)\b` + keyword + `\b`) + formatted = pattern.ReplaceAllString(formatted, "\n"+keyword) + } + + return strings.TrimSpace(formatted), nil +} + +// GetDataTypes lists supported SQLite data types. +func (d *SQLiteDialect) GetDataTypes() []DataType { + return []DataType{ + {Name: "INTEGER", Category: "numeric"}, + {Name: "REAL", Category: "numeric"}, + {Name: "TEXT", Category: "string"}, + {Name: "BLOB", Category: "binary"}, + {Name: "NUMERIC", Category: "numeric"}, + // SQLite is dynamically typed, but these are the storage classes + } +} + +// GetFunctions enumerates common SQLite functions. +func (d *SQLiteDialect) GetFunctions() []Function { + return []Function{ + {Name: "COUNT", Category: "aggregate", Description: "Count rows", Syntax: "COUNT(column)", Examples: []string{"COUNT(*)", "COUNT(id)"}}, + {Name: "SUM", Category: "aggregate", Description: "Sum values", Syntax: "SUM(column)", Examples: []string{"SUM(amount)"}}, + {Name: "AVG", Category: "aggregate", Description: "Average values", Syntax: "AVG(column)", Examples: []string{"AVG(price)"}}, + {Name: "MAX", Category: "aggregate", Description: "Maximum value", Syntax: "MAX(column)", Examples: []string{"MAX(created_at)"}}, + {Name: "MIN", Category: "aggregate", Description: "Minimum value", Syntax: "MIN(column)", Examples: []string{"MIN(price)"}}, + {Name: "LENGTH", Category: "string", Description: "String length", Syntax: "LENGTH(str)", Examples: []string{"LENGTH(description)"}}, + {Name: "SUBSTR", Category: "string", Description: "Extract substring", Syntax: "SUBSTR(str, pos, len)", Examples: []string{"SUBSTR(name, 1, 10)"}}, + {Name: "DATETIME", Category: "date", Description: "Date and time function", Syntax: "DATETIME(timestring, modifier...)", Examples: []string{"DATETIME('now')", "DATETIME('2023-01-01', '+1 day')"}}, + {Name: "DATE", Category: "date", Description: "Date function", Syntax: "DATE(timestring, modifier...)", Examples: []string{"DATE('now')", "DATE('2023-01-01')"}}, + {Name: "STRFTIME", Category: "date", Description: "Format date/time", Syntax: "STRFTIME(format, timestring)", Examples: []string{"STRFTIME('%Y-%m-%d', 'now')"}}, + } +} + +// GetKeywords returns SQLite reserved keywords. +func (d *SQLiteDialect) GetKeywords() []string { + return []string{ + "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", + "TABLE", "INDEX", "VIEW", "TRIGGER", "PRIMARY", "FOREIGN", "KEY", "UNIQUE", + "NOT", "NULL", "DEFAULT", "AUTOINCREMENT", "AND", "OR", "IN", "LIKE", "GLOB", + "BETWEEN", "EXISTS", "IS", "CASE", "WHEN", "THEN", "ELSE", "GROUP", "BY", + "ORDER", "HAVING", "LIMIT", "OFFSET", "UNION", "JOIN", "LEFT", "INNER", + "ON", "AS", "DISTINCT", "ALL", "ASC", "DESC", + } +} + +// TransformSQL converts SQLite queries to other dialects when supported. +func (d *SQLiteDialect) TransformSQL(sql string, targetDialect string) (string, error) { + switch targetDialect { + case "mysql": + return d.transformToMySQL(sql) + case "postgresql": + return d.transformToPostgreSQL(sql) + default: + return sql, fmt.Errorf("unsupported target dialect: %s", targetDialect) + } +} + +func (d *SQLiteDialect) transformToMySQL(sql string) (string, error) { + transformed := sql + + // Replace SQLite date functions with MySQL equivalents + transformed = strings.ReplaceAll(transformed, "DATETIME('now')", "NOW()") + transformed = strings.ReplaceAll(transformed, "DATE('now')", "CURDATE()") + + // Replace SUBSTR with SUBSTRING + substrPattern := regexp.MustCompile(`(?i)SUBSTR\s*\(\s*([^,]+),\s*([^,]+),\s*([^)]+)\s*\)`) + transformed = substrPattern.ReplaceAllString(transformed, "SUBSTRING($1, $2, $3)") + + return transformed, nil +} + +func (d *SQLiteDialect) transformToPostgreSQL(sql string) (string, error) { + transformed := sql + + // Replace SQLite date functions with PostgreSQL equivalents + transformed = strings.ReplaceAll(transformed, "DATETIME('now')", "NOW()") + transformed = strings.ReplaceAll(transformed, "DATE('now')", "CURRENT_DATE") + + // Replace SUBSTR with SUBSTRING + substrPattern := regexp.MustCompile(`(?i)SUBSTR\s*\(\s*([^,]+),\s*([^,]+),\s*([^)]+)\s*\)`) + transformed = substrPattern.ReplaceAllString(transformed, "SUBSTRING($1 FROM $2 FOR $3)") + + return transformed, nil +} + +// isKeywordUsedAsCommand checks if a keyword is used as a SQL command rather than an identifier +func isKeywordUsedAsCommand(sql, keyword string) bool { + // This is a simplified check - in practice you'd want more sophisticated parsing + commandPrefixes := []string{"SELECT ", "FROM ", "WHERE ", "GROUP BY", "ORDER BY", "HAVING ", "UNION ", "JOIN "} + for _, prefix := range commandPrefixes { + if strings.Contains(sql, prefix) && strings.Contains(sql, prefix+keyword) { + return true + } + } + return false +} diff --git a/pkg/ai/sql_test.go b/pkg/ai/sql_test.go new file mode 100644 index 0000000..2505769 --- /dev/null +++ b/pkg/ai/sql_test.go @@ -0,0 +1,691 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ai + +import ( + "strings" + "testing" +) + +func TestMySQLDialect_Name(t *testing.T) { + dialect := &MySQLDialect{} + expected := "MySQL" + if dialect.Name() != expected { + t.Errorf("Expected %s, got %s", expected, dialect.Name()) + } +} + +func TestMySQLDialect_ValidateSQL(t *testing.T) { + dialect := &MySQLDialect{} + + tests := []struct { + name string + sql string + expectedCount int + expectError bool + }{ + { + name: "valid SQL with semicolon", + sql: "SELECT * FROM users;", + expectedCount: 0, + expectError: false, + }, + { + name: "valid SQL without semicolon", + sql: "SELECT * FROM users", + expectedCount: 1, // Warning about missing semicolon + expectError: false, + }, + { + name: "empty SQL", + sql: "", + expectedCount: 1, // Error for empty statement + expectError: false, + }, + { + name: "SQL with reserved keyword", + sql: "SELECT * FROM `order`;", + expectedCount: 0, // Using backticks, so should be OK + expectError: false, + }, + { + name: "SQL with valid LIMIT", + sql: "SELECT * FROM users LIMIT 10;", + expectedCount: 0, + expectError: false, + }, + { + name: "SQL with MySQL-style LIMIT", + sql: "SELECT * FROM users LIMIT 10, 20;", + expectedCount: 0, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, err := dialect.ValidateSQL(tt.sql) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + return + } + + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if len(results) != tt.expectedCount { + t.Errorf("Expected %d validation results, got %d", tt.expectedCount, len(results)) + for i, result := range results { + t.Logf(" Result %d: %s [%s] %s", i+1, result.Type, result.Level, result.Message) + } + } + }) + } +} + +func TestMySQLDialect_OptimizeSQL(t *testing.T) { + dialect := &MySQLDialect{} + + tests := []struct { + name string + sql string + expectedSQL string + minSuggestions int + }{ + { + name: "SELECT without LIMIT or WHERE", + sql: "SELECT * FROM users", + expectedSQL: "SELECT * FROM users", // No change expected + minSuggestions: 1, // Should suggest LIMIT + }, + { + name: "SELECT with WHERE clause", + sql: "SELECT * FROM users WHERE age > 18", + expectedSQL: "SELECT * FROM users WHERE age > 18", + minSuggestions: 1, // Should suggest indexes + }, + { + name: "SELECT with subquery using IN", + sql: "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)", + expectedSQL: "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)", + minSuggestions: 2, // Should suggest EXISTS and indexes + }, + { + name: "SELECT with LIMIT", + sql: "SELECT * FROM users LIMIT 10", + expectedSQL: "SELECT * FROM users LIMIT 10", + minSuggestions: 0, // Might have suggestions but not required + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + optimizedSQL, suggestions, err := dialect.OptimizeSQL(tt.sql) + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if optimizedSQL != tt.expectedSQL { + t.Errorf("Expected optimized SQL: %s, got: %s", tt.expectedSQL, optimizedSQL) + } + + if len(suggestions) < tt.minSuggestions { + t.Errorf("Expected at least %d suggestions, got %d", tt.minSuggestions, len(suggestions)) + for i, suggestion := range suggestions { + t.Logf(" Suggestion %d: %s", i+1, suggestion) + } + } + }) + } +} + +func TestMySQLDialect_GetDataTypes(t *testing.T) { + dialect := &MySQLDialect{} + dataTypes := dialect.GetDataTypes() + + if len(dataTypes) == 0 { + t.Errorf("Expected data types but got none") + } + + // Check for key data types + expectedTypes := []string{"INT", "VARCHAR", "TEXT", "DATETIME", "DECIMAL"} + for _, expected := range expectedTypes { + found := false + for _, dataType := range dataTypes { + if dataType.Name == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected data type %s not found", expected) + } + } +} + +func TestMySQLDialect_GetFunctions(t *testing.T) { + dialect := &MySQLDialect{} + functions := dialect.GetFunctions() + + if len(functions) == 0 { + t.Errorf("Expected functions but got none") + } + + // Check for key functions + expectedFunctions := []string{"COUNT", "SUM", "AVG", "CONCAT", "NOW"} + for _, expected := range expectedFunctions { + found := false + for _, function := range functions { + if function.Name == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected function %s not found", expected) + } + } +} + +func TestMySQLDialect_TransformSQL(t *testing.T) { + dialect := &MySQLDialect{} + + tests := []struct { + name string + sql string + targetDialect string + expectedSQL string + expectError bool + }{ + { + name: "MySQL to PostgreSQL - backticks", + sql: "SELECT `name` FROM `users`", + targetDialect: "postgresql", + expectedSQL: "SELECT \"NAME\" FROM \"USERS\"", + expectError: false, + }, + { + name: "MySQL to PostgreSQL - LIMIT offset", + sql: "SELECT * FROM users LIMIT 10, 20", + targetDialect: "postgresql", + expectedSQL: "SELECT * FROM USERS LIMIT 20 OFFSET 10", + expectError: false, + }, + { + name: "MySQL to SQLite - remove backticks", + sql: "SELECT `name` FROM `users`", + targetDialect: "sqlite", + expectedSQL: "SELECT NAME FROM USERS", + expectError: false, + }, + { + name: "MySQL to SQLite - NOW() function", + sql: "SELECT NOW() FROM users", + targetDialect: "sqlite", + expectedSQL: "SELECT DATETIME('now') FROM USERS", + expectError: false, + }, + { + name: "unsupported target dialect", + sql: "SELECT * FROM users", + targetDialect: "oracle", + expectedSQL: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := dialect.TransformSQL(tt.sql, tt.targetDialect) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if result != tt.expectedSQL { + t.Errorf("Expected transformed SQL: %s, got: %s", tt.expectedSQL, result) + } + }) + } +} + +func TestPostgreSQLDialect_Name(t *testing.T) { + dialect := &PostgreSQLDialect{} + expected := "PostgreSQL" + if dialect.Name() != expected { + t.Errorf("Expected %s, got %s", expected, dialect.Name()) + } +} + +func TestPostgreSQLDialect_ValidateSQL(t *testing.T) { + dialect := &PostgreSQLDialect{} + + tests := []struct { + name string + sql string + expectedCount int + expectError bool + }{ + { + name: "valid PostgreSQL SQL", + sql: "SELECT * FROM users;", + expectedCount: 0, + expectError: false, + }, + { + name: "MySQL-style LIMIT", + sql: "SELECT * FROM users LIMIT 10, 20;", + expectedCount: 1, // Error for MySQL-style LIMIT + expectError: false, + }, + { + name: "backticks instead of double quotes", + sql: "SELECT `name` FROM `users`;", + expectedCount: 1, // Warning about backticks + expectError: false, + }, + { + name: "valid PostgreSQL LIMIT", + sql: "SELECT * FROM users LIMIT 20 OFFSET 10;", + expectedCount: 0, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, err := dialect.ValidateSQL(tt.sql) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + return + } + + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if len(results) != tt.expectedCount { + t.Errorf("Expected %d validation results, got %d", tt.expectedCount, len(results)) + for i, result := range results { + t.Logf(" Result %d: %s [%s] %s", i+1, result.Type, result.Level, result.Message) + } + } + }) + } +} + +func TestPostgreSQLDialect_GetDataTypes(t *testing.T) { + dialect := &PostgreSQLDialect{} + dataTypes := dialect.GetDataTypes() + + if len(dataTypes) == 0 { + t.Errorf("Expected data types but got none") + } + + // Check for PostgreSQL-specific data types + expectedTypes := []string{"INTEGER", "BIGINT", "VARCHAR", "TEXT", "TIMESTAMP", "JSONB", "UUID", "SERIAL"} + for _, expected := range expectedTypes { + found := false + for _, dataType := range dataTypes { + if dataType.Name == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected data type %s not found", expected) + } + } +} + +func TestPostgreSQLDialect_TransformSQL(t *testing.T) { + dialect := &PostgreSQLDialect{} + + tests := []struct { + name string + sql string + targetDialect string + expectedSQL string + expectError bool + }{ + { + name: "PostgreSQL to MySQL - double quotes to backticks", + sql: "SELECT \"name\" FROM \"users\"", + targetDialect: "mysql", + expectedSQL: "SELECT `name` FROM `users`", + expectError: false, + }, + { + name: "PostgreSQL to MySQL - LIMIT OFFSET", + sql: "SELECT * FROM users LIMIT 20 OFFSET 10", + targetDialect: "mysql", + expectedSQL: "SELECT * FROM users LIMIT 10, 20", + expectError: false, + }, + { + name: "PostgreSQL to SQLite - remove quotes", + sql: "SELECT \"name\" FROM \"users\"", + targetDialect: "sqlite", + expectedSQL: "SELECT name FROM users", + expectError: false, + }, + { + name: "PostgreSQL to SQLite - date functions", + sql: "SELECT CURRENT_DATE, NOW() FROM users", + targetDialect: "sqlite", + expectedSQL: "SELECT DATE('now'), DATETIME('now') FROM users", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := dialect.TransformSQL(tt.sql, tt.targetDialect) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if result != tt.expectedSQL { + t.Errorf("Expected transformed SQL: %s, got: %s", tt.expectedSQL, result) + } + }) + } +} + +func TestSQLiteDialect_Name(t *testing.T) { + dialect := &SQLiteDialect{} + expected := "SQLite" + if dialect.Name() != expected { + t.Errorf("Expected %s, got %s", expected, dialect.Name()) + } +} + +func TestSQLiteDialect_ValidateSQL(t *testing.T) { + dialect := &SQLiteDialect{} + + tests := []struct { + name string + sql string + expectedCount int + expectError bool + }{ + { + name: "valid SQLite SQL", + sql: "SELECT * FROM users;", + expectedCount: 0, + expectError: false, + }, + { + name: "RIGHT JOIN not supported", + sql: "SELECT * FROM users RIGHT JOIN orders ON users.id = orders.user_id;", + expectedCount: 1, // Error for unsupported RIGHT JOIN + expectError: false, + }, + { + name: "FULL OUTER JOIN not supported", + sql: "SELECT * FROM users FULL JOIN orders ON users.id = orders.user_id;", + expectedCount: 1, // Error for unsupported FULL JOIN + expectError: false, + }, + { + name: "LEFT JOIN is supported", + sql: "SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id;", + expectedCount: 0, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, err := dialect.ValidateSQL(tt.sql) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + return + } + + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if len(results) != tt.expectedCount { + t.Errorf("Expected %d validation results, got %d", tt.expectedCount, len(results)) + for i, result := range results { + t.Logf(" Result %d: %s [%s] %s", i+1, result.Type, result.Level, result.Message) + } + } + }) + } +} + +func TestSQLiteDialect_GetDataTypes(t *testing.T) { + dialect := &SQLiteDialect{} + dataTypes := dialect.GetDataTypes() + + if len(dataTypes) == 0 { + t.Errorf("Expected data types but got none") + } + + // SQLite has a limited set of storage classes + expectedTypes := []string{"INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC"} + for _, expected := range expectedTypes { + found := false + for _, dataType := range dataTypes { + if dataType.Name == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected data type %s not found", expected) + } + } +} + +func TestSQLiteDialect_GetFunctions(t *testing.T) { + dialect := &SQLiteDialect{} + functions := dialect.GetFunctions() + + if len(functions) == 0 { + t.Errorf("Expected functions but got none") + } + + // Check for SQLite-specific functions + expectedFunctions := []string{"COUNT", "SUM", "LENGTH", "SUBSTR", "DATETIME", "DATE", "STRFTIME"} + for _, expected := range expectedFunctions { + found := false + for _, function := range functions { + if function.Name == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected function %s not found", expected) + } + } +} + +func TestSQLiteDialect_TransformSQL(t *testing.T) { + dialect := &SQLiteDialect{} + + tests := []struct { + name string + sql string + targetDialect string + expectedSQL string + expectError bool + }{ + { + name: "SQLite to MySQL - date functions", + sql: "SELECT DATETIME('now'), DATE('now') FROM users", + targetDialect: "mysql", + expectedSQL: "SELECT NOW(), CURDATE() FROM users", + expectError: false, + }, + { + name: "SQLite to MySQL - SUBSTR to SUBSTRING", + sql: "SELECT SUBSTR(name, 1, 10) FROM users", + targetDialect: "mysql", + expectedSQL: "SELECT SUBSTRING(name, 1, 10) FROM users", + expectError: false, + }, + { + name: "SQLite to PostgreSQL - date functions", + sql: "SELECT DATETIME('now'), DATE('now') FROM users", + targetDialect: "postgresql", + expectedSQL: "SELECT NOW(), CURRENT_DATE FROM users", + expectError: false, + }, + { + name: "SQLite to PostgreSQL - SUBSTR syntax", + sql: "SELECT SUBSTR(name, 1, 10) FROM users", + targetDialect: "postgresql", + expectedSQL: "SELECT SUBSTRING(name FROM 1 FOR 10) FROM users", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := dialect.TransformSQL(tt.sql, tt.targetDialect) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if result != tt.expectedSQL { + t.Errorf("Expected transformed SQL: %s, got: %s", tt.expectedSQL, result) + } + }) + } +} + +func TestSQLDialect_FormatSQL(t *testing.T) { + dialects := []struct { + name string + dialect SQLDialect + }{ + {"MySQL", &MySQLDialect{}}, + {"PostgreSQL", &PostgreSQLDialect{}}, + {"SQLite", &SQLiteDialect{}}, + } + + sql := "SELECT name, email FROM users WHERE age > 18 GROUP BY name ORDER BY name LIMIT 10" + + for _, d := range dialects { + t.Run(d.name, func(t *testing.T) { + formatted, err := d.dialect.FormatSQL(sql) + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if formatted == "" { + t.Errorf("Expected formatted SQL but got empty string") + return + } + + // Check that the formatted SQL contains line breaks for major keywords + if !containsKeywordOnNewLine(formatted, "SELECT") { + t.Errorf("Expected SELECT on new line in formatted SQL: %s", formatted) + } + + if !containsKeywordOnNewLine(formatted, "FROM") { + t.Errorf("Expected FROM on new line in formatted SQL: %s", formatted) + } + + if !containsKeywordOnNewLine(formatted, "WHERE") { + t.Errorf("Expected WHERE on new line in formatted SQL: %s", formatted) + } + }) + } +} + +func containsKeywordOnNewLine(sql, keyword string) bool { + // Check if keyword is at the start of the SQL or after a newline + return strings.HasPrefix(sql, keyword) || strings.Contains(sql, "\n"+keyword) +} + +func TestSQLDialect_Integration(t *testing.T) { + // Integration test to verify all dialects work together + dialects := map[string]SQLDialect{ + "mysql": &MySQLDialect{}, + "postgresql": &PostgreSQLDialect{}, + "sqlite": &SQLiteDialect{}, + } + + originalSQL := "SELECT name FROM users WHERE age > 18" + + // Test cross-dialect transformation + for sourceName, sourceDialect := range dialects { + for targetName := range dialects { + if sourceName == targetName { + continue + } + + t.Run(sourceName+"_to_"+targetName, func(t *testing.T) { + transformed, err := sourceDialect.TransformSQL(originalSQL, targetName) + + if err != nil { + t.Errorf("Failed to transform from %s to %s: %v", sourceName, targetName, err) + return + } + + if transformed == "" { + t.Errorf("Transform from %s to %s resulted in empty SQL", sourceName, targetName) + } + + t.Logf("Transform %s -> %s: %s -> %s", sourceName, targetName, originalSQL, transformed) + }) + } + } +} diff --git a/pkg/ai/types.go b/pkg/ai/types.go new file mode 100644 index 0000000..ccd533b --- /dev/null +++ b/pkg/ai/types.go @@ -0,0 +1,106 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ai + +import ( + "time" + + "github.com/linuxsuren/atest-ext-ai/pkg/interfaces" +) + +// AIClient is retained for backward compatibility; prefer interfaces.AIClient. +// +//revive:disable:exported +type AIClient = interfaces.AIClient + +// GenerateRequest is retained for backward compatibility. +type GenerateRequest = interfaces.GenerateRequest + +// GenerateResponse is retained for backward compatibility. +type GenerateResponse = interfaces.GenerateResponse + +// Capabilities is retained for backward compatibility. +type Capabilities = interfaces.Capabilities + +// ModelInfo is retained for backward compatibility. +type ModelInfo = interfaces.ModelInfo + +// Feature is retained for backward compatibility. +type Feature = interfaces.Feature + +// RateLimits is retained for backward compatibility. +type RateLimits = interfaces.RateLimits + +// HealthStatus is retained for backward compatibility. +type HealthStatus = interfaces.HealthStatus + +//revive:enable:exported + +// ProviderConfig represents configuration for a specific AI provider +type ProviderConfig struct { + // Name is the provider name (openai, ollama, deepseek, custom, etc.) + // Note: "local" is accepted as an alias for "ollama" for backward compatibility + Name string `json:"name"` + + // Enabled indicates if this provider is enabled + Enabled bool `json:"enabled"` + + // Priority indicates the priority of this provider (higher = more preferred) + Priority int `json:"priority"` + + // Config contains provider-specific configuration + Config map[string]any `json:"config"` + + // Models lists the models available for this provider + Models []string `json:"models,omitempty"` + + // Timeout specifies the request timeout for this provider + Timeout time.Duration `json:"timeout,omitempty"` + + // MaxRetries specifies the maximum number of retries for this provider + MaxRetries int `json:"max_retries,omitempty"` +} + +// ServiceConfig represents the complete AI service configuration. +type ServiceConfig struct { + // Providers lists all configured AI providers + Providers []ProviderConfig `json:"providers"` + + // Retry configures the retry behavior + Retry RetryConfig `json:"retry"` +} + +//revive:disable-next-line exported +type AIServiceConfig = ServiceConfig + +// RetryConfig configures retry behavior +type RetryConfig struct { + // MaxAttempts is the maximum number of retry attempts + MaxAttempts int `json:"max_attempts"` + + // BaseDelay is the base delay between retries + BaseDelay time.Duration `json:"base_delay"` + + // MaxDelay is the maximum delay between retries + MaxDelay time.Duration `json:"max_delay"` + + // BackoffMultiplier is the multiplier for exponential backoff + BackoffMultiplier float64 `json:"backoff_multiplier"` + + // Jitter enables random jitter in retry delays + Jitter bool `json:"jitter"` +} diff --git a/pkg/config/duration.go b/pkg/config/duration.go new file mode 100644 index 0000000..f006611 --- /dev/null +++ b/pkg/config/duration.go @@ -0,0 +1,122 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "encoding/json" + "fmt" + "time" +) + +// Duration is a custom type that supports parsing from string in YAML/JSON +type Duration struct { + time.Duration +} + +// UnmarshalYAML implements yaml.Unmarshaler interface +func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + // Try to unmarshal as duration directly + return unmarshal(&d.Duration) + } + + duration, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration format: %s", s) + } + + d.Duration = duration + return nil +} + +// UnmarshalJSON implements json.Unmarshaler interface +func (d *Duration) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + // Try to unmarshal as number (nanoseconds) + var ns int64 + if err := json.Unmarshal(data, &ns); err != nil { + return err + } + d.Duration = time.Duration(ns) + return nil + } + + duration, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration format: %s", s) + } + + d.Duration = duration + return nil +} + +// UnmarshalTOML implements toml.Unmarshaler interface +func (d *Duration) UnmarshalTOML(data interface{}) error { + switch v := data.(type) { + case string: + duration, err := time.ParseDuration(v) + if err != nil { + return fmt.Errorf("invalid duration format: %s", v) + } + d.Duration = duration + return nil + case int64: + d.Duration = time.Duration(v) + return nil + case float64: + d.Duration = time.Duration(int64(v)) + return nil + default: + return fmt.Errorf("cannot unmarshal %T into Duration", data) + } +} + +// MarshalYAML implements yaml.Marshaler interface +func (d Duration) MarshalYAML() (interface{}, error) { + return d.Duration.String(), nil +} + +// MarshalJSON implements json.Marshaler interface +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Duration.String()) +} + +// String returns the string representation of the duration +func (d Duration) String() string { + return d.Duration.String() +} + +// Value returns the time.Duration value +func (d Duration) Value() time.Duration { + return d.Duration +} + +// NewDuration creates a Duration from time.Duration +func NewDuration(d time.Duration) Duration { + return Duration{Duration: d} +} + +// ParseDuration creates a Duration from string +func ParseDuration(s string) (Duration, error) { + d, err := time.ParseDuration(s) + if err != nil { + return Duration{}, err + } + return Duration{Duration: d}, nil +} diff --git a/pkg/config/simple_loader.go b/pkg/config/simple_loader.go new file mode 100644 index 0000000..59d3a75 --- /dev/null +++ b/pkg/config/simple_loader.go @@ -0,0 +1,564 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package config provides simplified configuration loading using YAML and environment variables. +// This replaces the previous Viper-based configuration system with a lightweight, direct approach. +package config + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v2" +) + +const ( + defaultUnixSocketPath = "/tmp/atest-ext-ai.sock" + defaultWindowsListenAddress = "127.0.0.1:38081" +) + +// LoadConfig loads configuration from file and environment variables +func LoadConfig() (*Config, error) { + // 1. Try to load from config file + cfg, err := loadConfigFile() + if err != nil { + // Config file not found or invalid - use defaults + cfg = defaultConfig() + } + + // 2. Apply environment variable overrides + applyEnvOverrides(cfg) + + // 3. Apply default values for any missing fields + applyDefaults(cfg) + + // 4. Validate configuration + if err := validateConfig(cfg); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + + return cfg, nil +} + +// loadConfigFile tries to find and load a config file from standard locations +func loadConfigFile() (*Config, error) { + // Search paths in priority order + searchPaths := []string{ + "config.yaml", + "config.yml", + "./config.yaml", + "./config.yml", + filepath.Join(os.Getenv("HOME"), ".config", "atest", "config.yaml"), + "/etc/atest/config.yaml", + } + + var lastErr error + var attemptedPaths []string + + for _, path := range searchPaths { + attemptedPaths = append(attemptedPaths, path) + cfg, err := loadYAMLFile(path) + if err == nil { + fmt.Fprintf(os.Stderr, "Configuration loaded from: %s\n", path) + return cfg, nil + } + lastErr = err + } + + // Log all attempted paths for troubleshooting + fmt.Fprintf(os.Stderr, "Warning: No configuration file found. Attempted paths:\n") + for i, path := range attemptedPaths { + fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, path) + } + fmt.Fprintf(os.Stderr, "Using default configuration. Last error: %v\n", lastErr) + fmt.Fprintf(os.Stderr, "To customize: Create config.yaml in current directory or ~/.config/atest/\n") + + return nil, fmt.Errorf("no config file found (tried %d paths): %w", len(attemptedPaths), lastErr) +} + +// loadYAMLFile loads configuration from a YAML file +func loadYAMLFile(path string) (*Config, error) { + data, err := os.ReadFile(path) // #nosec G304 -- configuration paths are restricted to trusted locations + if err != nil { + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + + return &cfg, nil +} + +// applyEnvOverrides applies environment variable overrides to the configuration. +// GUI-driven configuration is the primary workflow; environment overrides remain +// for legacy automation scenarios and may be removed in future versions. +func applyEnvOverrides(cfg *Config) { + // Server configuration + if host := os.Getenv("ATEST_EXT_AI_SERVER_HOST"); host != "" { + cfg.Server.Host = host + } + if port := os.Getenv("ATEST_EXT_AI_SERVER_PORT"); port != "" { + if p, err := strconv.Atoi(port); err == nil { + cfg.Server.Port = p + } + } + if socketPath := os.Getenv("ATEST_EXT_AI_SERVER_SOCKET_PATH"); socketPath != "" { + cfg.Server.SocketPath = socketPath + } + if listenAddr := os.Getenv("ATEST_EXT_AI_SERVER_LISTEN_ADDR"); listenAddr != "" { + cfg.Server.ListenAddress = listenAddr + } + if timeout := os.Getenv("ATEST_EXT_AI_SERVER_TIMEOUT"); timeout != "" { + if d, err := time.ParseDuration(timeout); err == nil { + cfg.Server.Timeout = Duration{Duration: d} + } + } + + // Plugin configuration + if debug := os.Getenv("ATEST_EXT_AI_DEBUG"); debug != "" { + cfg.Plugin.Debug = strings.ToLower(debug) == "true" + } + if logLevel := os.Getenv("ATEST_EXT_AI_LOG_LEVEL"); logLevel != "" { + cfg.Plugin.LogLevel = logLevel + } + if env := os.Getenv("ATEST_EXT_AI_ENVIRONMENT"); env != "" { + cfg.Plugin.Environment = env + } + + // AI configuration + if defaultService := os.Getenv("ATEST_EXT_AI_DEFAULT_SERVICE"); defaultService != "" { + cfg.AI.DefaultService = defaultService + } + if defaultService := os.Getenv("ATEST_EXT_AI_AI_PROVIDER"); defaultService != "" { + cfg.AI.DefaultService = defaultService + } + if timeout := os.Getenv("ATEST_EXT_AI_AI_TIMEOUT"); timeout != "" { + if d, err := time.ParseDuration(timeout); err == nil { + cfg.AI.Timeout = Duration{Duration: d} + } + } + + // Initialize services map if nil + if cfg.AI.Services == nil { + cfg.AI.Services = make(map[string]AIService) + } + + // Ollama service configuration + if endpoint := os.Getenv("ATEST_EXT_AI_OLLAMA_ENDPOINT"); endpoint != "" { + svc := cfg.AI.Services["ollama"] + svc.Endpoint = endpoint + cfg.AI.Services["ollama"] = svc + } + if model := os.Getenv("ATEST_EXT_AI_OLLAMA_MODEL"); model != "" { + svc := cfg.AI.Services["ollama"] + svc.Model = model + cfg.AI.Services["ollama"] = svc + } + if model := os.Getenv("ATEST_EXT_AI_AI_MODEL"); model != "" { + // Also check generic AI_MODEL env var + svc := cfg.AI.Services["ollama"] + if svc.Model == "" { + svc.Model = model + } + cfg.AI.Services["ollama"] = svc + } + + // OpenAI service configuration + if apiKey := os.Getenv("ATEST_EXT_AI_OPENAI_API_KEY"); apiKey != "" { + svc, ok := cfg.AI.Services["openai"] + if !ok { + svc = AIService{ + Enabled: true, + Provider: "openai", + } + } + svc.APIKey = apiKey + cfg.AI.Services["openai"] = svc + } + if model := os.Getenv("ATEST_EXT_AI_OPENAI_MODEL"); model != "" { + svc := cfg.AI.Services["openai"] + svc.Model = model + cfg.AI.Services["openai"] = svc + } + + // Claude service configuration + if apiKey := os.Getenv("ATEST_EXT_AI_CLAUDE_API_KEY"); apiKey != "" { + svc, ok := cfg.AI.Services["claude"] + if !ok { + svc = AIService{ + Enabled: true, + Provider: "claude", + } + } + svc.APIKey = apiKey + cfg.AI.Services["claude"] = svc + } + if model := os.Getenv("ATEST_EXT_AI_CLAUDE_MODEL"); model != "" { + svc := cfg.AI.Services["claude"] + svc.Model = model + cfg.AI.Services["claude"] = svc + } + + // Database configuration + if enabled := os.Getenv("ATEST_EXT_AI_DATABASE_ENABLED"); enabled != "" { + cfg.Database.Enabled = strings.ToLower(enabled) == "true" + } + if driver := os.Getenv("ATEST_EXT_AI_DATABASE_DRIVER"); driver != "" { + cfg.Database.Driver = driver + } + if dsn := os.Getenv("ATEST_EXT_AI_DATABASE_DSN"); dsn != "" { + cfg.Database.DSN = dsn + } + + // Logging configuration + if level := os.Getenv("ATEST_EXT_AI_LOG_LEVEL"); level != "" { + cfg.Logging.Level = level + } + if format := os.Getenv("ATEST_EXT_AI_LOG_FORMAT"); format != "" { + cfg.Logging.Format = format + } + if output := os.Getenv("ATEST_EXT_AI_LOG_OUTPUT"); output != "" { + cfg.Logging.Output = output + } +} + +// applyDefaults applies default values for any missing configuration +func applyDefaults(cfg *Config) { + // Server defaults + if cfg.Server.Host == "" { + cfg.Server.Host = "0.0.0.0" + } + if cfg.Server.Port == 0 { + cfg.Server.Port = 8080 + } + if runtime.GOOS == "windows" { + if cfg.Server.ListenAddress == "" { + cfg.Server.ListenAddress = defaultWindowsListenAddress + } + } else { + if cfg.Server.SocketPath == "" { + cfg.Server.SocketPath = defaultUnixSocketPath + } + } + if cfg.Server.Timeout.Duration == 0 { + cfg.Server.Timeout = Duration{Duration: 30 * time.Second} + } + if cfg.Server.ReadTimeout.Duration == 0 { + cfg.Server.ReadTimeout = Duration{Duration: 15 * time.Second} + } + if cfg.Server.WriteTimeout.Duration == 0 { + cfg.Server.WriteTimeout = Duration{Duration: 15 * time.Second} + } + if cfg.Server.MaxConns == 0 { + cfg.Server.MaxConns = 100 + } + + // Plugin defaults + if cfg.Plugin.Name == "" { + cfg.Plugin.Name = "atest-ext-ai" + } + if cfg.Plugin.Version == "" { + cfg.Plugin.Version = "1.0.0" + } + if cfg.Plugin.LogLevel == "" { + cfg.Plugin.LogLevel = "info" + } + if cfg.Plugin.Environment == "" { + cfg.Plugin.Environment = "production" + } + + // AI defaults + if cfg.AI.DefaultService == "" { + cfg.AI.DefaultService = "ollama" + } + if cfg.AI.Timeout.Duration == 0 { + cfg.AI.Timeout = Duration{Duration: 60 * time.Second} + } + + // Initialize services map if nil + if cfg.AI.Services == nil { + cfg.AI.Services = make(map[string]AIService) + } + + // Ollama service defaults + if _, exists := cfg.AI.Services["ollama"]; !exists { + cfg.AI.Services["ollama"] = AIService{ + Enabled: true, + Provider: "ollama", + Endpoint: "http://localhost:11434", + Model: "qwen2.5-coder:latest", + MaxTokens: 4096, + Priority: 1, + Timeout: Duration{Duration: 60 * time.Second}, + } + } else { + // Fill in missing fields for existing Ollama service + svc := cfg.AI.Services["ollama"] + if svc.Endpoint == "" { + svc.Endpoint = "http://localhost:11434" + } + if svc.Model == "" { + svc.Model = "qwen2.5-coder:latest" + } + if svc.MaxTokens == 0 { + svc.MaxTokens = 4096 + } + if svc.Timeout.Duration == 0 { + svc.Timeout = Duration{Duration: 60 * time.Second} + } + if svc.Priority == 0 { + svc.Priority = 1 + } + cfg.AI.Services["ollama"] = svc + } + + // Retry defaults + if cfg.AI.Retry.MaxAttempts == 0 { + cfg.AI.Retry.Enabled = true + cfg.AI.Retry.MaxAttempts = 3 + cfg.AI.Retry.InitialDelay = Duration{Duration: 1 * time.Second} + cfg.AI.Retry.MaxDelay = Duration{Duration: 30 * time.Second} + cfg.AI.Retry.Multiplier = 2.0 + cfg.AI.Retry.Jitter = true + } + + // Rate limit defaults + if cfg.AI.RateLimit.RequestsPerMinute == 0 { + cfg.AI.RateLimit.Enabled = true + cfg.AI.RateLimit.RequestsPerMinute = 60 + cfg.AI.RateLimit.BurstSize = 10 + cfg.AI.RateLimit.WindowSize = Duration{Duration: 1 * time.Minute} + } + + // Database defaults + if cfg.Database.Driver == "" { + cfg.Database.Driver = "sqlite" + } + if cfg.Database.DSN == "" { + cfg.Database.DSN = "file:atest-ext-ai.db?cache=shared&mode=rwc" + } + if cfg.Database.DefaultType == "" { + cfg.Database.DefaultType = "mysql" + } + if cfg.Database.MaxConns == 0 { + cfg.Database.MaxConns = 10 + } + if cfg.Database.MaxIdle == 0 { + cfg.Database.MaxIdle = 5 + } + if cfg.Database.MaxLifetime.Duration == 0 { + cfg.Database.MaxLifetime = Duration{Duration: 1 * time.Hour} + } + + // Logging defaults + if cfg.Logging.Level == "" { + cfg.Logging.Level = "info" + } + if cfg.Logging.Format == "" { + cfg.Logging.Format = "json" + } + if cfg.Logging.Output == "" { + cfg.Logging.Output = "stdout" + } + if cfg.Logging.File.Path == "" { + cfg.Logging.File.Path = "/var/log/atest-ext-ai.log" + } + if cfg.Logging.File.MaxSize == "" { + cfg.Logging.File.MaxSize = "100MB" + } + if cfg.Logging.File.MaxBackups == 0 { + cfg.Logging.File.MaxBackups = 3 + } + if cfg.Logging.File.MaxAge == 0 { + cfg.Logging.File.MaxAge = 28 + } +} + +// validateConfig validates the configuration with relaxed rules +// Only critical configuration errors cause failure - the plugin can start with minimal config +func validateConfig(cfg *Config) error { + var errors []string + + // Critical validations only - allow minimal configuration to work + + // Validate server port is in valid range (but allow defaults to work) + if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { + errors = append(errors, fmt.Sprintf("invalid server port: %d (must be 1-65535)", cfg.Server.Port)) + } + + // Note: Other server fields have defaults, no validation needed + + // Plugin configuration has defaults, no validation needed + + // AI configuration - only validate if services are configured + if len(cfg.AI.Services) > 0 { + validProviders := []string{"ollama", "openai", "claude", "deepseek", "local", "custom"} + for name, svc := range cfg.AI.Services { + if !svc.Enabled { + continue + } + + // Validate provider is recognized (but allow unknown providers to pass through) + if svc.Provider != "" && !contains(validProviders, svc.Provider) { + // Warning only - don't fail + fmt.Fprintf(os.Stderr, "Warning: service '%s' has unknown provider '%s' (known: %s)\n", + name, svc.Provider, strings.Join(validProviders, ", ")) + } + + // API key validation is now a warning, not an error (graceful degradation) + if svc.Provider == "openai" || svc.Provider == "claude" || svc.Provider == "deepseek" { + if svc.APIKey == "" { + fmt.Fprintf(os.Stderr, "Warning: service '%s' (provider '%s') has no API key configured - it may not work\n", + name, svc.Provider) + } + } + + // MaxTokens validation relaxed - only check if set to unreasonable values + if svc.MaxTokens < 0 || svc.MaxTokens > 1000000 { + fmt.Fprintf(os.Stderr, "Warning: service '%s' has unusual max_tokens value: %d\n", name, svc.MaxTokens) + } + } + } + + // Retry configuration - relax validation + if cfg.AI.Retry.MaxAttempts > 100 { + fmt.Fprintf(os.Stderr, "Warning: AI retry max_attempts is very high: %d\n", cfg.AI.Retry.MaxAttempts) + } + + // Database configuration - only validate if enabled + if cfg.Database.Enabled { + validDrivers := []string{"sqlite", "mysql", "postgresql"} + if cfg.Database.Driver != "" && !contains(validDrivers, cfg.Database.Driver) { + errors = append(errors, fmt.Sprintf("invalid database driver: %s (must be one of: %s)", + cfg.Database.Driver, strings.Join(validDrivers, ", "))) + } + if cfg.Database.DSN == "" { + errors = append(errors, "database DSN cannot be empty when database is enabled") + } + } + + // Logging configuration validation - warnings only + validFormats := []string{"json", "text"} + if cfg.Logging.Format != "" && !contains(validFormats, cfg.Logging.Format) { + fmt.Fprintf(os.Stderr, "Warning: unknown logging format '%s' (known: %s), will use default\n", + cfg.Logging.Format, strings.Join(validFormats, ", ")) + cfg.Logging.Format = "json" // Fix it instead of failing + } + validOutputs := []string{"stdout", "stderr", "file"} + if cfg.Logging.Output != "" && !contains(validOutputs, cfg.Logging.Output) { + fmt.Fprintf(os.Stderr, "Warning: unknown logging output '%s' (known: %s), will use default\n", + cfg.Logging.Output, strings.Join(validOutputs, ", ")) + cfg.Logging.Output = "stdout" // Fix it instead of failing + } + + // Only fail if there are critical errors + if len(errors) > 0 { + return fmt.Errorf("configuration validation failed (critical errors only):\n - %s", + strings.Join(errors, "\n - ")) + } + + return nil +} + +// defaultConfig returns a configuration with all default values +func defaultConfig() *Config { + return &Config{ + Server: ServerConfig{ + Host: "0.0.0.0", + Port: 8080, + SocketPath: defaultUnixSocketPath, + ListenAddress: defaultWindowsListenAddress, + Timeout: Duration{Duration: 30 * time.Second}, + ReadTimeout: Duration{Duration: 15 * time.Second}, + WriteTimeout: Duration{Duration: 15 * time.Second}, + MaxConns: 100, + }, + Plugin: PluginConfig{ + Name: "atest-ext-ai", + Version: "1.0.0", + Debug: false, + LogLevel: "info", + Environment: "production", + }, + AI: AIConfig{ + DefaultService: "ollama", + Timeout: Duration{Duration: 60 * time.Second}, + Services: map[string]AIService{ + "ollama": { + Enabled: true, + Provider: "ollama", + Endpoint: "http://localhost:11434", + Model: "qwen2.5-coder:latest", + MaxTokens: 4096, + Priority: 1, + Timeout: Duration{Duration: 60 * time.Second}, + }, + }, + Retry: RetryConfig{ + Enabled: true, + MaxAttempts: 3, + InitialDelay: Duration{Duration: 1 * time.Second}, + MaxDelay: Duration{Duration: 30 * time.Second}, + Multiplier: 2.0, + Jitter: true, + }, + RateLimit: RateLimitConfig{ + Enabled: true, + RequestsPerMinute: 60, + BurstSize: 10, + WindowSize: Duration{Duration: 1 * time.Minute}, + }, + }, + Database: DatabaseConfig{ + Enabled: false, + Driver: "sqlite", + DSN: "file:atest-ext-ai.db?cache=shared&mode=rwc", + DefaultType: "mysql", + MaxConns: 10, + MaxIdle: 5, + MaxLifetime: Duration{Duration: 1 * time.Hour}, + }, + Logging: LoggingConfig{ + Level: "info", + Format: "json", + Output: "stdout", + File: LogFileConfig{ + Path: "/var/log/atest-ext-ai.log", + MaxSize: "100MB", + MaxBackups: 3, + MaxAge: 28, + Compress: true, + }, + }, + } +} + +// contains checks if a string slice contains a specific string (case-insensitive) +func contains(slice []string, item string) bool { + for _, s := range slice { + if strings.EqualFold(s, item) { + return true + } + } + return false +} diff --git a/pkg/config/simple_loader_test.go b/pkg/config/simple_loader_test.go new file mode 100644 index 0000000..0a8f370 --- /dev/null +++ b/pkg/config/simple_loader_test.go @@ -0,0 +1,355 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigDefaults(t *testing.T) { + // Change to temp directory to avoid loading real config + tempDir := t.TempDir() + switchToDir(t, tempDir) + + // Load configuration (should use defaults when no file exists) + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("Failed to load default configuration: %v", err) + } + + // Verify default values + if cfg.Server.Host != "0.0.0.0" { + t.Errorf("Expected default host '0.0.0.0', got '%s'", cfg.Server.Host) + } + if cfg.Server.Port != 8080 { + t.Errorf("Expected default port 8080, got %d", cfg.Server.Port) + } + if cfg.Plugin.Name != "atest-ext-ai" { + t.Errorf("Expected default plugin name 'atest-ext-ai', got '%s'", cfg.Plugin.Name) + } + if cfg.AI.DefaultService != "ollama" { + t.Errorf("Expected default service 'ollama', got '%s'", cfg.AI.DefaultService) + } +} + +func TestLoadConfigFromYAML(t *testing.T) { + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "config.yaml") + + configData := ` +server: + host: "test-host" + port: 9090 + timeout: "45s" + max_connections: 200 + socket_path: "/tmp/test.sock" + +plugin: + name: "test-plugin" + version: "2.0.0" + debug: true + log_level: "debug" + environment: "production" + +ai: + default_service: "openai" + timeout: "120s" + services: + openai: + enabled: true + provider: "openai" + api_key: "test-key" + model: "gpt-4" + max_tokens: 8192 + priority: 1 +` + + if err := os.WriteFile(configFile, []byte(configData), 0o600); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Change directory to where the config file is + switchToDir(t, tempDir) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("Failed to load configuration from YAML: %v", err) + } + + // Verify loaded values + if cfg.Server.Host != "test-host" { + t.Errorf("Expected host 'test-host', got '%s'", cfg.Server.Host) + } + if cfg.Server.Port != 9090 { + t.Errorf("Expected port 9090, got %d", cfg.Server.Port) + } + if cfg.Plugin.Name != "test-plugin" { + t.Errorf("Expected plugin name 'test-plugin', got '%s'", cfg.Plugin.Name) + } + if cfg.AI.DefaultService != "openai" { + t.Errorf("Expected default service 'openai', got '%s'", cfg.AI.DefaultService) + } +} + +func TestLoadConfigWithEnvOverrides(t *testing.T) { + // Set environment variables + _ = os.Setenv("ATEST_EXT_AI_SERVER_HOST", "env-host") + _ = os.Setenv("ATEST_EXT_AI_SERVER_PORT", "5555") + _ = os.Setenv("ATEST_EXT_AI_LOG_LEVEL", "debug") + // Note: Not setting AI_PROVIDER to avoid validation issues with default services + defer func() { + _ = os.Unsetenv("ATEST_EXT_AI_SERVER_HOST") + _ = os.Unsetenv("ATEST_EXT_AI_SERVER_PORT") + _ = os.Unsetenv("ATEST_EXT_AI_LOG_LEVEL") + }() + + // Change to temp directory to avoid loading real config + tempDir := t.TempDir() + switchToDir(t, tempDir) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("Failed to load configuration with env overrides: %v", err) + } + + // Verify environment variables override defaults + if cfg.Server.Host != "env-host" { + t.Errorf("Expected host from env 'env-host', got '%s'", cfg.Server.Host) + } + if cfg.Server.Port != 5555 { + t.Errorf("Expected port from env 5555, got %d", cfg.Server.Port) + } + if cfg.Plugin.LogLevel != "debug" { + t.Errorf("Expected log level from env 'debug', got '%s'", cfg.Plugin.LogLevel) + } + // Verify AI default service is still 'ollama' (default, since we didn't override it) + if cfg.AI.DefaultService != "ollama" { + t.Errorf("Expected default service 'ollama', got '%s'", cfg.AI.DefaultService) + } +} + +func TestValidateConfigErrors(t *testing.T) { + tests := []struct { + name string + modifyFunc func(*Config) + shouldError bool + }{ + { + name: "valid config", + modifyFunc: func(_ *Config) { + // No modifications, default config should be valid + }, + shouldError: false, + }, + { + name: "invalid port - too low", + modifyFunc: func(cfg *Config) { + cfg.Server.Port = 0 + }, + shouldError: true, + }, + { + name: "invalid port - too high", + modifyFunc: func(cfg *Config) { + cfg.Server.Port = 70000 + }, + shouldError: true, + }, + { + name: "empty host", + modifyFunc: func(cfg *Config) { + cfg.Server.Host = "" + }, + shouldError: false, + }, + { + name: "empty default service", + modifyFunc: func(cfg *Config) { + cfg.AI.DefaultService = "" + }, + shouldError: false, + }, + { + name: "default service not in services", + modifyFunc: func(cfg *Config) { + cfg.AI.DefaultService = "nonexistent" + }, + shouldError: false, + }, + { + name: "invalid log level", + modifyFunc: func(cfg *Config) { + cfg.Plugin.LogLevel = "invalid" + }, + shouldError: false, + }, + { + name: "invalid environment", + modifyFunc: func(cfg *Config) { + cfg.Plugin.Environment = "invalid" + }, + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := defaultConfig() + tt.modifyFunc(cfg) + + err := validateConfig(cfg) + if tt.shouldError && err == nil { + t.Errorf("Expected validation error but got nil") + } + if !tt.shouldError && err != nil { + t.Errorf("Expected no validation error but got: %v", err) + } + }) + } +} + +func TestApplyDefaults(t *testing.T) { + cfg := &Config{} + applyDefaults(cfg) + + // Verify defaults are applied + if cfg.Server.Host == "" { + t.Error("Expected server host to have default value") + } + if cfg.Server.Port == 0 { + t.Error("Expected server port to have default value") + } + if cfg.Plugin.Name == "" { + t.Error("Expected plugin name to have default value") + } + if cfg.AI.DefaultService == "" { + t.Error("Expected AI default service to have default value") + } + if len(cfg.AI.Services) == 0 { + t.Error("Expected AI services to have default values") + } + if cfg.AI.Retry.MaxAttempts == 0 { + t.Error("Expected retry max attempts to have default value") + } +} + +func TestLoadYAMLFile(t *testing.T) { + tempDir := t.TempDir() + validFile := filepath.Join(tempDir, "valid.yaml") + invalidFile := filepath.Join(tempDir, "invalid.yaml") + nonexistentFile := filepath.Join(tempDir, "nonexistent.yaml") + + // Create valid YAML file + validData := ` +server: + host: "localhost" + port: 8080 +plugin: + name: "test" + version: "1.0.0" + log_level: "info" + environment: "production" +ai: + default_service: "ollama" + services: + ollama: + enabled: true + provider: "ollama" + model: "test-model" + max_tokens: 4096 + priority: 1 +` + if err := os.WriteFile(validFile, []byte(validData), 0o600); err != nil { + t.Fatalf("Failed to write valid file: %v", err) + } + + // Create invalid YAML file + invalidData := ` +server: + host: "localhost" + - invalid syntax +` + if err := os.WriteFile(invalidFile, []byte(invalidData), 0o600); err != nil { + t.Fatalf("Failed to write invalid file: %v", err) + } + + // Test valid file + cfg, err := loadYAMLFile(validFile) + if err != nil { + t.Errorf("Expected no error for valid file, got: %v", err) + } + if cfg == nil { + t.Error("Expected config to be loaded, got nil") + } + + // Test invalid file + _, err = loadYAMLFile(invalidFile) + if err == nil { + t.Error("Expected error for invalid YAML, got nil") + } + + // Test nonexistent file + _, err = loadYAMLFile(nonexistentFile) + if err == nil { + t.Error("Expected error for nonexistent file, got nil") + } +} + +func TestContainsFunction(t *testing.T) { + tests := []struct { + name string + slice []string + item string + expected bool + }{ + {"found in slice", []string{"a", "b", "c"}, "b", true}, + {"not found in slice", []string{"a", "b", "c"}, "d", false}, + {"case insensitive match", []string{"Debug", "Info"}, "debug", true}, + {"empty slice", []string{}, "a", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := contains(tt.slice, tt.item) + if result != tt.expected { + t.Errorf("contains(%v, %s) = %v, expected %v", tt.slice, tt.item, result, tt.expected) + } + }) + } +} + +// switchToDir changes the current working directory for the duration of the test. +func switchToDir(t *testing.T, dir string) { + t.Helper() + + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current working directory: %v", err) + } + + if err := os.Chdir(dir); err != nil { + t.Fatalf("failed to change directory to %s: %v", dir, err) + } + + t.Cleanup(func() { + if err := os.Chdir(originalWd); err != nil { + t.Fatalf("failed to restore working directory: %v", err) + } + }) +} diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 0000000..0520367 --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,132 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +// No imports needed for this file + +// Config represents the complete application configuration +type Config struct { + Server ServerConfig `yaml:"server" json:"server" validate:"required"` + Plugin PluginConfig `yaml:"plugin" json:"plugin" validate:"required"` + AI AIConfig `yaml:"ai" json:"ai" validate:"required"` + Database DatabaseConfig `yaml:"database" json:"database"` + Logging LoggingConfig `yaml:"logging" json:"logging"` +} + +// ServerConfig contains server-specific configuration +type ServerConfig struct { + Host string `yaml:"host" json:"host"` + Port int `yaml:"port" json:"port" validate:"min=1,max=65535"` + Timeout Duration `yaml:"timeout" json:"timeout"` + MaxConns int `yaml:"max_connections" json:"max_connections"` + SocketPath string `yaml:"socket_path" json:"socket_path"` + ListenAddress string `yaml:"listen_address" json:"listen_address"` + ReadTimeout Duration `yaml:"read_timeout" json:"read_timeout"` + WriteTimeout Duration `yaml:"write_timeout" json:"write_timeout"` +} + +// PluginConfig contains plugin-specific configuration +type PluginConfig struct { + Name string `yaml:"name" json:"name"` + Version string `yaml:"version" json:"version"` + Debug bool `yaml:"debug" json:"debug"` + LogLevel string `yaml:"log_level" json:"log_level"` + Environment string `yaml:"environment" json:"environment"` +} + +// AIConfig contains AI service configuration +type AIConfig struct { + DefaultService string `yaml:"default_service" json:"default_service"` + Services map[string]AIService `yaml:"services" json:"services"` + Fallback []string `yaml:"fallback_order" json:"fallback_order"` + Timeout Duration `yaml:"timeout" json:"timeout"` + RateLimit RateLimitConfig `yaml:"rate_limit" json:"rate_limit"` + Retry RetryConfig `yaml:"retry" json:"retry"` +} + +// AIService represents configuration for a specific AI service +type AIService struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Provider string `yaml:"provider" json:"provider"` + Endpoint string `yaml:"endpoint" json:"endpoint"` + APIKey string `yaml:"api_key" json:"api_key"` + Model string `yaml:"model" json:"model"` + MaxTokens int `yaml:"max_tokens" json:"max_tokens"` + TopP float32 `yaml:"top_p" json:"top_p"` + Headers map[string]string `yaml:"headers" json:"headers"` + Models []string `yaml:"models" json:"models"` + Priority int `yaml:"priority" json:"priority"` + Timeout Duration `yaml:"timeout" json:"timeout"` + + // Deprecated fields (kept for backward compatibility warning) + Temperature float32 `yaml:"temperature" json:"temperature,omitempty"` +} + +// ValidateAndWarnDeprecated checks for deprecated fields and returns warnings +func (s *AIService) ValidateAndWarnDeprecated() []string { + var warnings []string + if s.Temperature != 0 { + warnings = append(warnings, "Temperature field is deprecated and will be ignored. Configure temperature when creating the LLM client if needed.") + } + return warnings +} + +// RateLimitConfig contains rate limiting configuration +type RateLimitConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + RequestsPerMinute int `yaml:"requests_per_minute" json:"requests_per_minute"` + BurstSize int `yaml:"burst_size" json:"burst_size"` + WindowSize Duration `yaml:"window_size" json:"window_size"` +} + +// RetryConfig contains retry configuration +type RetryConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + MaxAttempts int `yaml:"max_attempts" json:"max_attempts"` + InitialDelay Duration `yaml:"initial_delay" json:"initial_delay"` + MaxDelay Duration `yaml:"max_delay" json:"max_delay"` + Multiplier float32 `yaml:"multiplier" json:"multiplier"` + Jitter bool `yaml:"jitter" json:"jitter"` +} + +// DatabaseConfig contains database configuration (optional) +type DatabaseConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Driver string `yaml:"driver" json:"driver"` + DSN string `yaml:"dsn" json:"dsn"` + DefaultType string `yaml:"default_type" json:"default_type"` + MaxConns int `yaml:"max_connections" json:"max_connections"` + MaxIdle int `yaml:"max_idle" json:"max_idle"` + MaxLifetime Duration `yaml:"max_lifetime" json:"max_lifetime"` +} + +// LoggingConfig contains logging configuration +type LoggingConfig struct { + Level string `yaml:"level" json:"level"` + Format string `yaml:"format" json:"format"` + Output string `yaml:"output" json:"output"` + File LogFileConfig `yaml:"file" json:"file"` +} + +// LogFileConfig contains log file configuration +type LogFileConfig struct { + Path string `yaml:"path" json:"path"` + MaxSize string `yaml:"max_size" json:"max_size"` + MaxBackups int `yaml:"max_backups" json:"max_backups"` + MaxAge int `yaml:"max_age" json:"max_age"` + Compress bool `yaml:"compress" json:"compress"` +} diff --git a/pkg/errors/doc.go b/pkg/errors/doc.go new file mode 100644 index 0000000..0417a0e --- /dev/null +++ b/pkg/errors/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package errors defines reusable error types and classification helpers. +package errors diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 0000000..4999c63 --- /dev/null +++ b/pkg/errors/errors.go @@ -0,0 +1,187 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package errors + +import ( + "errors" + "fmt" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Standard errors that can be used across the application +// These follow the Uber Go Style Guide pattern of declaring sentinel errors +var ( + // ErrProviderNotConfigured indicates that no AI provider is configured or enabled + ErrProviderNotConfigured = errors.New("AI provider not configured") + + // ErrModelNotFound indicates that the requested model does not exist + ErrModelNotFound = errors.New("model not found") + + // ErrInvalidConfig indicates that configuration validation failed + ErrInvalidConfig = errors.New("invalid configuration") + + // ErrConnectionFailed indicates that connection to AI service failed + ErrConnectionFailed = errors.New("connection failed") + + // ErrProviderNotAvailable indicates that the provider exists but is not currently available + ErrProviderNotAvailable = errors.New("provider not available") + + // ErrInvalidRequest indicates that the request parameters are invalid + ErrInvalidRequest = errors.New("invalid request") + + // ErrTimeout indicates that an operation timed out + ErrTimeout = errors.New("operation timed out") + + // ErrResourceExhausted indicates that rate limits or quotas have been exceeded + ErrResourceExhausted = errors.New("resource exhausted") +) + +// ToGRPCError converts internal application errors to gRPC status errors +// with appropriate error codes. +// +// This function implements the error mapping strategy defined in docs/ERROR_HANDLING.md +// +// Usage: +// +// if err := doSomething(); err != nil { +// return ToGRPCError(err) +// } +func ToGRPCError(err error) error { + if err == nil { + return nil + } + + // Map specific errors to appropriate gRPC codes + switch { + case errors.Is(err, ErrProviderNotConfigured): + return status.Error(codes.FailedPrecondition, err.Error()) + + case errors.Is(err, ErrModelNotFound): + return status.Error(codes.NotFound, err.Error()) + + case errors.Is(err, ErrInvalidConfig), errors.Is(err, ErrInvalidRequest): + return status.Error(codes.InvalidArgument, err.Error()) + + case errors.Is(err, ErrConnectionFailed), errors.Is(err, ErrProviderNotAvailable): + return status.Error(codes.Unavailable, err.Error()) + + case errors.Is(err, ErrTimeout): + return status.Error(codes.DeadlineExceeded, err.Error()) + + case errors.Is(err, ErrResourceExhausted): + return status.Error(codes.ResourceExhausted, err.Error()) + + default: + // For unknown errors, return as Internal error + return status.Error(codes.Internal, err.Error()) + } +} + +// ToGRPCErrorf is a convenience function that wraps fmt.Errorf and ToGRPCError +// +// Usage: +// +// return ToGRPCErrorf(ErrModelNotFound, "model %s not found in provider %s", modelID, provider) +func ToGRPCErrorf(err error, format string, args ...interface{}) error { + wrapped := fmt.Errorf(format+": %w", append(args, err)...) + return ToGRPCError(wrapped) +} + +// IsRetryable checks if an error indicates a retryable condition +// +// Usage: +// +// if err != nil && IsRetryable(err) { +// // Retry the operation +// } +func IsRetryable(err error) bool { + if err == nil { + return false + } + + // Check for specific retryable errors + switch { + case errors.Is(err, ErrConnectionFailed): + return true + case errors.Is(err, ErrProviderNotAvailable): + return true + case errors.Is(err, ErrTimeout): + return true + case errors.Is(err, ErrResourceExhausted): + return true + default: + // Check gRPC status codes for retryable conditions + if st, ok := status.FromError(err); ok { + code := st.Code() + return code == codes.Unavailable || + code == codes.DeadlineExceeded || + code == codes.ResourceExhausted || + code == codes.Aborted + } + return false + } +} + +// ValidationError represents a configuration or request validation error +// with details about which field failed validation +type ValidationError struct { + Field string // The field that failed validation + Value string // The invalid value (if safe to include) + Message string // Human-readable error message +} + +func (e *ValidationError) Error() string { + if e.Value != "" { + return fmt.Sprintf("validation failed for field %q with value %q: %s", e.Field, e.Value, e.Message) + } + return fmt.Sprintf("validation failed for field %q: %s", e.Field, e.Message) +} + +// NewValidationError creates a new ValidationError +func NewValidationError(field, value, message string) error { + return &ValidationError{ + Field: field, + Value: value, + Message: message, + } +} + +// ConnectionError wraps connection-related errors with additional context +type ConnectionError struct { + Provider string // AI provider name + Endpoint string // Connection endpoint + Err error // Underlying error +} + +func (e *ConnectionError) Error() string { + return fmt.Sprintf("connection to %s (%s) failed: %v", e.Provider, e.Endpoint, e.Err) +} + +func (e *ConnectionError) Unwrap() error { + return e.Err +} + +// NewConnectionError creates a new ConnectionError +func NewConnectionError(provider, endpoint string, err error) error { + return &ConnectionError{ + Provider: provider, + Endpoint: endpoint, + Err: err, + } +} diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go new file mode 100644 index 0000000..637d3d6 --- /dev/null +++ b/pkg/errors/errors_test.go @@ -0,0 +1,240 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package errors + +import ( + "errors" + "fmt" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestToGRPCError(t *testing.T) { + tests := []struct { + name string + err error + expectedCode codes.Code + }{ + { + name: "nil error", + err: nil, + expectedCode: codes.OK, + }, + { + name: "provider not configured", + err: ErrProviderNotConfigured, + expectedCode: codes.FailedPrecondition, + }, + { + name: "model not found", + err: ErrModelNotFound, + expectedCode: codes.NotFound, + }, + { + name: "invalid config", + err: ErrInvalidConfig, + expectedCode: codes.InvalidArgument, + }, + { + name: "invalid request", + err: ErrInvalidRequest, + expectedCode: codes.InvalidArgument, + }, + { + name: "connection failed", + err: ErrConnectionFailed, + expectedCode: codes.Unavailable, + }, + { + name: "provider not available", + err: ErrProviderNotAvailable, + expectedCode: codes.Unavailable, + }, + { + name: "timeout", + err: ErrTimeout, + expectedCode: codes.DeadlineExceeded, + }, + { + name: "resource exhausted", + err: ErrResourceExhausted, + expectedCode: codes.ResourceExhausted, + }, + { + name: "unknown error", + err: errors.New("some unknown error"), + expectedCode: codes.Internal, + }, + { + name: "wrapped error", + err: fmt.Errorf("outer: %w", ErrModelNotFound), + expectedCode: codes.NotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + grpcErr := ToGRPCError(tt.err) + + if tt.err == nil { + if grpcErr != nil { + t.Errorf("expected nil error, got %v", grpcErr) + } + return + } + + st, ok := status.FromError(grpcErr) + if !ok { + t.Fatalf("expected gRPC status error, got %T", grpcErr) + } + + if st.Code() != tt.expectedCode { + t.Errorf("expected code %v, got %v", tt.expectedCode, st.Code()) + } + }) + } +} + +func TestToGRPCErrorf(t *testing.T) { + err := ToGRPCErrorf(ErrModelNotFound, "model %s not found in provider %s", "gpt-4", "openai") + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status error, got %T", err) + } + + if st.Code() != codes.NotFound { + t.Errorf("expected code NotFound, got %v", st.Code()) + } + + expectedMsg := "model gpt-4 not found in provider openai: model not found" + if st.Message() != expectedMsg { + t.Errorf("expected message %q, got %q", expectedMsg, st.Message()) + } +} + +func TestIsRetryable(t *testing.T) { + tests := []struct { + name string + err error + retryable bool + }{ + { + name: "nil error", + err: nil, + retryable: false, + }, + { + name: "connection failed", + err: ErrConnectionFailed, + retryable: true, + }, + { + name: "provider not available", + err: ErrProviderNotAvailable, + retryable: true, + }, + { + name: "timeout", + err: ErrTimeout, + retryable: true, + }, + { + name: "resource exhausted", + err: ErrResourceExhausted, + retryable: true, + }, + { + name: "invalid config", + err: ErrInvalidConfig, + retryable: false, + }, + { + name: "model not found", + err: ErrModelNotFound, + retryable: false, + }, + { + name: "gRPC unavailable", + err: status.Error(codes.Unavailable, "service unavailable"), + retryable: true, + }, + { + name: "gRPC deadline exceeded", + err: status.Error(codes.DeadlineExceeded, "timeout"), + retryable: true, + }, + { + name: "gRPC invalid argument", + err: status.Error(codes.InvalidArgument, "bad request"), + retryable: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsRetryable(tt.err) + if result != tt.retryable { + t.Errorf("expected retryable=%v, got %v", tt.retryable, result) + } + }) + } +} + +func TestValidationError(t *testing.T) { + err := NewValidationError("model", "invalid-model", "model name contains invalid characters") + + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *ValidationError, got %T", err) + } + + if ve.Field != "model" { + t.Errorf("expected field 'model', got %q", ve.Field) + } + + expectedMsg := `validation failed for field "model" with value "invalid-model": model name contains invalid characters` + if ve.Error() != expectedMsg { + t.Errorf("expected message %q, got %q", expectedMsg, ve.Error()) + } +} + +func TestConnectionError(t *testing.T) { + underlying := errors.New("dial tcp: connection refused") + err := NewConnectionError("ollama", "http://localhost:11434", underlying) + + var ce *ConnectionError + if !errors.As(err, &ce) { + t.Fatalf("expected *ConnectionError, got %T", err) + } + + if ce.Provider != "ollama" { + t.Errorf("expected provider 'ollama', got %q", ce.Provider) + } + + // Test Unwrap + if !errors.Is(err, underlying) { + t.Error("expected ConnectionError to wrap underlying error") + } + + expectedMsg := "connection to ollama (http://localhost:11434) failed: dial tcp: connection refused" + if ce.Error() != expectedMsg { + t.Errorf("expected message %q, got %q", expectedMsg, ce.Error()) + } +} diff --git a/pkg/interfaces/ai.go b/pkg/interfaces/ai.go new file mode 100644 index 0000000..95844cb --- /dev/null +++ b/pkg/interfaces/ai.go @@ -0,0 +1,182 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//revive:disable-next-line var-naming +package interfaces + +import ( + "context" + "time" +) + +// GenerateRequest represents a unified AI generation request +type GenerateRequest struct { + // Prompt is the input text for generation + Prompt string `json:"prompt"` + + // Model specifies which model to use for generation + Model string `json:"model"` + + // MaxTokens limits the maximum number of tokens in the response + MaxTokens int `json:"max_tokens,omitempty"` + + // Context provides additional context for the generation + Context []string `json:"context,omitempty"` + + // Options allows provider-specific parameters + Options map[string]any `json:"options,omitempty"` + + // SystemPrompt provides system-level instructions + SystemPrompt string `json:"system_prompt,omitempty"` + + // Stream indicates whether to stream the response + Stream bool `json:"stream,omitempty"` +} + +// GenerateResponse represents a unified AI generation response +type GenerateResponse struct { + // Text is the generated content + Text string `json:"text"` + + // Model indicates which model was actually used + Model string `json:"model"` + + // Metadata contains provider-specific metadata + Metadata map[string]any `json:"metadata,omitempty"` + + // RequestID is a unique identifier for this request + RequestID string `json:"request_id,omitempty"` + + // ProcessingTime indicates how long the generation took + ProcessingTime time.Duration `json:"processing_time"` + + // ConfidenceScore indicates the model's confidence in the response + ConfidenceScore float64 `json:"confidence_score,omitempty"` +} + +// HealthStatus represents the health status of an AI service +type HealthStatus struct { + // Healthy indicates if the service is healthy + Healthy bool `json:"healthy"` + + // Status provides a human-readable status message + Status string `json:"status"` + + // ResponseTime indicates the response time for the health check + ResponseTime time.Duration `json:"response_time"` + + // LastChecked indicates when the health check was last performed + LastChecked time.Time `json:"last_checked"` + + // Metadata contains additional health-related information + Metadata map[string]any `json:"metadata,omitempty"` + + // Errors contains any errors encountered during health check + Errors []string `json:"errors,omitempty"` +} + +// Capabilities describes the capabilities of an AI client +type Capabilities struct { + // Provider identifies the AI service provider + Provider string `json:"provider"` + + // Models lists the available models + Models []ModelInfo `json:"models"` + + // Features lists the supported features + Features []Feature `json:"features"` + + // MaxTokens indicates the maximum token limit + MaxTokens int `json:"max_tokens"` + + // SupportedLanguages lists supported programming/natural languages + SupportedLanguages []string `json:"supported_languages,omitempty"` + + // RateLimits describes the rate limiting information + RateLimits *RateLimits `json:"rate_limits,omitempty"` +} + +// ModelInfo provides information about a specific model +type ModelInfo struct { + // ID is the model identifier + ID string `json:"id"` + + // Name is the human-readable model name + Name string `json:"name"` + + // Description describes the model's capabilities + Description string `json:"description,omitempty"` + + // MaxTokens is the maximum context length for this model + MaxTokens int `json:"max_tokens"` + + // InputCostPer1K is the cost per 1K input tokens (if applicable) + InputCostPer1K float64 `json:"input_cost_per_1k,omitempty"` + + // OutputCostPer1K is the cost per 1K output tokens (if applicable) + OutputCostPer1K float64 `json:"output_cost_per_1k,omitempty"` + + // Capabilities lists model-specific capabilities + Capabilities []string `json:"capabilities,omitempty"` +} + +// Feature represents a specific AI feature +type Feature struct { + // Name is the feature identifier + Name string `json:"name"` + + // Enabled indicates if the feature is currently enabled + Enabled bool `json:"enabled"` + + // Description describes what the feature does + Description string `json:"description"` + + // Parameters contains feature-specific parameters + Parameters map[string]string `json:"parameters,omitempty"` + + // Version indicates the feature version + Version string `json:"version,omitempty"` +} + +// RateLimits describes rate limiting information +type RateLimits struct { + // RequestsPerMinute is the limit on requests per minute + RequestsPerMinute int `json:"requests_per_minute,omitempty"` + + // TokensPerMinute is the limit on tokens per minute + TokensPerMinute int `json:"tokens_per_minute,omitempty"` + + // RequestsPerDay is the limit on requests per day + RequestsPerDay int `json:"requests_per_day,omitempty"` + + // TokensPerDay is the limit on tokens per day + TokensPerDay int `json:"tokens_per_day,omitempty"` +} + +// AIClient defines the unified interface for AI service providers +type AIClient interface { + // Generate executes an AI generation request + Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) + + // GetCapabilities returns the capabilities of this AI client + GetCapabilities(ctx context.Context) (*Capabilities, error) + + // HealthCheck performs a health check on the AI service + HealthCheck(ctx context.Context) (*HealthStatus, error) + + // Close releases any resources held by the client + Close() error +} diff --git a/pkg/interfaces/doc.go b/pkg/interfaces/doc.go new file mode 100644 index 0000000..b6ff935 --- /dev/null +++ b/pkg/interfaces/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package interfaces declares shared interfaces used across AI providers and services. +// +//revive:disable-next-line var-naming +package interfaces diff --git a/pkg/logging/doc.go b/pkg/logging/doc.go new file mode 100644 index 0000000..d7930b3 --- /dev/null +++ b/pkg/logging/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package logging centralizes structured logging configuration for the plugin. +package logging diff --git a/pkg/logging/logger.go b/pkg/logging/logger.go new file mode 100644 index 0000000..a7c50d7 --- /dev/null +++ b/pkg/logging/logger.go @@ -0,0 +1,42 @@ +package logging + +import ( + "log/slog" + "os" + "strings" +) + +// Logger is the shared structured logger used throughout the plugin. +var Logger *slog.Logger + +func init() { + level := strings.ToLower(os.Getenv("LOG_LEVEL")) + + var logLevel slog.Level + switch level { + case "debug": + logLevel = slog.LevelDebug + case "info": + logLevel = slog.LevelInfo + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + + opts := &slog.HandlerOptions{ + Level: logLevel, + } + + // Use JSON handler for production, text handler for development + var handler slog.Handler + if os.Getenv("APP_ENV") == "development" { + handler = slog.NewTextHandler(os.Stdout, opts) + } else { + handler = slog.NewJSONHandler(os.Stdout, opts) + } + + Logger = slog.New(handler) +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go new file mode 100644 index 0000000..bcea1ef --- /dev/null +++ b/pkg/metrics/metrics.go @@ -0,0 +1,72 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package metrics provides Prometheus metrics for monitoring AI operations +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // AI请求计数 + aiRequestsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "atest_ai_requests_total", + Help: "Total number of AI requests", + }, + []string{"method", "provider", "status"}, + ) + + // AI请求延迟 + aiRequestDuration = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "atest_ai_request_duration_seconds", + Help: "AI request duration in seconds", + Buckets: prometheus.ExponentialBuckets(0.1, 2, 10), + }, + []string{"method", "provider"}, + ) + + // AI服务健康状态 + aiServiceHealth = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "atest_ai_service_health", + Help: "AI service health status (1=healthy, 0=unhealthy)", + }, + []string{"provider"}, + ) +) + +// RecordRequest 记录AI请求 +func RecordRequest(method, provider, status string) { + aiRequestsTotal.WithLabelValues(method, provider, status).Inc() +} + +// RecordDuration 记录请求延迟 +func RecordDuration(method, provider string, duration float64) { + aiRequestDuration.WithLabelValues(method, provider).Observe(duration) +} + +// SetHealthStatus 设置健康状态 +func SetHealthStatus(provider string, healthy bool) { + value := 0.0 + if healthy { + value = 1.0 + } + aiServiceHealth.WithLabelValues(provider).Set(value) +} diff --git a/pkg/plugin/assets/.gitkeep b/pkg/plugin/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pkg/plugin/doc.go b/pkg/plugin/doc.go new file mode 100644 index 0000000..dbef525 --- /dev/null +++ b/pkg/plugin/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package plugin exposes the gRPC service implementation consumed by api-testing. +package plugin diff --git a/pkg/plugin/service.go b/pkg/plugin/service.go new file mode 100644 index 0000000..df08512 --- /dev/null +++ b/pkg/plugin/service.go @@ -0,0 +1,1492 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/linuxsuren/api-testing/pkg/server" + "github.com/linuxsuren/api-testing/pkg/testing/remote" + "github.com/linuxsuren/atest-ext-ai/pkg/ai" + "github.com/linuxsuren/atest-ext-ai/pkg/ai/providers/universal" + "github.com/linuxsuren/atest-ext-ai/pkg/config" + apperrors "github.com/linuxsuren/atest-ext-ai/pkg/errors" + "github.com/linuxsuren/atest-ext-ai/pkg/logging" + "github.com/linuxsuren/atest-ext-ai/pkg/metrics" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +//go:embed assets/ai-chat.js +var aiChatJS string + +//go:embed assets/ai-chat.css +var aiChatCSS string + +// InitializationError captures detailed context about component initialization failures +// This allows us to provide comprehensive diagnostic information in error responses +type InitializationError struct { + Component string // Component that failed (e.g., "AI Engine", "AI Manager") + Reason string // Error message explaining the failure + Details map[string]string // Additional diagnostic information +} + +// Global initialization error tracking for enhanced error messages +// This is populated during service initialization and used to provide detailed context +// when operations fail due to unavailable services +var initErrors []InitializationError + +func clearInitErrorsFor(components ...string) { + if len(initErrors) == 0 || len(components) == 0 { + return + } + + componentSet := make(map[string]struct{}, len(components)) + for _, name := range components { + componentSet[name] = struct{}{} + } + + filtered := initErrors[:0] + for _, initErr := range initErrors { + if _, drop := componentSet[initErr.Component]; drop { + continue + } + filtered = append(filtered, initErr) + } + initErrors = filtered +} + +func contextError(ctx context.Context) error { + if ctx == nil { + return nil + } + + if err := ctx.Err(); err != nil { + return status.Error(codes.Canceled, err.Error()) + } + + return nil +} + +func normalizeDatabaseType(value string) string { + dbType := strings.ToLower(strings.TrimSpace(value)) + switch dbType { + case "mysql": + return "mysql" + case "postgres", "postgresql", "pg": + return "postgresql" + case "sqlite", "sqlite3": + return "sqlite" + default: + return "" + } +} + +func (s *AIPluginService) defaultDatabaseType() string { + if normalized := normalizeDatabaseType(s.config.Database.DefaultType); normalized != "" { + return normalized + } + return "mysql" +} + +func extractDatabaseTypeFromMap(values map[string]any) string { + if values == nil { + return "" + } + + keys := []string{"database_type", "databaseDialect", "database_dialect", "dialect"} + for _, key := range keys { + if raw, ok := values[key]; ok { + if str, ok := raw.(string); ok { + if normalized := normalizeDatabaseType(str); normalized != "" { + return normalized + } + } + } + } + return "" +} + +func (s *AIPluginService) resolveDatabaseType(explicit string, configMap map[string]any) string { + if normalized := normalizeDatabaseType(explicit); normalized != "" { + return normalized + } + + if fromConfig := extractDatabaseTypeFromMap(configMap); fromConfig != "" { + return fromConfig + } + + return s.defaultDatabaseType() +} + +func normalizeDurationField(payload map[string]any, key string) { + raw, ok := payload[key] + if !ok || raw == nil { + return + } + + switch value := raw.(type) { + case string: + if value == "" { + return + } + duration, err := time.ParseDuration(value) + if err != nil { + logging.Logger.Warn("Invalid duration string", "field", key, "value", value, "error", err) + return + } + payload[key] = duration.Nanoseconds() + case float64: + if value == 0 { + return + } + if value < float64(time.Second) { + payload[key] = int64(value * float64(time.Second)) + } else { + payload[key] = int64(value) + } + } +} + +// AIPluginService implements the Loader gRPC service for AI functionality +type AIPluginService struct { + remote.UnimplementedLoaderServer + aiEngine ai.Engine + config *config.Config + capabilityDetector *ai.CapabilityDetector + aiManager *ai.Manager +} + +// NewAIPluginService creates a new AI plugin service instance +// This function implements graceful degradation - the plugin will start successfully +// even if AI services are temporarily unavailable, allowing configuration and UI features to work. +func NewAIPluginService() (*AIPluginService, error) { + logging.Logger.Info("Initializing AI plugin service...") + + // Log version information for debugging and compatibility verification + logging.Logger.Info("Plugin version information", + "plugin_version", PluginVersion, + "api_version", APIVersion, + "grpc_interface_version", GRPCInterfaceVersion, + "min_api_testing_version", MinCompatibleAPITestingVersion) + logging.Logger.Info("Compatibility note: This plugin requires api-testing >= " + MinCompatibleAPITestingVersion) + + cfg, err := config.LoadConfig() + if err != nil { + logging.Logger.Error("Failed to load configuration", "error", err) + return nil, fmt.Errorf("failed to load configuration: %w", err) + } + logging.Logger.Info("Configuration loaded successfully") + + service := &AIPluginService{ + config: cfg, + } + + // Try to initialize AI engine - but allow plugin to start if it fails + aiEngine, err := ai.NewEngine(cfg.AI) + if err != nil { + logging.Logger.Warn("AI engine initialization failed - plugin will start in degraded mode", + "error", err, + "impact", "AI generation features will be unavailable until AI service is available") + + // Collect detailed initialization error for diagnostic messages + initErr := InitializationError{ + Component: "AI Engine", + Reason: err.Error(), + Details: map[string]string{ + "default_service": cfg.AI.DefaultService, + "provider_count": fmt.Sprintf("%d", len(cfg.AI.Services)), + }, + } + // Add specific provider details if available + if cfg.AI.DefaultService != "" { + if svc, ok := cfg.AI.Services[cfg.AI.DefaultService]; ok { + initErr.Details["provider_endpoint"] = svc.Endpoint + initErr.Details["provider_model"] = svc.Model + } + } + initErrors = append(initErrors, initErr) + service.aiEngine = nil + } else { + logging.Logger.Info("AI engine initialized successfully") + service.aiEngine = aiEngine + } + + // Try to initialize unified AI manager - but allow plugin to start if it fails + aiManager, err := ai.NewAIManager(cfg.AI) + if err != nil { + logging.Logger.Warn("AI manager initialization failed - plugin will start in degraded mode", + "error", err, + "impact", "Provider discovery and model listing will be unavailable") + + // Collect detailed initialization error for diagnostic messages + initErr := InitializationError{ + Component: "AI Manager", + Reason: err.Error(), + Details: map[string]string{ + "default_service": cfg.AI.DefaultService, + "configured_count": fmt.Sprintf("%d", len(cfg.AI.Services)), + }, + } + // Add list of configured services + if len(cfg.AI.Services) > 0 { + var services []string + for name := range cfg.AI.Services { + services = append(services, name) + } + initErr.Details["configured_services"] = strings.Join(services, ", ") + } + initErrors = append(initErrors, initErr) + service.aiManager = nil + } else { + logging.Logger.Info("AI manager initialized successfully") + service.aiManager = aiManager + + // Initialize capability detector only if AI manager is available + capabilityDetector := ai.NewCapabilityDetector(cfg.AI, aiManager) + logging.Logger.Info("Capability detector initialized") + service.capabilityDetector = capabilityDetector + + // Auto-discover providers in background only if manager is available + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if providers, err := aiManager.DiscoverProviders(ctx); err == nil { + logging.Logger.Info("Discovered AI providers", "count", len(providers)) + for _, p := range providers { + logging.Logger.Debug("Provider models available", "provider", p.Name, "model_count", len(p.Models)) + } + } else { + logging.Logger.Warn("Provider discovery failed", "error", err) + } + }() + } + + // Log final status + if service.aiEngine != nil && service.aiManager != nil { + logging.Logger.Info("AI plugin service fully operational") + } else { + logging.Logger.Warn("AI plugin service started in degraded mode - some features unavailable", + "ai_engine_available", service.aiEngine != nil, + "ai_manager_available", service.aiManager != nil) + } + + return service, nil +} + +// Query handles AI query requests from the main API testing system +func (s *AIPluginService) Query(ctx context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + logging.Logger.Debug("Query received", + "type", req.Type, + "key", req.Key, + "sql_length", len(req.Sql)) + + // Accept both empty type (for backward compatibility) and explicit "ai" type + // The main project doesn't always send the type field + if req.Type != "" && req.Type != "ai" { + logging.Logger.Warn("Unsupported query type", "type", req.Type) + return nil, status.Errorf(codes.InvalidArgument, "unsupported query type: %s", req.Type) + } + + // Handle new AI interface standard + switch req.Key { + case "generate": + // Check AI engine availability for generation requests + if s.aiEngine == nil { + logging.Logger.Error("AI generation requested but AI engine is not available") + + // Build enhanced error message with initialization details + errMsg := "AI generation service is currently unavailable." + + // Add specific initialization error information if available + if len(initErrors) > 0 { + errMsg += " Initialization errors:" + for _, initErr := range initErrors { + errMsg += fmt.Sprintf("\n- %s: %s", initErr.Component, initErr.Reason) + // Add relevant details + if len(initErr.Details) > 0 { + for key, value := range initErr.Details { + errMsg += fmt.Sprintf("\n %s: %s", key, value) + } + } + } + } else { + errMsg += " Please check AI provider configuration and connectivity." + } + + return nil, status.Errorf(codes.FailedPrecondition, errMsg) + } + return s.handleAIGenerate(ctx, req) + case "capabilities": + return s.handleAICapabilities(ctx, req) + case "providers": + // Check AI manager availability for provider operations + if s.aiManager == nil { + logging.Logger.Error("Provider discovery requested but AI manager is not available") + + // Build enhanced error message with initialization details + errMsg := "AI provider discovery is currently unavailable." + if len(initErrors) > 0 { + errMsg += " Initialization errors:" + for _, initErr := range initErrors { + if initErr.Component == "AI Manager" { + errMsg += fmt.Sprintf("\n- %s: %s", initErr.Component, initErr.Reason) + if len(initErr.Details) > 0 { + for key, value := range initErr.Details { + errMsg += fmt.Sprintf("\n %s: %s", key, value) + } + } + } + } + } else { + errMsg += " Please check AI service configuration." + } + + return nil, status.Errorf(codes.FailedPrecondition, errMsg) + } + return s.handleGetProviders(ctx, req) + case "models": + // Check AI manager availability for model operations + if s.aiManager == nil { + logging.Logger.Error("Model listing requested but AI manager is not available") + + // Build enhanced error message with initialization details + errMsg := "AI model listing is currently unavailable." + if len(initErrors) > 0 { + errMsg += " Initialization errors:" + for _, initErr := range initErrors { + if initErr.Component == "AI Manager" { + errMsg += fmt.Sprintf("\n- %s: %s", initErr.Component, initErr.Reason) + if len(initErr.Details) > 0 { + for key, value := range initErr.Details { + errMsg += fmt.Sprintf("\n %s: %s", key, value) + } + } + } + } + } else { + errMsg += " Please check AI service configuration." + } + + return nil, status.Errorf(codes.FailedPrecondition, errMsg) + } + return s.handleGetModels(ctx, req) + case "test_connection": + // Connection testing can work even without initialized services + if s.aiManager == nil { + logging.Logger.Error("Connection test requested but AI manager is not available") + + // Build enhanced error message with initialization details + errMsg := "AI connection testing is currently unavailable." + if len(initErrors) > 0 { + errMsg += " Initialization errors:" + for _, initErr := range initErrors { + if initErr.Component == "AI Manager" { + errMsg += fmt.Sprintf("\n- %s: %s", initErr.Component, initErr.Reason) + if len(initErr.Details) > 0 { + for key, value := range initErr.Details { + errMsg += fmt.Sprintf("\n %s: %s", key, value) + } + } + } + } + } else { + errMsg += " Please check AI service configuration." + } + + return nil, status.Errorf(codes.FailedPrecondition, errMsg) + } + return s.handleTestConnection(ctx, req) + case "health_check": + return s.handleHealthCheck(ctx, req) + case "update_config": + if s.aiManager == nil { + logging.Logger.Error("Config update requested but AI manager is not available") + + // Build enhanced error message with initialization details + errMsg := "AI configuration update is currently unavailable." + if len(initErrors) > 0 { + errMsg += " Initialization errors:" + for _, initErr := range initErrors { + if initErr.Component == "AI Manager" { + errMsg += fmt.Sprintf("\n- %s: %s", initErr.Component, initErr.Reason) + if len(initErr.Details) > 0 { + for key, value := range initErr.Details { + errMsg += fmt.Sprintf("\n %s: %s", key, value) + } + } + } + } + } else { + errMsg += " Please check AI service configuration." + } + + return nil, status.Errorf(codes.FailedPrecondition, errMsg) + } + return s.handleUpdateConfig(ctx, req) + default: + // Backward compatibility: support legacy natural language queries + // Check AI engine availability for legacy queries + if s.aiEngine == nil { + logging.Logger.Error("AI query requested but AI engine is not available") + + // Build enhanced error message with initialization details + errMsg := "AI service is currently unavailable." + + // Add specific initialization error information if available + if len(initErrors) > 0 { + errMsg += " Initialization errors:" + for _, initErr := range initErrors { + errMsg += fmt.Sprintf("\n- %s: %s", initErr.Component, initErr.Reason) + // Add relevant details + if len(initErr.Details) > 0 { + for key, value := range initErr.Details { + errMsg += fmt.Sprintf("\n %s: %s", key, value) + } + } + } + } else { + errMsg += " Please check AI provider configuration and connectivity." + } + + return nil, status.Errorf(codes.FailedPrecondition, errMsg) + } + return s.handleLegacyQuery(ctx, req) + } +} + +// handleCapabilitiesQuery handles requests for AI plugin capabilities +func (s *AIPluginService) handleCapabilitiesQuery(ctx context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + logging.Logger.Info("Handling capabilities query", "key", req.Key) + + // Parse capability request parameters from SQL field (if provided) + capReq := &ai.CapabilitiesRequest{ + IncludeModels: true, + IncludeDatabases: true, + IncludeFeatures: true, + CheckHealth: false, // Default to false for performance + } + + // Parse parameters from SQL field if provided + if req.Sql != "" { + var params map[string]bool + if err := json.Unmarshal([]byte(req.Sql), ¶ms); err == nil { + if includeModels, ok := params["include_models"]; ok { + capReq.IncludeModels = includeModels + } + if includeDatabases, ok := params["include_databases"]; ok { + capReq.IncludeDatabases = includeDatabases + } + if includeFeatures, ok := params["include_features"]; ok { + capReq.IncludeFeatures = includeFeatures + } + if checkHealth, ok := params["check_health"]; ok { + capReq.CheckHealth = checkHealth + } + } else { + logging.Logger.Error("Failed to parse capability request parameters", "error", err) + } + } + + // Handle specific capability subqueries + if strings.Contains(req.Key, ".") { + parts := strings.Split(req.Key, ".") + if len(parts) >= 2 { + subQuery := parts[len(parts)-1] + switch subQuery { + case "metadata": + return nil, status.Errorf(codes.Unimplemented, "metadata query not supported") + case "models": + capReq.IncludeModels = true + capReq.IncludeDatabases = false + capReq.IncludeFeatures = false + case "databases": + capReq.IncludeModels = false + capReq.IncludeDatabases = true + capReq.IncludeFeatures = false + case "features": + capReq.IncludeModels = false + capReq.IncludeDatabases = false + capReq.IncludeFeatures = true + case "health": + capReq.CheckHealth = true + } + } + } + + // Get capabilities + if s.capabilityDetector == nil { + return nil, status.Errorf(codes.Internal, "capability detector not initialized") + } + + capabilities, err := s.capabilityDetector.GetCapabilities(ctx, capReq) + if err != nil { + logging.Logger.Error("Failed to get capabilities", "error", err) + return nil, status.Errorf(codes.Internal, "failed to get capabilities: %v", err) + } + + // Convert capabilities to JSON + capabilitiesJSON, err := json.Marshal(capabilities) + if err != nil { + logging.Logger.Error("Failed to marshal capabilities", "error", err) + return nil, status.Errorf(codes.Internal, "failed to serialize capabilities: %v", err) + } + + // Create response + result := &server.DataQueryResult{ + Data: []*server.Pair{ + { + Key: "capabilities", + Value: string(capabilitiesJSON), + }, + { + Key: "version", + Value: capabilities.Version, + }, + { + Key: "last_updated", + Value: capabilities.LastUpdated.Format("2006-01-02T15:04:05Z"), + }, + { + Key: "model_count", + Value: fmt.Sprintf("%d", len(capabilities.Models)), + }, + { + Key: "database_count", + Value: fmt.Sprintf("%d", len(capabilities.Databases)), + }, + { + Key: "feature_count", + Value: fmt.Sprintf("%d", len(capabilities.Features)), + }, + { + Key: "overall_health", + Value: fmt.Sprintf("%t", capabilities.Health.Overall), + }, + }, + } + + logging.Logger.Info("Capabilities query completed successfully", + "models", len(capabilities.Models), "databases", len(capabilities.Databases), "features", len(capabilities.Features)) + + return result, nil +} + +// Verify returns the plugin status for health checks +// This implements graceful degradation: the plugin is considered "Ready" if the core +// configuration is loaded, even if AI services are temporarily unavailable. +// AI service status is reported in the message field for diagnostic purposes. +func (s *AIPluginService) Verify(ctx context.Context, _ *server.Empty) (*server.ExtensionStatus, error) { + logging.Logger.Info("Health check requested") + + if err := contextError(ctx); err != nil { + return nil, err + } + + // Plugin Ready check: only require core configuration to be loaded + // This allows UI and configuration features to work even if AI services are down + isReady := s.config != nil + + var message string + if !isReady { + message = "Configuration not loaded - plugin cannot start" + logging.Logger.Error("Health check failed: configuration missing") + } else { + // Build detailed status message + aiEngineStatus := "unavailable" + if s.aiEngine != nil { + aiEngineStatus = "operational" + } + aiManagerStatus := "unavailable" + if s.aiManager != nil { + aiManagerStatus = "operational" + } + + if s.aiEngine != nil && s.aiManager != nil { + message = "AI Plugin fully operational" + logging.Logger.Info("Health check passed: plugin fully operational") + } else { + message = fmt.Sprintf("AI Plugin ready (degraded mode: AI engine=%s, AI manager=%s)", + aiEngineStatus, aiManagerStatus) + logging.Logger.Warn("Health check passed but plugin in degraded mode", + "ai_engine", aiEngineStatus, + "ai_manager", aiManagerStatus) + } + } + + // Include detailed version information for diagnostics + versionInfo := fmt.Sprintf("%s (API: %s, gRPC: %s, requires api-testing >= %s)", + PluginVersion, APIVersion, GRPCInterfaceVersion, MinCompatibleAPITestingVersion) + + status := &server.ExtensionStatus{ + Ready: isReady, + ReadOnly: true, // AI plugin is read-only - only provides AI query and UI features, not data storage + Version: versionInfo, + Message: message, + } + + logging.Logger.Debug("Verify response", + "ready", isReady, + "version", versionInfo, + "message", message) + + return status, nil +} + +// Shutdown gracefully stops the AI plugin service +func (s *AIPluginService) Shutdown() { + logging.Logger.Info("Shutting down AI plugin service...") + + if s.aiEngine != nil { + logging.Logger.Info("Closing AI engine...") + s.aiEngine.Close() + logging.Logger.Info("AI engine closed successfully") + } + + logging.Logger.Info("AI plugin service shutdown complete") +} + +// GetVersion returns the plugin version information +func (s *AIPluginService) GetVersion(ctx context.Context, _ *server.Empty) (*server.Version, error) { + logging.Logger.Debug("GetVersion called") + + if err := contextError(ctx); err != nil { + return nil, err + } + + return &server.Version{ + Version: fmt.Sprintf("%s (API: %s, gRPC: %s)", PluginVersion, APIVersion, GRPCInterfaceVersion), + Commit: "HEAD", // Could be set during build time via ldflags + Date: time.Now().Format(time.RFC3339), + }, nil +} + +var ( + // APIVersion is the current API version for the AI plugin + APIVersion = "v1" + // PluginVersion is the plugin implementation version (resolved at build time or via module metadata) + PluginVersion = detectPluginVersion() + // GRPCInterfaceVersion is the expected gRPC interface version from api-testing. + // This helps detect incompatibilities between plugin and main project. + GRPCInterfaceVersion = detectAPITestingVersion() + // MinCompatibleAPITestingVersion is the minimum api-testing version required. + MinCompatibleAPITestingVersion = GRPCInterfaceVersion +) + +// handleAIGenerate handles ai.generate calls +func (s *AIPluginService) handleAIGenerate(ctx context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + start := time.Now() + provider := s.config.AI.DefaultService + + defer func() { + duration := time.Since(start).Seconds() + metrics.RecordDuration("generate", provider, duration) + }() + + // Parse parameters from SQL field + var params struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Config string `json:"config"` + DatabaseType string `json:"database_type"` + } + + if req.Sql != "" { + if err := json.Unmarshal([]byte(req.Sql), ¶ms); err != nil { + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidRequest, "failed to parse AI parameters: %v", err) + } + } + + if params.Prompt == "" { + return nil, apperrors.ToGRPCError(apperrors.ErrInvalidRequest) + } + + // Parse optional config + var configMap map[string]interface{} + if params.Config != "" { + if err := json.Unmarshal([]byte(params.Config), &configMap); err != nil { + logging.Logger.Warn("Failed to parse config JSON", "error", err) + } + } + + logging.Logger.Debug("AI generate parameters", + "model", params.Model, + "prompt_length", len(params.Prompt), + "has_config", params.Config != "") + + // Generate using AI engine + context := map[string]string{} + if params.Model != "" { + context["preferred_model"] = params.Model + logging.Logger.Debug("Setting preferred model", "model", params.Model) + } + if params.Config != "" { + context["config"] = params.Config + } + + // Get database type from configuration, fallback to mysql if not configured + databaseType := s.resolveDatabaseType(params.DatabaseType, configMap) + context["database_type"] = databaseType + + sqlResult, err := s.aiEngine.GenerateSQL(ctx, &ai.GenerateSQLRequest{ + NaturalLanguage: params.Prompt, + DatabaseType: databaseType, + Context: context, + }) + if err != nil { + metrics.RecordRequest("generate", provider, "error") + + logging.Logger.Error("SQL generation failed", + "error", err, + "database_type", databaseType, + "prompt_length", len(params.Prompt)) + + // Business logic error: return error in response data, not as gRPC error + // This allows the main project to handle it gracefully + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "api_version", Value: APIVersion}, + {Key: "success", Value: "false"}, + {Key: "error", Value: err.Error()}, + {Key: "error_code", Value: "GENERATION_FAILED"}, + }, + }, nil + } + + // Return in simplified format with line break + simpleFormat := fmt.Sprintf("sql:%s\nexplanation:%s", sqlResult.SQL, sqlResult.Explanation) + + // Build minimal meta information for UI display + metaData := map[string]interface{}{ + "confidence": sqlResult.ConfidenceScore, + "model": sqlResult.ModelUsed, + "dialect": databaseType, + } + metaJSON, err := json.Marshal(metaData) + if err != nil { + metaJSON = []byte(fmt.Sprintf(`{"confidence": %f, "model": "%s"}`, + sqlResult.ConfidenceScore, sqlResult.ModelUsed)) + } + + logging.Logger.Debug("Returning SQL generation result", + "confidence", sqlResult.ConfidenceScore, + "model", sqlResult.ModelUsed, + "sql_length", len(sqlResult.SQL)) + + metrics.RecordRequest("generate", provider, "success") + + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "api_version", Value: APIVersion}, + {Key: "generated_sql", Value: simpleFormat}, + {Key: "success", Value: "true"}, + {Key: "meta", Value: string(metaJSON)}, + }, + }, nil +} + +// handleAICapabilities handles ai.capabilities calls +func (s *AIPluginService) handleAICapabilities(ctx context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + + capReq := &ai.CapabilitiesRequest{ + IncludeModels: true, + IncludeFeatures: true, + CheckHealth: false, + } + + if req != nil && req.Sql != "" { + var params map[string]bool + if err := json.Unmarshal([]byte(req.Sql), ¶ms); err == nil { + if includeModels, ok := params["include_models"]; ok { + capReq.IncludeModels = includeModels + } + if includeFeatures, ok := params["include_features"]; ok { + capReq.IncludeFeatures = includeFeatures + } + if checkHealth, ok := params["check_health"]; ok { + capReq.CheckHealth = checkHealth + } + } else { + logging.Logger.Warn("Failed to parse capabilities request overrides", "error", err) + } + } + + // Check if capability detector is available + if s.capabilityDetector == nil { + logging.Logger.Warn("Capability detector not available - returning minimal capabilities") + // Return minimal capabilities when detector is not available + minimalCaps := map[string]interface{}{ + "plugin_ready": true, + "ai_available": false, + "degraded_mode": true, + "plugin_version": PluginVersion, + "api_version": APIVersion, + } + capsJSON, _ := json.Marshal(minimalCaps) + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "api_version", Value: APIVersion}, + {Key: "capabilities", Value: string(capsJSON)}, + {Key: "models", Value: "[]"}, + {Key: "features", Value: "[]"}, + {Key: "description", Value: "AI Extension Plugin (degraded mode - AI services unavailable)"}, + {Key: "version", Value: PluginVersion}, + {Key: "success", Value: "true"}, + {Key: "warning", Value: "AI services are currently unavailable"}, + }, + }, nil + } + + capabilities, err := s.capabilityDetector.GetCapabilities(ctx, capReq) + if err != nil { + logging.Logger.Error("Failed to get capabilities", "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrProviderNotAvailable, "failed to retrieve capabilities: %v", err) + } + + // Convert to JSON strings for AI interface standard + capabilitiesJSON, _ := json.Marshal(capabilities) + modelsJSON, _ := json.Marshal(capabilities.Models) + featuresJSON, _ := json.Marshal(capabilities.Features) + + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "api_version", Value: APIVersion}, + {Key: "capabilities", Value: string(capabilitiesJSON)}, + {Key: "models", Value: string(modelsJSON)}, + {Key: "features", Value: string(featuresJSON)}, + {Key: "description", Value: "AI Extension Plugin for intelligent SQL generation"}, + {Key: "version", Value: PluginVersion}, + {Key: "success", Value: "true"}, + }, + }, nil +} + +// GetMenus returns the menu entries for AI plugin UI +func (s *AIPluginService) GetMenus(ctx context.Context, _ *server.Empty) (*server.MenuList, error) { + logging.Logger.Debug("AI plugin GetMenus called") + + if err := contextError(ctx); err != nil { + return nil, err + } + + return &server.MenuList{ + Data: []*server.Menu{ + { + Name: "AI Assistant", + Index: "ai-chat", + Icon: "ChatDotRound", + Version: 1, + }, + }, + }, nil +} + +// GetPageOfJS returns the JavaScript code for AI plugin UI +func (s *AIPluginService) GetPageOfJS(ctx context.Context, req *server.SimpleName) (*server.CommonResult, error) { + logging.Logger.Debug("AI plugin GetPageOfJS called", "name", req.Name) + + if err := contextError(ctx); err != nil { + return nil, err + } + + if req.Name != "ai-chat" { + return &server.CommonResult{ + Success: false, + Message: fmt.Sprintf("Unknown AI plugin page: %s", req.Name), + }, nil + } + + // Use embedded JavaScript file for clean separation of concerns + jsCode := aiChatJS + + return &server.CommonResult{ + Success: true, + Message: jsCode, + }, nil +} + +// GetPageOfCSS returns the CSS styles for AI plugin UI +func (s *AIPluginService) GetPageOfCSS(ctx context.Context, req *server.SimpleName) (*server.CommonResult, error) { + logging.Logger.Debug("Serving CSS for AI plugin", "name", req.Name) + + if err := contextError(ctx); err != nil { + return nil, err + } + + if req.Name != "ai-chat" { + return &server.CommonResult{ + Success: false, + Message: fmt.Sprintf("Unknown AI plugin page: %s", req.Name), + }, nil + } + + return &server.CommonResult{ + Success: true, + Message: aiChatCSS, + }, nil +} + +// GetPageOfStatic returns static files for AI plugin UI (not implemented) +func (s *AIPluginService) GetPageOfStatic(ctx context.Context, _ *server.SimpleName) (*server.CommonResult, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + + result := &server.CommonResult{ + Success: false, + Message: "Static files not supported", + } + return result, nil +} + +// GetThemes returns the list of available themes (AI plugin doesn't provide themes) +func (s *AIPluginService) GetThemes(ctx context.Context, _ *server.Empty) (*server.SimpleList, error) { + logging.Logger.Debug("GetThemes called - AI plugin does not provide themes") + + if err := contextError(ctx); err != nil { + return nil, err + } + + return &server.SimpleList{ + Data: []*server.Pair{}, // Empty list - AI plugin doesn't provide themes + }, nil +} + +// GetTheme returns a specific theme (AI plugin doesn't provide themes) +func (s *AIPluginService) GetTheme(ctx context.Context, req *server.SimpleName) (*server.CommonResult, error) { + logging.Logger.Debug("GetTheme called", "theme", req.Name) + + if err := contextError(ctx); err != nil { + return nil, err + } + + return &server.CommonResult{ + Success: false, + Message: "AI plugin does not provide themes", + }, nil +} + +// GetBindings returns the list of available bindings (AI plugin doesn't provide bindings) +func (s *AIPluginService) GetBindings(ctx context.Context, _ *server.Empty) (*server.SimpleList, error) { + logging.Logger.Debug("GetBindings called - AI plugin does not provide bindings") + + if err := contextError(ctx); err != nil { + return nil, err + } + + return &server.SimpleList{ + Data: []*server.Pair{}, // Empty list - AI plugin doesn't provide bindings + }, nil +} + +// GetBinding returns a specific binding (AI plugin doesn't provide bindings) +func (s *AIPluginService) GetBinding(ctx context.Context, req *server.SimpleName) (*server.CommonResult, error) { + logging.Logger.Debug("GetBinding called", "binding", req.Name) + + if err := contextError(ctx); err != nil { + return nil, err + } + + return &server.CommonResult{ + Success: false, + Message: "AI plugin does not provide bindings", + }, nil +} + +// PProf returns profiling data for the AI plugin +func (s *AIPluginService) PProf(ctx context.Context, req *server.PProfRequest) (*server.PProfData, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + + if req == nil { + return nil, status.Error(codes.InvalidArgument, "profiling request cannot be nil") + } + + logging.Logger.Debug("PProf called", "profile_type", req.Name) + + // For now, return empty profiling data + // In the future, this could be extended to provide actual profiling information + result := &server.PProfData{ + Data: []byte{}, // Empty profiling data + } + return result, nil +} + +// handleLegacyQuery maintains backward compatibility with the original implementation +func (s *AIPluginService) handleLegacyQuery(ctx context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + // Handle legacy capabilities query + if req.Key == "capabilities" || strings.HasPrefix(req.Key, "ai.capabilities") { + return s.handleCapabilitiesQuery(ctx, req) + } + + // For AI queries, we use the 'key' field as the natural language input + // and 'sql' field for any additional context or existing SQL + if req.Key == "" { + logging.Logger.Warn("Missing key field (natural language query) in request") + return nil, status.Errorf(codes.InvalidArgument, "key field is required for AI queries (natural language input)") + } + + // Generate SQL using AI engine + queryPreview := req.Key + if len(queryPreview) > 100 { + queryPreview = queryPreview[:100] + "..." + } + logging.Logger.Info("Generating SQL for natural language query", "query_preview", queryPreview) + + // Create context map from available information + contextMap := make(map[string]string) + if req.Sql != "" { + contextMap["existing_sql"] = req.Sql + } + + // Get database type from configuration, fallback to mysql if not configured + databaseType := s.defaultDatabaseType() + contextMap["database_type"] = databaseType + + sqlResult, err := s.aiEngine.GenerateSQL(ctx, &ai.GenerateSQLRequest{ + NaturalLanguage: req.Key, + DatabaseType: databaseType, + Context: contextMap, + }) + if err != nil { + logging.Logger.Error("Failed to generate SQL", "error", err) + + // Business logic error: return error in response data, not as gRPC error + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "success", Value: "false"}, + {Key: "error", Value: err.Error()}, + {Key: "error_code", Value: "GENERATION_FAILED"}, + }, + }, nil + } + + // Create response in simplified format with line break + simpleFormat := fmt.Sprintf("sql:%s\nexplanation:%s", sqlResult.SQL, sqlResult.Explanation) + + // Build minimal meta information for UI display + metaData := map[string]interface{}{ + "confidence": sqlResult.ConfidenceScore, + "model": sqlResult.ModelUsed, + "dialect": databaseType, + } + metaJSON, err := json.Marshal(metaData) + if err != nil { + metaJSON = []byte(fmt.Sprintf(`{"confidence": %f, "model": "%s"}`, + sqlResult.ConfidenceScore, sqlResult.ModelUsed)) + } + + logging.Logger.Debug("Legacy query result", + "confidence", sqlResult.ConfidenceScore, + "model", sqlResult.ModelUsed, + "request_id", sqlResult.RequestID) + + result := &server.DataQueryResult{ + Data: []*server.Pair{ + { + Key: "generated_sql", + Value: simpleFormat, + }, + { + Key: "success", + Value: "true", + }, + { + Key: "meta", + Value: string(metaJSON), + }, + }, + } + + logging.Logger.Info("AI query completed successfully", + "request_id", sqlResult.RequestID, "confidence", sqlResult.ConfidenceScore, "processing_time_ms", sqlResult.ProcessingTime.Milliseconds()) + + return result, nil +} + +// handleGetProviders returns the list of available AI providers +func (s *AIPluginService) handleGetProviders(ctx context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + logging.Logger.Debug("Getting AI providers list") + + if err := contextError(ctx); err != nil { + return nil, err + } + + includeUnavailable := true + if req != nil && req.Sql != "" { + var params map[string]bool + if err := json.Unmarshal([]byte(req.Sql), ¶ms); err == nil { + if includeUnavailableParam, ok := params["include_unavailable"]; ok { + includeUnavailable = includeUnavailableParam + } + } else { + logging.Logger.Warn("Failed to parse provider query overrides", "error", err) + } + } + + // Discover providers + providers, err := s.aiManager.DiscoverProviders(ctx) + if err != nil { + logging.Logger.Error("Failed to discover providers", "error", err) + return nil, status.Errorf(codes.Internal, "failed to discover providers: %v", err) + } + + if !includeUnavailable { + filtered := providers[:0] + for _, provider := range providers { + if provider.Available { + filtered = append(filtered, provider) + } + } + providers = filtered + } + + // Convert to JSON + providersJSON, err := json.Marshal(providers) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to serialize providers: %v", err) + } + + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "providers", Value: string(providersJSON)}, + {Key: "count", Value: fmt.Sprintf("%d", len(providers))}, + {Key: "success", Value: "true"}, + }, + }, nil +} + +// handleGetModels returns models for a specific provider +func (s *AIPluginService) handleGetModels(ctx context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + // Parse provider name from SQL field + var params struct { + Provider string `json:"provider"` + } + + if req.Sql != "" { + if err := json.Unmarshal([]byte(req.Sql), ¶ms); err != nil { + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidRequest, "invalid parameters: %v", err) + } + } + + if params.Provider == "" { + // If no provider specified, return all models from all providers + allModels := make(map[string][]interface{}) + + // Get all configured clients + clients := s.aiManager.GetAllClients() + for providerName := range clients { + if models, err := s.aiManager.GetModels(ctx, providerName); err == nil { + modelList := make([]interface{}, len(models)) + for i, m := range models { + modelList[i] = m + } + allModels[providerName] = modelList + } + } + + modelsJSON, _ := json.Marshal(allModels) + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "models", Value: string(modelsJSON)}, + {Key: "success", Value: "true"}, + }, + }, nil + } + + // Get models for specific provider + // Map frontend category names to backend provider names + providerName := params.Provider + switch params.Provider { + case "local": + providerName = "ollama" + case "online": + // Map "online" to default online provider (can be configured) + providerName = "deepseek" + } + + models, err := s.aiManager.GetModels(ctx, providerName) + if err != nil { + logging.Logger.Error("Failed to get models", "provider", providerName, "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrModelNotFound, "failed to get models for provider %s: %v", providerName, err) + } + + modelsJSON, _ := json.Marshal(models) + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "models", Value: string(modelsJSON)}, + {Key: "provider", Value: params.Provider}, + {Key: "count", Value: fmt.Sprintf("%d", len(models))}, + {Key: "success", Value: "true"}, + }, + }, nil +} + +// handleTestConnection tests a connection with provided configuration +func (s *AIPluginService) handleTestConnection(ctx context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + logging.Logger.Debug("Handling test connection request", "sql_length", len(req.Sql)) + + // Parse configuration from SQL field + var config universal.Config + if req.Sql != "" { + var payload map[string]any + if err := json.Unmarshal([]byte(req.Sql), &payload); err != nil { + logging.Logger.Error("Failed to parse connection config", "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidConfig, "invalid configuration: %v", err) + } + + normalizeDurationField(payload, "timeout") + + normalizedPayload, err := json.Marshal(payload) + if err != nil { + logging.Logger.Error("Failed to normalize connection config", "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidConfig, "invalid configuration: %v", err) + } + + if err := json.Unmarshal(normalizedPayload, &config); err != nil { + logging.Logger.Error("Failed to decode normalized connection config", "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidConfig, "invalid configuration: %v", err) + } + } + + // Map "local" to "ollama" for backward compatibility + if config.Provider == "local" { + config.Provider = "ollama" + } + + // Log configuration for debugging (mask API key) + apiKeyDisplay := "***masked***" + if config.APIKey != "" && len(config.APIKey) > 4 { + apiKeyDisplay = config.APIKey[:4] + "***" + } + logging.Logger.Debug("Testing connection", + "provider", config.Provider, + "api_key_prefix", apiKeyDisplay, + "model", config.Model) + + // Test the connection + result, err := s.aiManager.TestConnection(ctx, &config) + if err != nil { + logging.Logger.Error("Connection test failed", + "provider", config.Provider, + "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrConnectionFailed, "connection test failed for provider %s: %v", config.Provider, err) + } + + resultJSON, _ := json.Marshal(result) + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "result", Value: string(resultJSON)}, + {Key: "success", Value: fmt.Sprintf("%t", result.Success)}, + {Key: "message", Value: result.Message}, + {Key: "response_time_ms", Value: fmt.Sprintf("%d", result.ResponseTime.Milliseconds())}, + }, + }, nil +} + +// handleUpdateConfig updates the configuration for a provider +func (s *AIPluginService) handleUpdateConfig(_ context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + logging.Logger.Debug("Handling update config request", "sql_length", len(req.Sql)) + + // Parse update request from SQL field + var updateReq struct { + Provider string `json:"provider"` + Config *universal.Config `json:"config"` + } + + if req.Sql != "" { + var payload map[string]any + if err := json.Unmarshal([]byte(req.Sql), &payload); err != nil { + logging.Logger.Error("Failed to parse update request", "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidRequest, "invalid update request: %v", err) + } + + if configPayload, ok := payload["config"].(map[string]any); ok { + normalizeDurationField(configPayload, "timeout") + payload["config"] = configPayload + } + + normalizedPayload, err := json.Marshal(payload) + if err != nil { + logging.Logger.Error("Failed to normalize update config payload", "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidRequest, "invalid update request: %v", err) + } + + if err := json.Unmarshal(normalizedPayload, &updateReq); err != nil { + logging.Logger.Error("Failed to decode normalized update request", "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidRequest, "invalid update request: %v", err) + } + } + + if updateReq.Provider == "" || updateReq.Config == nil { + return nil, apperrors.ToGRPCError(apperrors.ErrInvalidRequest) + } + + // Map "local" to "ollama" for backward compatibility + if updateReq.Provider == "local" { + updateReq.Provider = "ollama" + } + if updateReq.Config.Provider == "local" { + updateReq.Config.Provider = "ollama" + } + + logging.Logger.Debug("Updating provider config", "provider", updateReq.Provider) + + // Update the configuration by adding/updating the client + serviceConfig := config.AIService{ + Enabled: true, + Provider: updateReq.Config.Provider, + Endpoint: updateReq.Config.Endpoint, + Model: updateReq.Config.Model, + APIKey: updateReq.Config.APIKey, + MaxTokens: updateReq.Config.MaxTokens, + } + if updateReq.Config.Timeout > 0 { + serviceConfig.Timeout = config.Duration{Duration: updateReq.Config.Timeout} + } + + oldEngine := s.aiEngine + + servicesCopy := make(map[string]config.AIService, len(s.config.AI.Services)+1) + for name, svc := range s.config.AI.Services { + servicesCopy[name] = svc + } + servicesCopy[updateReq.Provider] = serviceConfig + + newAIConfig := s.config.AI + newAIConfig.Services = servicesCopy + if newAIConfig.DefaultService == "" { + newAIConfig.DefaultService = updateReq.Provider + } + + manager, err := ai.NewAIManager(newAIConfig) + if err != nil { + logging.Logger.Error("Failed to rebuild AI manager", + "provider", updateReq.Provider, + "error", err) + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidConfig, "failed to rebuild AI manager: %v", err) + } + + engine, err := ai.NewEngineWithManager(manager, newAIConfig) + if err != nil { + logging.Logger.Error("Failed to rebuild AI engine", + "provider", updateReq.Provider, + "error", err) + _ = manager.Close() + return nil, apperrors.ToGRPCErrorf(apperrors.ErrInvalidConfig, "failed to rebuild AI engine: %v", err) + } + + capabilityDetector := ai.NewCapabilityDetector(newAIConfig, manager) + + s.config.AI = newAIConfig + s.aiManager = manager + s.aiEngine = engine + s.capabilityDetector = capabilityDetector + clearInitErrorsFor("AI Engine", "AI Manager") + + if oldEngine != nil { + oldEngine.Close() + } + + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "provider", Value: updateReq.Provider}, + {Key: "message", Value: "Configuration updated successfully"}, + {Key: "success", Value: "true"}, + }, + }, nil +} + +// handleHealthCheck performs health check on specific AI service +// This is separate from the plugin's Verify method, which only checks if the plugin is ready +func (s *AIPluginService) handleHealthCheck(ctx context.Context, req *server.DataQuery) (*server.DataQueryResult, error) { + logging.Logger.Debug("AI service health check requested") + + // Parse request parameters + var params struct { + Provider string `json:"provider"` + Timeout int `json:"timeout"` // seconds + } + + if req.Sql != "" { + if err := json.Unmarshal([]byte(req.Sql), ¶ms); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid parameters: %v", err) + } + } + + // Default timeout 5 seconds + if params.Timeout == 0 { + params.Timeout = 5 + } + + // Check specified provider or default provider + provider := params.Provider + if provider == "" { + provider = s.config.AI.DefaultService + } + + // Execute health check with timeout + checkCtx, cancel := context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second) + defer cancel() + + var healthy bool + var errorMsg string + + // Perform health check on the AI engine + if s.aiEngine != nil { + if s.aiEngine.IsHealthy() { + healthy = true + errorMsg = "" + } else { + healthy = false + errorMsg = "AI service is not available" + } + } else { + healthy = false + errorMsg = "AI engine not initialized" + } + + // Wait for context timeout if still checking + select { + case <-checkCtx.Done(): + if checkCtx.Err() == context.DeadlineExceeded { + healthy = false + errorMsg = "Health check timeout" + } + default: + // Check completed + } + + return &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "healthy", Value: fmt.Sprintf("%t", healthy)}, + {Key: "provider", Value: provider}, + {Key: "error", Value: errorMsg}, + {Key: "timestamp", Value: time.Now().Format(time.RFC3339)}, + {Key: "success", Value: "true"}, // API call succeeded even if service is unhealthy + }, + }, nil +} diff --git a/pkg/plugin/service_test.go b/pkg/plugin/service_test.go new file mode 100644 index 0000000..86aaee6 --- /dev/null +++ b/pkg/plugin/service_test.go @@ -0,0 +1,419 @@ +/* +Copyright 2025 API Testing Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "context" + "encoding/json" + "testing" + + "github.com/linuxsuren/api-testing/pkg/server" + "github.com/linuxsuren/atest-ext-ai/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAIGenerateFieldNames verifies that the AI generate response contains the correct field names +// This is a regression test for Issue #1: Field name mismatch (content vs generated_sql) +func TestAIGenerateFieldNames(t *testing.T) { + t.Run("generate response contains generated_sql field", func(t *testing.T) { + // This test ensures the response uses "generated_sql" instead of "content" + // to match what the main project (api-testing) expects + + // Create a mock response simulating what handleAIGenerate returns + mockResponse := &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "api_version", Value: "v1"}, + {Key: "generated_sql", Value: "sql:SELECT * FROM users;\nexplanation:This query selects all users."}, + {Key: "success", Value: "true"}, + {Key: "meta", Value: `{"confidence": 0.95, "model": "test-model"}`}, + }, + } + + // Verify the critical field exists + var hasGeneratedSQL bool + var generatedSQLValue string + + for _, pair := range mockResponse.Data { + if pair.Key == "generated_sql" { + hasGeneratedSQL = true + generatedSQLValue = pair.Value + } + } + + // Assert the field exists + assert.True(t, hasGeneratedSQL, "Response must contain 'generated_sql' field, not 'content'") + assert.NotEmpty(t, generatedSQLValue, "generated_sql value should not be empty") + + // Verify the format includes both SQL and explanation + assert.Contains(t, generatedSQLValue, "sql:", "generated_sql should contain 'sql:' prefix") + assert.Contains(t, generatedSQLValue, "explanation:", "generated_sql should contain 'explanation:' prefix") + }) + + t.Run("legacy response contains generated_sql field", func(t *testing.T) { + // This test ensures the legacy query handler also uses "generated_sql" + + mockResponse := &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "generated_sql", Value: "sql:SELECT name FROM users;\nexplanation:Get user names."}, + {Key: "success", Value: "true"}, + {Key: "meta", Value: `{"confidence": 0.9, "model": "legacy-model"}`}, + }, + } + + // Verify field existence + var hasGeneratedSQL bool + for _, pair := range mockResponse.Data { + if pair.Key == "generated_sql" { + hasGeneratedSQL = true + break + } + } + + assert.True(t, hasGeneratedSQL, "Legacy response must also use 'generated_sql' field") + }) +} + +// TestResponseFieldStructure verifies the complete structure of AI responses +func TestResponseFieldStructure(t *testing.T) { + tests := []struct { + name string + mockResponse *server.DataQueryResult + expectedFields map[string]bool + }{ + { + name: "AI generate response structure", + mockResponse: &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "api_version", Value: "v1"}, + {Key: "generated_sql", Value: "sql:SELECT 1;"}, + {Key: "success", Value: "true"}, + {Key: "meta", Value: "{}"}, + }, + }, + expectedFields: map[string]bool{ + "api_version": true, + "generated_sql": true, + "success": true, + "meta": true, + }, + }, + { + name: "AI capabilities response structure", + mockResponse: &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "api_version", Value: "v1"}, + {Key: "capabilities", Value: "{}"}, + {Key: "models", Value: "[]"}, + {Key: "features", Value: "[]"}, + {Key: "success", Value: "true"}, + }, + }, + expectedFields: map[string]bool{ + "api_version": true, + "capabilities": true, + "models": true, + "features": true, + "success": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualFields := make(map[string]bool) + for _, pair := range tt.mockResponse.Data { + actualFields[pair.Key] = true + } + + // Check all expected fields are present + for field, expected := range tt.expectedFields { + assert.Equal(t, expected, actualFields[field], + "Field %s should be present", field) + } + }) + } +} + +// TestSuccessFieldConsistency verifies success field is correctly set +// This is a regression test for Issue #2: Success field processing conflict +func TestSuccessFieldConsistency(t *testing.T) { + t.Run("success field with no error", func(t *testing.T) { + mockResponse := &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "generated_sql", Value: "sql:SELECT 1;"}, + {Key: "success", Value: "true"}, + // No error field should be present when successful + }, + } + + var hasSuccess bool + var hasError bool + var successValue string + + for _, pair := range mockResponse.Data { + if pair.Key == "success" { + hasSuccess = true + successValue = pair.Value + } + if pair.Key == "error" { + hasError = true + } + } + + assert.True(t, hasSuccess, "success field must be present") + assert.Equal(t, "true", successValue, "success should be 'true'") + assert.False(t, hasError, "error field should not be present when successful") + }) + + t.Run("error response structure", func(t *testing.T) { + // When an error occurs, we expect different handling + // This test documents the expected error format + + mockErrorResponse := &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "success", Value: "false"}, + {Key: "error", Value: "AI generation failed"}, + {Key: "error_code", Value: "GENERATION_FAILED"}, + }, + } + + var hasSuccess bool + var hasError bool + var hasErrorCode bool + var successValue string + + for _, pair := range mockErrorResponse.Data { + if pair.Key == "success" { + hasSuccess = true + successValue = pair.Value + } + if pair.Key == "error" { + hasError = true + } + if pair.Key == "error_code" { + hasErrorCode = true + } + } + + assert.True(t, hasSuccess, "success field must be present even on error") + assert.Equal(t, "false", successValue, "success should be 'false' on error") + assert.True(t, hasError, "error field should be present on error") + assert.True(t, hasErrorCode, "error_code field should be present on error") + }) + + t.Run("success response must not contain error fields", func(t *testing.T) { + // Verify successful responses don't accidentally include error fields + mockSuccessResponse := &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "api_version", Value: "v1"}, + {Key: "generated_sql", Value: "sql:SELECT * FROM users;"}, + {Key: "success", Value: "true"}, + {Key: "meta", Value: `{"confidence": 0.9}`}, + }, + } + + var hasError bool + var hasErrorCode bool + var successValue string + + for _, pair := range mockSuccessResponse.Data { + if pair.Key == "success" { + successValue = pair.Value + } + if pair.Key == "error" { + hasError = true + } + if pair.Key == "error_code" { + hasErrorCode = true + } + } + + assert.Equal(t, "true", successValue, "success should be 'true'") + assert.False(t, hasError, "error field must not be present in successful response") + assert.False(t, hasErrorCode, "error_code field must not be present in successful response") + }) +} + +// TestMetaJSONParsing verifies meta field contains valid JSON +func TestMetaJSONParsing(t *testing.T) { + mockResponse := &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "generated_sql", Value: "sql:SELECT 1;"}, + {Key: "meta", Value: `{"confidence": 0.95, "model": "test-model"}`}, + }, + } + + var metaValue string + for _, pair := range mockResponse.Data { + if pair.Key == "meta" { + metaValue = pair.Value + break + } + } + + // Verify meta is valid JSON + var metaData map[string]interface{} + err := json.Unmarshal([]byte(metaValue), &metaData) + require.NoError(t, err, "meta field should contain valid JSON") + + // Verify expected meta fields + assert.Contains(t, metaData, "confidence", "meta should contain confidence") + assert.Contains(t, metaData, "model", "meta should contain model") +} + +// TestGeneratedSQLFormat verifies the format of generated SQL +func TestGeneratedSQLFormat(t *testing.T) { + tests := []struct { + name string + sqlValue string + expectValid bool + }{ + { + name: "valid format with sql and explanation", + sqlValue: "sql:SELECT * FROM users;\nexplanation:Get all users", + expectValid: true, + }, + { + name: "valid format multiline", + sqlValue: "sql:SELECT * FROM users WHERE age > 18;\nexplanation:Get adult users", + expectValid: true, + }, + { + name: "missing explanation", + sqlValue: "sql:SELECT * FROM users;", + expectValid: false, // Should have explanation + }, + { + name: "missing sql prefix", + sqlValue: "SELECT * FROM users;\nexplanation:Get users", + expectValid: false, // Should have sql: prefix + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hasSQL := containsStr(tt.sqlValue, "sql:") + hasExplanation := containsStr(tt.sqlValue, "explanation:") + + isValid := hasSQL && hasExplanation + assert.Equal(t, tt.expectValid, isValid, + "SQL format validation failed for: %s", tt.name) + }) + } +} + +// Helper function +func containsStr(s, substr string) bool { + return len(s) >= len(substr) && + (s == substr || containsSubstring(s, substr)) +} + +func containsSubstring(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// Benchmark for field lookup performance +func BenchmarkFieldLookup(b *testing.B) { + mockResponse := &server.DataQueryResult{ + Data: []*server.Pair{ + {Key: "api_version", Value: "v1"}, + {Key: "generated_sql", Value: "sql:SELECT * FROM users;"}, + {Key: "success", Value: "true"}, + {Key: "meta", Value: `{"confidence": 0.95}`}, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, pair := range mockResponse.Data { + if pair.Key == "generated_sql" { + _ = pair.Value + break + } + } + } +} + +func TestHandleUpdateConfigRefreshesEngine(t *testing.T) { + service, err := NewAIPluginService() + require.NoError(t, err) + require.NotNil(t, service) + t.Cleanup(service.Shutdown) + + oldManager := service.aiManager + oldEngine := service.aiEngine + + updatePayload := map[string]any{ + "provider": "ollama", + "config": map[string]any{ + "provider": "ollama", + "endpoint": "http://localhost:11439", + "model": "test-model", + "api_key": "", + "max_tokens": 1337, + }, + } + + payload, err := json.Marshal(updatePayload) + require.NoError(t, err) + + resp, err := service.handleUpdateConfig(context.Background(), &server.DataQuery{Sql: string(payload)}) + require.NoError(t, err) + require.NotNil(t, resp) + + updatedService := service.config.AI.Services["ollama"] + require.Equal(t, "http://localhost:11439", updatedService.Endpoint) + require.Equal(t, 1337, updatedService.MaxTokens) + + require.NotNil(t, service.aiManager) + require.NotNil(t, service.aiEngine) + require.NotNil(t, service.capabilityDetector) + + require.NotEqual(t, oldManager, service.aiManager) + require.NotEqual(t, oldEngine, service.aiEngine) +} + +func TestResolveDatabaseType(t *testing.T) { + svc := &AIPluginService{ + config: &config.Config{ + Database: config.DatabaseConfig{DefaultType: "postgres"}, + }, + } + + t.Run("uses explicit value when provided", func(t *testing.T) { + assert.Equal(t, "mysql", svc.resolveDatabaseType("mysql", nil)) + }) + + t.Run("normalizes postgres aliases", func(t *testing.T) { + assert.Equal(t, "postgresql", svc.resolveDatabaseType("pg", nil)) + }) + + t.Run("falls back to config default", func(t *testing.T) { + assert.Equal(t, "postgresql", svc.resolveDatabaseType("", nil)) + }) + + t.Run("uses config map overrides", func(t *testing.T) { + configMap := map[string]any{"database_dialect": "sqlite3"} + assert.Equal(t, "sqlite", svc.resolveDatabaseType("", configMap)) + }) +} diff --git a/pkg/plugin/version.go b/pkg/plugin/version.go new file mode 100644 index 0000000..3bd3869 --- /dev/null +++ b/pkg/plugin/version.go @@ -0,0 +1,71 @@ +package plugin + +import ( + "runtime/debug" + "strings" +) + +const ( + defaultPluginVersion = "v1.0.0" + defaultAPITestingVersion = "v0.0.19" + apiTestingModulePath = "github.com/linuxsuren/api-testing" + pluginModulePath = "github.com/linuxsuren/atest-ext-ai" + developmentVersionSentinel = "(devel)" +) + +// buildPluginVersion can be overridden at link time via -ldflags. +var buildPluginVersion string + +// buildAPITestingVersion allows overriding the detected api-testing version via -ldflags. +var buildAPITestingVersion string + +func detectPluginVersion() string { + if v := normalizeVersion(buildPluginVersion); v != "" { + return v + } + + if info, ok := debug.ReadBuildInfo(); ok { + if info.Main.Path == pluginModulePath { + if v := normalizeVersion(info.Main.Version); v != "" { + return v + } + } + for _, setting := range info.Settings { + if setting.Key == "vcs.tag" { + if v := normalizeVersion(setting.Value); v != "" { + return v + } + } + } + } + + return defaultPluginVersion +} + +func detectAPITestingVersion() string { + if v := normalizeVersion(buildAPITestingVersion); v != "" { + return v + } + + if info, ok := debug.ReadBuildInfo(); ok { + for _, dep := range info.Deps { + if dep.Path == apiTestingModulePath { + if v := normalizeVersion(dep.Version); v != "" { + return v + } + } + } + } + + return defaultAPITestingVersion +} + +func normalizeVersion(v string) string { + if v == "" || v == developmentVersionSentinel { + return "" + } + if strings.HasPrefix(v, "v") || strings.HasPrefix(v, "V") { + return v + } + return "v" + v +}
{{ t('ai.welcome.startChat') }}
{{ message.sql }}