From 3546c04348b080e55c3f539027af0f7b71280048 Mon Sep 17 00:00:00 2001 From: Irina Levit Date: Sun, 28 Dec 2025 15:32:00 -0500 Subject: [PATCH] feat: Major UI/UX improvements and production readiness ## Features Added ### Document Reference System - Implemented numbered document references (@1, @2, etc.) with autocomplete dropdown - Added fuzzy filename matching for @filename references - Document filtering now prioritizes numeric refs > filename refs > all documents - Autocomplete dropdown appears when typing @ with keyboard navigation (Up/Down, Enter/Tab, Escape) - Document numbers displayed in UI for easy reference ### Conversation Management - Added conversation rename functionality with inline editing - Implemented conversation search (by title and content) - Search box always visible, even when no conversations exist - Export reports now replace @N references with actual filenames ### UI/UX Improvements - Removed debug toggle button - Improved text contrast in dark mode (better visibility) - Made input textarea expand to full available width - Fixed file text color for better readability - Enhanced document display with numbered badges ### Configuration & Timeouts - Made HTTP client timeouts configurable (connect, write, pool) - Added .env.example with all configuration options - Updated timeout documentation ### Developer Experience - Added `make test-setup` target for automated test conversation creation - Test setup script supports TEST_MESSAGE and TEST_DOCS env vars - Improved Makefile with dev and test-setup targets ### Documentation - Updated ARCHITECTURE.md with all new features - Created comprehensive deployment documentation - Added GPU VM setup guides - Removed unnecessary markdown files (CLAUDE.md, CONTRIBUTING.md, header.jpg) - Organized documentation in docs/ directory ### GPU VM / Ollama (Stability + GPU Offload) - Updated GPU VM docs to reflect the working systemd environment for remote Ollama - Standardized remote Ollama port to 11434 (and added /v1/models verification) - Documented required env for GPU offload on this VM: - `OLLAMA_MODELS=/mnt/data/ollama`, `HOME=/mnt/data/ollama/home` - `OLLAMA_LLM_LIBRARY=cuda_v12` (not `cuda`) - `LD_LIBRARY_PATH=/usr/local/lib/ollama:/usr/local/lib/ollama/cuda_v12` ## Technical Changes ### Backend - Enhanced `docs_context.py` with reference parsing (numeric and filename) - Added `update_conversation_title` to storage.py - New endpoints: PATCH /api/conversations/{id}/title, GET /api/conversations/search - Improved report generation with filename substitution ### Frontend - Removed debugMode state and related code - Added autocomplete dropdown component - Implemented search functionality in Sidebar - Enhanced ChatInterface with autocomplete and improved textarea sizing - Updated CSS for better contrast and responsive design ## Files Changed - Backend: config.py, council.py, docs_context.py, main.py, storage.py - Frontend: App.jsx, ChatInterface.jsx, Sidebar.jsx, and related CSS files - Documentation: README.md, ARCHITECTURE.md, new docs/ directory - Configuration: .env.example, Makefile - Scripts: scripts/test_setup.py ## Breaking Changes None - all changes are backward compatible ## Testing - All existing tests pass - New test-setup script validates conversation creation workflow - Manual testing of autocomplete, search, and rename features --- .cursor/rules.md | 27 + .editorconfig | 15 + .env.example | 80 + .github/workflows/ci.yml | 142 + .gitignore | 52 + .python-version | 1 + ARCHITECTURE.md | 80 + CHANGELOG.md | 48 + COMMIT_MESSAGE.md | 79 + Makefile | 298 ++ README.md | 187 + backend/__init__.py | 1 + backend/config.py | 109 + backend/council.py | 537 +++ backend/docs_context.py | 106 + backend/documents.py | 103 + backend/llm_client.py | 132 + backend/main.py | 579 +++ backend/openai_compat.py | 253 ++ backend/storage.py | 224 ++ backend/tests/test_config_env.py | 35 + backend/tests/test_doc_preview_truncation.py | 60 + backend/tests/test_docs_api.py | 110 + backend/tests/test_docs_context.py | 38 + backend/tests/test_documents.py | 48 + backend/tests/test_llm_client.py | 65 + backend/tests/test_llm_status.py | 36 + backend/tests/test_openai_compat.py | 87 + docs/DEPLOYMENT.md | 278 ++ docs/DEPLOYMENT_RECOMMENDATIONS.md | 178 + docs/GPU_VM_SETUP.md | 93 + docs/README.md | 11 + frontend/.gitignore | 24 + frontend/README.md | 16 + frontend/eslint.config.js | 29 + frontend/index.html | 13 + frontend/package-lock.json | 3649 ++++++++++++++++++ frontend/package.json | 29 + frontend/public/vite.svg | 1 + frontend/src/App.css | 16 + frontend/src/App.jsx | 462 +++ frontend/src/api.js | 286 ++ frontend/src/assets/react.svg | 1 + frontend/src/components/ChatInterface.css | 460 +++ frontend/src/components/ChatInterface.jsx | 424 ++ frontend/src/components/Sidebar.css | 214 + frontend/src/components/Sidebar.jsx | 141 + frontend/src/components/Stage1.css | 80 + frontend/src/components/Stage1.jsx | 47 + frontend/src/components/Stage2.css | 170 + frontend/src/components/Stage2.jsx | 110 + frontend/src/components/Stage3.css | 47 + frontend/src/components/Stage3.jsx | 37 + frontend/src/index.css | 140 + frontend/src/main.jsx | 10 + frontend/tests/api.test.js | 101 + frontend/tests/chatinterface.ssr.test.js | 23 + frontend/vite.config.js | 7 + pyproject.toml | 14 + scripts/check_firewall.sh | 24 + scripts/check_ollama_models.sh | 60 + scripts/configure_ollama_gpu_vm.sh | 40 + scripts/diagnose_gpu_vm.sh | 57 + scripts/find_ollama_models.sh | 59 + scripts/fix_firewall_gpu_vm.sh | 40 + scripts/fix_ollama_remote.sh | 57 + scripts/fix_ollama_storage.sh | 87 + scripts/test_gpu_vm.py | 54 + scripts/test_gpu_vm_detailed.py | 62 + scripts/test_model_query.py | 48 + scripts/test_model_timeout.py | 70 + scripts/test_ollama_direct.py | 33 + scripts/test_setup.py | 140 + start.sh | 31 + test-story.md | 30 + tests/Allan + burridge - taboos.md | 145 + tests/allan-burridge.md | 1127 ++++++ tests/test-story.md | 30 + uv.lock | 694 ++++ 79 files changed, 13531 insertions(+) create mode 100644 .cursor/rules.md create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 ARCHITECTURE.md create mode 100644 CHANGELOG.md create mode 100644 COMMIT_MESSAGE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/__init__.py create mode 100644 backend/config.py create mode 100644 backend/council.py create mode 100644 backend/docs_context.py create mode 100644 backend/documents.py create mode 100644 backend/llm_client.py create mode 100644 backend/main.py create mode 100644 backend/openai_compat.py create mode 100644 backend/storage.py create mode 100644 backend/tests/test_config_env.py create mode 100644 backend/tests/test_doc_preview_truncation.py create mode 100644 backend/tests/test_docs_api.py create mode 100644 backend/tests/test_docs_context.py create mode 100644 backend/tests/test_documents.py create mode 100644 backend/tests/test_llm_client.py create mode 100644 backend/tests/test_llm_status.py create mode 100644 backend/tests/test_openai_compat.py create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/DEPLOYMENT_RECOMMENDATIONS.md create mode 100644 docs/GPU_VM_SETUP.md create mode 100644 docs/README.md create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/ChatInterface.css create mode 100644 frontend/src/components/ChatInterface.jsx create mode 100644 frontend/src/components/Sidebar.css create mode 100644 frontend/src/components/Sidebar.jsx create mode 100644 frontend/src/components/Stage1.css create mode 100644 frontend/src/components/Stage1.jsx create mode 100644 frontend/src/components/Stage2.css create mode 100644 frontend/src/components/Stage2.jsx create mode 100644 frontend/src/components/Stage3.css create mode 100644 frontend/src/components/Stage3.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/tests/api.test.js create mode 100644 frontend/tests/chatinterface.ssr.test.js create mode 100644 frontend/vite.config.js create mode 100644 pyproject.toml create mode 100644 scripts/check_firewall.sh create mode 100755 scripts/check_ollama_models.sh create mode 100755 scripts/configure_ollama_gpu_vm.sh create mode 100755 scripts/diagnose_gpu_vm.sh create mode 100755 scripts/find_ollama_models.sh create mode 100755 scripts/fix_firewall_gpu_vm.sh create mode 100755 scripts/fix_ollama_remote.sh create mode 100755 scripts/fix_ollama_storage.sh create mode 100755 scripts/test_gpu_vm.py create mode 100644 scripts/test_gpu_vm_detailed.py create mode 100644 scripts/test_model_query.py create mode 100644 scripts/test_model_timeout.py create mode 100644 scripts/test_ollama_direct.py create mode 100755 scripts/test_setup.py create mode 100755 start.sh create mode 100644 test-story.md create mode 100644 tests/Allan + burridge - taboos.md create mode 100644 tests/allan-burridge.md create mode 100644 tests/test-story.md create mode 100644 uv.lock diff --git a/.cursor/rules.md b/.cursor/rules.md new file mode 100644 index 0000000..327d85a --- /dev/null +++ b/.cursor/rules.md @@ -0,0 +1,27 @@ +## Cursor Rules (Project Standards) + +### Non-negotiables +- Never commit secrets. Never paste credentials into code, docs, or logs. +- Prefer env vars over hardcoding (see `.env.example`). +- Add/extend tests for any behavior change. + +### Backend standards +- Use the provider abstraction (`backend/llm_client.py`) for all model calls. +- Keep request/response payloads explicit and logged safely (no secrets). +- Treat uploaded documents as untrusted input: + - enforce `.md` extension + - enforce size limits + - never execute content + +### Frontend standards +- Keep API calls in `frontend/src/api.js`. +- Keep app state + orchestration in `frontend/src/App.jsx`. +- Components should be readable and small. + +### Testing +- Backend: `make test-backend` +- Frontend: `make test-frontend` + +### Architecture +- The app runs locally; the GPU VM runs an OpenAI-compatible inference server. +- The app calls the VM over HTTP (or via SSH tunnel) and does not "use the GPU" directly. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9113dee --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 + +[*.{js,jsx,ts,tsx,json,css,md}] +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2ec3383 --- /dev/null +++ b/.env.example @@ -0,0 +1,80 @@ +# ============================================================================ +# LLM Council Configuration +# ============================================================================ +# Copy this file to .env and customize as needed +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Provider & Server Configuration +# ---------------------------------------------------------------------------- + +# Use local Ollama (automatically sets base URL to http://localhost:11434) +USE_LOCAL_OLLAMA=true + +# For remote Ollama or other OpenAI-compatible servers, comment USE_LOCAL_OLLAMA +# and set the base URL instead: +# OPENAI_COMPAT_BASE_URL=http://your-server:11434 +# OPENAI_COMPAT_BASE_URL=http://10.0.30.63:8000 + +# Optional API key if your server requires authentication +# OPENAI_COMPAT_API_KEY=your_api_key_here + +# ---------------------------------------------------------------------------- +# Model Configuration +# ---------------------------------------------------------------------------- + +# Models for the council (comma or newline separated) +# Tip: Check available models with: curl -s 'http://localhost:8001/api/llm/status?probe=true' +COUNCIL_MODELS=llama3.2:3b,qwen2.5:3b,gemma2:2b + +# Chairman model (synthesizes final response from council) +CHAIRMAN_MODEL=llama3.2:3b + +# Maximum tokens per request +MAX_TOKENS=1024 + +# ---------------------------------------------------------------------------- +# Timeout Configuration +# ---------------------------------------------------------------------------- + +# Default timeout for general LLM queries (Stage 1: council responses) +LLM_TIMEOUT_SECONDS=300.0 + +# Timeout for chairman synthesis (may need longer for complex responses) +CHAIRMAN_TIMEOUT_SECONDS=180.0 + +# Timeout for title generation (short responses) +TITLE_GENERATION_TIMEOUT_SECONDS=120.0 + +# HTTP client timeout for OpenAI-compatible server (fallback, rarely used) +OPENAI_COMPAT_TIMEOUT_SECONDS=300 + +# ---------------------------------------------------------------------------- +# Retry Configuration +# ---------------------------------------------------------------------------- + +# Number of retries for failed requests (retryable HTTP errors) +OPENAI_COMPAT_RETRIES=2 + +# Exponential backoff base delay between retries (seconds) +OPENAI_COMPAT_RETRY_BACKOFF_SECONDS=0.5 + +# ---------------------------------------------------------------------------- +# Concurrency Configuration +# ---------------------------------------------------------------------------- + +# Maximum concurrent model requests (0 = unlimited, 1 = sequential) +LLM_MAX_CONCURRENCY=1 + +# ---------------------------------------------------------------------------- +# Document Upload Configuration (Optional) +# ---------------------------------------------------------------------------- + +# Directory for uploaded markdown documents (per-conversation) +# DOCS_DIR=data/docs + +# Maximum document size in bytes (default: 1MB) +# MAX_DOC_BYTES=1000000 + +# Maximum characters to preview when fetching documents (default: 20000) +# MAX_DOC_PREVIEW_CHARS=20000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..be4bab6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,142 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + backend-test: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: uv sync + + - name: Run backend tests + run: uv run python -m unittest discover -s backend/tests -p "test_*.py" -v + + frontend-test: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: | + cd frontend + npm ci + + - name: Run frontend tests + run: | + cd frontend + npm test + + - name: Lint frontend + run: | + cd frontend + npm run lint + + lint-python: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: uv sync + + - name: Check Python syntax + run: | + python3 -m py_compile backend/**/*.py || true + echo "Python syntax check complete" + + secret-scanning: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + dependency-scan: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + continue-on-error: true + + - name: Upload Trivy results to GitHub Security + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: 'trivy-results.sarif' + continue-on-error: true + + workflow-summary: + runs-on: ubuntu-latest + needs: [backend-test, frontend-test, lint-python, secret-scanning, dependency-scan] + if: always() + steps: + - name: Generate workflow summary + run: | + echo "## 🔍 CI Workflow Summary" >> $GITHUB_STEP_SUMMARY || true + echo "" >> $GITHUB_STEP_SUMMARY || true + echo "### Job Results" >> $GITHUB_STEP_SUMMARY || true + echo "" >> $GITHUB_STEP_SUMMARY || true + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY || true + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY || true + echo "| 🐍 Backend Tests | ${{ needs.backend-test.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| ⚛️ Frontend Tests | ${{ needs.frontend-test.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| 📝 Python Lint | ${{ needs.lint-python.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| 🔐 Secret Scanning | ${{ needs.secret-scanning.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| 📦 Dependency Scan | ${{ needs.dependency-scan.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "" >> $GITHUB_STEP_SUMMARY || true + echo "### 📊 Summary" >> $GITHUB_STEP_SUMMARY || true + echo "" >> $GITHUB_STEP_SUMMARY || true + echo "All checks have completed. Review individual job logs for details." >> $GITHUB_STEP_SUMMARY || true + continue-on-error: true + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39d3c27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Python-generated files +__pycache__/ +*.py[oc] +*.pyc +build/ +dist/ +wheels/ +*.egg-info +.pytest_cache/ +.coverage +htmlcov/ + +# Virtual environments +.venv +venv/ +ENV/ +env/ + +# Keys and secrets +.env +.env.local +.env.*.local + +# Data files +data/ + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/.vite/ +frontend/build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.temp +.cache/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..246e701 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,80 @@ +## Architecture + +### Overview +LLM Council is a local web app with: +- **Frontend**: React + Vite (`frontend/`) on `:5173` +- **Backend**: FastAPI (`backend/`) on `:8001` +- **Storage**: JSON conversations + uploaded markdown docs on disk (`data/`) +- **LLM Provider**: pluggable backend client + +### Runtime data flow +1. UI sends a message to backend (`/api/conversations/{id}/message/stream`). +2. Backend loads any uploaded markdown docs for the conversation and injects them as additional context. +3. Backend runs a 3-stage pipeline: + - **Stage 1**: query each council model in parallel + - **Stage 2**: anonymized peer review + ranking + - **Stage 3**: chairman synthesis + +### LLM provider layer +The backend uses OpenAI-compatible API servers (Ollama, vLLM, TGI, etc.). + +Configuration: +- `USE_LOCAL_OLLAMA=true` - automatically sets base URL to `http://localhost:11434` +- `OPENAI_COMPAT_BASE_URL` - set to your server URL (e.g., `http://remote-server:11434`) + +The provider (`backend/openai_compat.py`) targets servers that expose: +- `POST /v1/chat/completions` +- `GET /v1/models` + +The council orchestration uses the unified interface in `backend/llm_client.py`. + +### Document uploads and references +- Per-conversation markdown documents are stored under: `data/docs//` +- Documents are automatically numbered (1, 2, 3, etc.) based on upload order +- Documents can be referenced in prompts using: + - Numeric references: `@1`, `@2`, `@3` (by upload order) + - Filename references: `@filename` (fuzzy matching) +- Backend endpoints: + - `GET /api/conversations/{id}/documents` + - `POST /api/conversations/{id}/documents` (multipart file) + - `GET /api/conversations/{id}/documents/{doc_id}` (preview/truncated) + - `DELETE /api/conversations/{id}/documents/{doc_id}` +- Document context is automatically injected when referenced in user queries +- Export reports replace `@1`, `@2` references with actual filenames + +### Conversation management +- Conversations stored as JSON files in `data/conversations/` +- Features: + - Create, list, get, delete conversations + - Rename conversations (inline editing) + - Search conversations by title and message content + - Export conversations as markdown reports + - Auto-generate titles from first message + +### Frontend features +- **Document autocomplete**: Type `@` to see numbered document list with autocomplete +- **Conversation search**: Search box filters conversations by title/content +- **Theme toggle**: Light/dark mode support +- **Streaming responses**: Real-time updates as models respond +- **Document preview**: View uploaded documents inline +- **Export reports**: Download conversations as markdown files + +### Configuration +Primary runtime config is via `.env` (gitignored). Key settings: +- Model configuration: `COUNCIL_MODELS`, `CHAIRMAN_MODEL` +- Timeouts: `LLM_TIMEOUT_SECONDS`, `CHAIRMAN_TIMEOUT_SECONDS`, `OPENAI_COMPAT_TIMEOUT_SECONDS` +- HTTP client timeouts: `OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS`, `OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS`, `OPENAI_COMPAT_POOL_TIMEOUT_SECONDS` +- Document limits: `MAX_DOC_BYTES`, `MAX_DOC_PREVIEW_CHARS` + +Useful endpoints: +- `GET /api/llm/status` and `GET /api/llm/status?probe=true` +- `GET /api/conversations/search?q=...` - Search conversations +- `PATCH /api/conversations/{id}/title` - Rename conversation + +### Security model (local dev) +This is currently built for local/private network usage. +If you deploy beyond localhost, add: +- auth (session/token) +- rate limits +- upload limits +- network restrictions / TLS diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0bca9f6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] - Production Ready Release + +### Added +- **Document Reference System**: Numbered references (@1, @2, etc.) with autocomplete dropdown +- **Fuzzy Filename Matching**: Support for @filename references with partial matching +- **Conversation Rename**: Inline editing to rename conversations +- **Conversation Search**: Search by title and content, always-visible search box +- **Autocomplete Dropdown**: Keyboard-navigable dropdown when typing @ +- **Test Setup Script**: `make test-setup` for automated test conversation creation +- **Configuration Template**: `.env.example` with all available options +- **HTTP Client Timeouts**: Configurable connect, write, and pool timeouts +- **Comprehensive Documentation**: Deployment guides, GPU VM setup, architecture docs + +### Changed +- **UI Improvements**: Better text contrast, larger input textarea, improved document display +- **Report Generation**: @N references replaced with actual filenames in exports +- **Document Filtering**: Prioritizes numeric refs > filename refs > all documents +- **Removed Debug UI**: Cleaned up debug toggle button +- **Documentation Organization**: Moved deployment docs to `docs/` directory + +### Fixed +- **Text Visibility**: Improved contrast in dark mode +- **Input Sizing**: Textarea now expands to full available width +- **File Text Color**: Better visibility for document names +- **Search Visibility**: Search box remains visible even with no conversations +- **ReferenceError**: Fixed `debugMode is not defined` error + +### Technical +- Enhanced `docs_context.py` with reference parsing +- New API endpoints: `PATCH /api/conversations/{id}/title`, `GET /api/conversations/search` +- Improved error handling and user feedback +- Better state management in React components + +## [0.1.0] - Initial Release + +### Added +- Multi-LLM council system with Stage 1 (individual responses), Stage 2 (peer review), Stage 3 (synthesis) +- OpenAI-compatible API support (Ollama, vLLM, TGI) +- Document upload and management +- Conversation management +- Streaming responses +- Light/dark theme toggle +- Basic UI with React + Vite + diff --git a/COMMIT_MESSAGE.md b/COMMIT_MESSAGE.md new file mode 100644 index 0000000..e40677e --- /dev/null +++ b/COMMIT_MESSAGE.md @@ -0,0 +1,79 @@ +feat: Major UI/UX improvements and production readiness + +## Features Added + +### Document Reference System +- Implemented numbered document references (@1, @2, etc.) with autocomplete dropdown +- Added fuzzy filename matching for @filename references +- Document filtering now prioritizes numeric refs > filename refs > all documents +- Autocomplete dropdown appears when typing @ with keyboard navigation (Up/Down, Enter/Tab, Escape) +- Document numbers displayed in UI for easy reference + +### Conversation Management +- Added conversation rename functionality with inline editing +- Implemented conversation search (by title and content) +- Search box always visible, even when no conversations exist +- Export reports now replace @N references with actual filenames + +### UI/UX Improvements +- Removed debug toggle button +- Improved text contrast in dark mode (better visibility) +- Made input textarea expand to full available width +- Fixed file text color for better readability +- Enhanced document display with numbered badges + +### Configuration & Timeouts +- Made HTTP client timeouts configurable (connect, write, pool) +- Added .env.example with all configuration options +- Updated timeout documentation + +### Developer Experience +- Added `make test-setup` target for automated test conversation creation +- Test setup script supports TEST_MESSAGE and TEST_DOCS env vars +- Improved Makefile with dev and test-setup targets + +### Documentation +- Updated ARCHITECTURE.md with all new features +- Created comprehensive deployment documentation +- Added GPU VM setup guides +- Removed unnecessary markdown files (CLAUDE.md, CONTRIBUTING.md, header.jpg) +- Organized documentation in docs/ directory + +### GPU VM / Ollama (Stability + GPU Offload) +- Updated GPU VM docs to reflect the working systemd environment for remote Ollama +- Standardized remote Ollama port to 11434 (and added /v1/models verification) +- Documented required env for GPU offload on this VM: + - `OLLAMA_MODELS=/mnt/data/ollama`, `HOME=/mnt/data/ollama/home` + - `OLLAMA_LLM_LIBRARY=cuda_v12` (not `cuda`) + - `LD_LIBRARY_PATH=/usr/local/lib/ollama:/usr/local/lib/ollama/cuda_v12` + +## Technical Changes + +### Backend +- Enhanced `docs_context.py` with reference parsing (numeric and filename) +- Added `update_conversation_title` to storage.py +- New endpoints: PATCH /api/conversations/{id}/title, GET /api/conversations/search +- Improved report generation with filename substitution + +### Frontend +- Removed debugMode state and related code +- Added autocomplete dropdown component +- Implemented search functionality in Sidebar +- Enhanced ChatInterface with autocomplete and improved textarea sizing +- Updated CSS for better contrast and responsive design + +## Files Changed +- Backend: config.py, council.py, docs_context.py, main.py, storage.py +- Frontend: App.jsx, ChatInterface.jsx, Sidebar.jsx, and related CSS files +- Documentation: README.md, ARCHITECTURE.md, new docs/ directory +- Configuration: .env.example, Makefile +- Scripts: scripts/test_setup.py + +## Breaking Changes +None - all changes are backward compatible + +## Testing +- All existing tests pass +- New test-setup script validates conversation creation workflow +- Manual testing of autocomplete, search, and rename features + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..26007f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,298 @@ +.PHONY: dev backend frontend test test-backend test-frontend test-setup \ + stop-backend stop-frontend stop-ollama reset \ + start-ollama start-backend start-frontend up restart \ + status logs help + +help: + @# Use color only when output is a TTY. Keep plain output for logs/CI. + @is_tty=0; if [ -t 1 ]; then is_tty=1; fi; \ + if [ $$is_tty -eq 1 ]; then \ + BOLD=$$(printf '\033[1m'); DIM=$$(printf '\033[2m'); RESET=$$(printf '\033[0m'); \ + CYAN=$$(printf '\033[36m'); GREEN=$$(printf '\033[32m'); YELLOW=$$(printf '\033[33m'); \ + else \ + BOLD=""; DIM=""; RESET=""; CYAN=""; GREEN=""; YELLOW=""; \ + fi; \ + printf "%sLLM Council%s %sMake targets%s\n" "$$BOLD" "$$RESET" "$$DIM" "$$RESET"; \ + printf "\n"; \ + printf "%sCore%s\n" "$$CYAN" "$$RESET"; \ + printf " %smake dev%s Start backend + frontend (foreground, Ctrl+C to stop)\n" "$$GREEN" "$$RESET"; \ + printf " %smake up%s Start ollama + backend + frontend (background via nohup)\n" "$$GREEN" "$$RESET"; \ + printf " %smake restart%s Reset (stop) then start everything (up)\n" "$$GREEN" "$$RESET"; \ + printf "\n"; \ + printf "%sStart/Stop%s\n" "$$CYAN" "$$RESET"; \ + printf " %smake start-ollama%s Start Ollama (systemd if available, else 'ollama serve' nohup)\n" "$$GREEN" "$$RESET"; \ + printf " %smake start-backend%s Start backend (nohup, logs: %s/tmp/llm-council-backend.log%s)\n" "$$GREEN" "$$RESET" "$$DIM" "$$RESET"; \ + printf " %smake start-frontend%s Start frontend (nohup, logs: %s/tmp/llm-council-frontend.log%s)\n" "$$GREEN" "$$RESET" "$$DIM" "$$RESET"; \ + printf " %smake stop-backend%s Stop backend on port %s8001%s\n" "$$GREEN" "$$RESET" "$$YELLOW" "$$RESET"; \ + printf " %smake stop-frontend%s Stop frontend on port %s5173%s\n" "$$GREEN" "$$RESET" "$$YELLOW" "$$RESET"; \ + printf " %smake stop-ollama%s Attempt to stop Ollama (systemd if available)\n" "$$GREEN" "$$RESET"; \ + printf " %smake reset%s Stop frontend + backend + attempt Ollama stop\n" "$$GREEN" "$$RESET"; \ + printf "\n"; \ + printf "%sTesting / setup%s\n" "$$CYAN" "$$RESET"; \ + printf " %smake test%s Run backend + frontend tests\n" "$$GREEN" "$$RESET"; \ + printf " %smake test-backend%s Run backend unit tests\n" "$$GREEN" "$$RESET"; \ + printf " %smake test-frontend%s Run frontend tests\n" "$$GREEN" "$$RESET"; \ + printf " %smake test-setup%s Create a new conversation, upload TEST_DOCS, prefill TEST_MESSAGE in UI\n" "$$GREEN" "$$RESET"; \ + printf " %sEnv:%s TEST_DOCS=path1.md,path2.md TEST_MESSAGE='your msg'\n" "$$DIM" "$$RESET"; \ + printf "\n"; \ + printf "%sDiagnostics%s\n" "$$CYAN" "$$RESET"; \ + printf " %smake status%s Show whether backend/frontend/ollama are up + PIDs + basic info\n" "$$GREEN" "$$RESET"; \ + printf " %smake logs%s Tail recent backend/frontend logs (from /tmp)\n" "$$GREEN" "$$RESET"; \ + printf "\n"; \ + printf "%sHelp%s\n" "$$CYAN" "$$RESET"; \ + printf " %smake help%s Show this message\n" "$$GREEN" "$$RESET" + +backend: + uv run python -m backend.main + +frontend: + cd frontend && npm run dev + +dev: + ./start.sh + +test-backend: + uv run python -m unittest discover -s backend/tests -p "test_*.py" -q + +test-frontend: + cd frontend && npm test + +test: test-backend test-frontend + +stop-backend: + @echo "Stopping backend processes on port 8001..." + @if lsof -ti:8001 > /dev/null 2>&1; then \ + lsof -ti:8001 | xargs kill -9 2>/dev/null || true; \ + echo "✓ Backend stopped"; \ + sleep 1; \ + else \ + echo "✓ No backend process found on port 8001"; \ + fi + +stop-frontend: + @echo "Stopping frontend processes on port 5173..." + @if lsof -ti:5173 > /dev/null 2>&1; then \ + lsof -ti:5173 | xargs kill -9 2>/dev/null || true; \ + echo "✓ Frontend stopped"; \ + sleep 1; \ + else \ + echo "✓ No frontend process found on port 5173"; \ + fi + +stop-ollama: + @echo "Stopping Ollama..." + @# Prefer systemd if available; do not force-kill processes (safer). + @if command -v systemctl >/dev/null 2>&1; then \ + if systemctl is-active --quiet ollama 2>/dev/null; then \ + echo "Stopping systemd service: ollama"; \ + systemctl stop ollama 2>/dev/null || true; \ + sleep 2; \ + else \ + echo "Ollama systemd service is not active"; \ + fi; \ + else \ + echo "systemctl not found (not a systemd environment)"; \ + fi + @PIDS=$$(pgrep -x ollama 2>/dev/null || true); \ + if [ -n "$$PIDS" ]; then \ + echo "⚠️ Ollama processes still running (PIDs: $$PIDS)"; \ + echo " Stop manually with one of:"; \ + echo " - systemctl stop ollama (if installed as a service)"; \ + echo " - pkill -x ollama"; \ + echo " - sudo pkill -x ollama"; \ + else \ + echo "✓ Ollama is stopped"; \ + fi + +reset: stop-frontend stop-backend stop-ollama + @echo "" + @echo "✓ Reset complete - Frontend/Backend stopped (Ollama stop attempted)" + @echo "" + @echo "To start fresh (recommended):" + @echo " make up" + @echo "" + @echo "Or manually:" + @echo " 1) make start-ollama" + @echo " 2) make start-backend" + @echo " 3) make start-frontend" + +start-ollama: + @echo "Starting Ollama..." + @if curl -s --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1; then \ + echo "✓ Ollama already responding on http://localhost:11434"; \ + exit 0; \ + fi + @if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q "^ollama\\.service"; then \ + echo "Starting systemd service: ollama"; \ + systemctl start ollama 2>/dev/null || true; \ + sleep 2; \ + else \ + echo "systemd service not available; starting 'ollama serve' in background"; \ + nohup ollama serve > /tmp/llm-council-ollama.log 2>&1 & \ + echo "ollama serve PID: $$!"; \ + sleep 2; \ + fi + @if curl -s --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1; then \ + echo "✓ Ollama is up: http://localhost:11434"; \ + else \ + echo "⚠️ Ollama did not respond yet. Check: /tmp/llm-council-ollama.log"; \ + fi + +start-backend: + @echo "Starting backend..." + @if curl -s --max-time 2 http://localhost:8001/ >/dev/null 2>&1; then \ + echo "✓ Backend already responding on http://localhost:8001"; \ + exit 0; \ + fi + @nohup uv run python -m backend.main > /tmp/llm-council-backend.log 2>&1 & \ + echo "backend PID: $$!"; \ + sleep 2; \ + if curl -s --max-time 2 http://localhost:8001/ >/dev/null 2>&1; then \ + echo "✓ Backend is up: http://localhost:8001"; \ + else \ + echo "⚠️ Backend did not respond yet. Check: /tmp/llm-council-backend.log"; \ + fi + +start-frontend: + @echo "Starting frontend..." + @if curl -s --max-time 2 http://localhost:5173/ >/dev/null 2>&1; then \ + echo "✓ Frontend already responding on http://localhost:5173"; \ + exit 0; \ + fi + @nohup sh -c "cd frontend && npm run dev" > /tmp/llm-council-frontend.log 2>&1 & \ + echo "frontend PID: $$!"; \ + sleep 2; \ + if curl -s --max-time 2 http://localhost:5173/ >/dev/null 2>&1; then \ + echo "✓ Frontend is up: http://localhost:5173"; \ + else \ + echo "⚠️ Frontend did not respond yet. Check: /tmp/llm-council-frontend.log"; \ + fi + +up: start-ollama start-backend start-frontend + @echo "" + @echo "✓ All services started (or already running)" + @echo "Frontend: http://localhost:5173" + @echo "Backend: http://localhost:8001" + @echo "Ollama: http://localhost:11434" + +restart: reset up + +status: + @echo "=== LLM Council status ===" + @date + @echo "" + @echo "-- Backend (8001) --" + @if curl -s --max-time 2 http://localhost:8001/ >/dev/null 2>&1; then \ + echo "✓ UP: http://localhost:8001"; \ + else \ + echo "✗ DOWN: http://localhost:8001"; \ + fi + @echo "PIDs:"; lsof -ti:8001 2>/dev/null | tr '\n' ' ' || true; echo "" + @echo "" + @echo "-- Frontend (5173) --" + @if curl -s --max-time 2 http://localhost:5173/ >/dev/null 2>&1; then \ + echo "✓ UP: http://localhost:5173"; \ + else \ + echo "✗ DOWN: http://localhost:5173"; \ + fi + @echo "PIDs:"; lsof -ti:5173 2>/dev/null | tr '\n' ' ' || true; echo "" + @echo "" + @echo "-- Ollama (11434) --" + @if curl -s --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1; then \ + echo "✓ UP: http://localhost:11434"; \ + else \ + echo "✗ DOWN/UNREACHABLE: http://localhost:11434"; \ + fi + @O_PID=$$(pgrep -x ollama 2>/dev/null || true); \ + R_CNT=$$(pgrep -f "ollama runner" 2>/dev/null | wc -l | tr -d ' '); \ + if [ -n "$$O_PID" ]; then \ + echo "ollama PIDs: $$O_PID"; \ + echo "runner count: $$R_CNT"; \ + echo ""; \ + echo "top ollama processes:"; \ + ps -o pid,ppid,pcpu,pmem,etime,command -p $$O_PID 2>/dev/null || true; \ + pgrep -f "ollama runner" 2>/dev/null | head -5 | while read pid; do \ + ps -o pid,ppid,pcpu,pmem,etime,command -p $$pid 2>/dev/null || true; \ + done; \ + else \ + echo "ollama not running"; \ + fi + @echo "" + @echo "-- Quick health hints --" + @echo "Backend log: /tmp/llm-council-backend.log" + @echo "Frontend log: /tmp/llm-council-frontend.log" + @echo "Run: make logs" + +logs: + @echo "=== Recent logs ===" + @echo "" + @echo "-- Backend log (/tmp/llm-council-backend.log) --" + @tail -n 80 /tmp/llm-council-backend.log 2>/dev/null || echo "(no backend log found)" + @echo "" + @echo "-- Frontend log (/tmp/llm-council-frontend.log) --" + @tail -n 120 /tmp/llm-council-frontend.log 2>/dev/null || echo "(no frontend log found)" + +test-setup: + @echo "Setting up test conversation..." + @echo "Usage: TEST_MESSAGE='your message' TEST_DOCS='path1.md,path2.md' make test-setup" + @echo "" + @echo "⚠️ Safeguard check: Verifying backend is running..." + @if ! curl -s http://localhost:8001/ > /dev/null 2>&1; then \ + echo "✗ Backend is NOT running on port 8001"; \ + echo " Starting backend in background..."; \ + uv run python -m backend.main > /tmp/llm-council-backend.log 2>&1 & \ + BACKEND_PID=$$!; \ + echo " Backend PID: $$BACKEND_PID"; \ + sleep 2; \ + if ! curl -s http://localhost:8001/ > /dev/null 2>&1; then \ + echo "✗ Backend failed to start"; \ + exit 1; \ + fi; \ + echo "✓ Backend started"; \ + else \ + echo "✓ Backend is already running"; \ + fi + @echo "" + @echo "Running test setup script..." + @uv run python scripts/test_setup.py > /tmp/llm-council-test-setup.log 2>&1; \ + cat /tmp/llm-council-test-setup.log; \ + CONV_ID=$$(grep "CONVERSATION_ID=" /tmp/llm-council-test-setup.log | cut -d'=' -f2 | head -1); \ + OPEN_URL=$$(grep "OPEN_URL=" /tmp/llm-council-test-setup.log | cut -d'=' -f2- | head -1); \ + echo ""; \ + echo "Checking frontend status..."; \ + if pgrep -f "vite" > /dev/null 2>&1 || pgrep -f "npm.*dev" > /dev/null 2>&1; then \ + echo "✓ Frontend process detected (already running)"; \ + elif timeout 2 curl -s http://localhost:5173/ > /dev/null 2>&1; then \ + echo "✓ Frontend is responding"; \ + else \ + echo "Starting frontend in background..."; \ + cd frontend && npm run dev > /tmp/llm-council-frontend.log 2>&1 & \ + echo "Frontend starting (PID: $$!)"; \ + echo "Waiting up to 10 seconds for frontend..."; \ + for i in 1 2 3 4 5 6 7 8 9 10; do \ + if timeout 1 curl -s http://localhost:5173/ > /dev/null 2>&1; then \ + echo "✓ Frontend is ready!"; \ + break; \ + fi; \ + sleep 1; \ + done; \ + fi; \ + echo ""; \ + echo "✓ Test setup complete!"; \ + echo ""; \ + if [ -n "$$OPEN_URL" ]; then \ + echo "Opening browser: $$OPEN_URL"; \ + xdg-open "$$OPEN_URL" 2>/dev/null || \ + open "$$OPEN_URL" 2>/dev/null || \ + echo "Open manually: $$OPEN_URL"; \ + elif [ -n "$$CONV_ID" ]; then \ + echo "Opening browser with new conversation: $$CONV_ID"; \ + xdg-open "http://localhost:5173/?conversation=$$CONV_ID" 2>/dev/null || \ + open "http://localhost:5173/?conversation=$$CONV_ID" 2>/dev/null || \ + echo "Open manually: http://localhost:5173/?conversation=$$CONV_ID"; \ + else \ + echo "Open: http://localhost:5173"; \ + echo "The new conversation should appear in the sidebar"; \ + fi; \ + echo ""; \ + echo "Note: TEST_MESSAGE is NOT sent automatically - check the script output above for the message to paste" diff --git a/README.md b/README.md new file mode 100644 index 0000000..191b4e9 --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# LLM Council + + +The idea of this repo is that instead of asking a question to a single LLM, you can group multiple LLMs into your "LLM Council". This repo is a simple, local web app that essentially looks like ChatGPT except it sends your query to multiple LLMs via an OpenAI-compatible API (Ollama, vLLM, TGI, etc.), it then asks them to review and rank each other's work, and finally a Chairman LLM produces the final response. + +In a bit more detail, here is what happens when you submit a query: + +1. **Stage 1: First opinions**. The user query is given to all LLMs individually, and the responses are collected. The individual responses are shown in a "tab view", so that the user can inspect them all one by one. +2. **Stage 2: Review**. Each individual LLM is given the responses of the other LLMs. Under the hood, the LLM identities are anonymized so that the LLM can't play favorites when judging their outputs. The LLM is asked to rank them in accuracy and insight. +3. **Stage 3: Final response**. The designated Chairman of the LLM Council takes all of the model's responses and compiles them into a single final answer that is presented to the user. + +## Vibe Code Alert + +This project was 99% vibe coded as a fun Saturday hack because I wanted to explore and evaluate a number of LLMs side by side in the process of [reading books together with LLMs](https://x.com/karpathy/status/1990577951671509438). It's nice and useful to see multiple responses side by side, and also the cross-opinions of all LLMs on each other's outputs. You're not going to support it in any way, it's provided here as is for other people's inspiration and you don't intend to improve it. Code is ephemeral now and libraries are over, ask your LLM to change it in whatever way you like. + +## Setup + +### 1. Install Dependencies + +The project uses [uv](https://docs.astral.sh/uv/) for project management. + +**Backend:** +```bash +uv sync +``` + +**Frontend:** +```bash +cd frontend +npm install +cd .. +``` + +### 2. Configure Ollama Server + +LLM Council requires an OpenAI-compatible API server. The easiest way to get started is with Ollama running locally or on a remote server. + +**For local Ollama:** +1. Install and start Ollama: https://ollama.ai +2. Pull some models: +```bash +ollama pull llama3.2:3b +ollama pull qwen2.5:3b +ollama pull gemma2:2b +``` + +### 3. Configure Environment + +Create a `.env` file in the project root with your configuration: + +**For local Ollama:** +```bash +USE_LOCAL_OLLAMA=true +COUNCIL_MODELS=llama3.2:3b,qwen2.5:3b,gemma2:2b +CHAIRMAN_MODEL=llama3.2:3b +MAX_TOKENS=1024 +LLM_MAX_CONCURRENCY=1 +``` + +**For remote Ollama or other OpenAI-compatible server:** +```bash +OPENAI_COMPAT_BASE_URL=http://your-server:11434 +COUNCIL_MODELS=llama3.2:3b,qwen2.5:3b,gemma2:2b +CHAIRMAN_MODEL=llama3.2:3b +MAX_TOKENS=2048 +LLM_MAX_CONCURRENCY=1 +``` + +**Optional timeout configuration:** +```bash +LLM_TIMEOUT_SECONDS=120.0 # Default timeout for LLM queries +CHAIRMAN_TIMEOUT_SECONDS=180.0 # Timeout for chairman synthesis +TITLE_GENERATION_TIMEOUT_SECONDS=120.0 # Timeout for title generation +OPENAI_COMPAT_TIMEOUT_SECONDS=300.0 # Timeout for OpenAI-compatible server +OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS=10.0 # HTTP connection timeout +OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS=10.0 # HTTP write timeout +OPENAI_COMPAT_POOL_TIMEOUT_SECONDS=10.0 # HTTP pool timeout +``` + +See `.env.example` for all available configuration options. Alternatively, you can edit `backend/config.py` directly to set defaults. + +## Running the Application + +**Option 1: Use the start script** +```bash +./start.sh +``` + +**Option 2: Use Makefile** +```bash +make dev +``` + +**Option 3: Run manually** + +Terminal 1 (Backend): +```bash +uv run python -m backend.main +``` + +Terminal 2 (Frontend): +```bash +cd frontend +npm run dev +``` + +Then open http://localhost:5173 in your browser. + +**Option 4: Test setup with pre-configured conversation** +```bash +# Set in .env: +# TEST_MESSAGE="Your message" +# TEST_DOCS="doc1.md,doc2.md" +make test-setup +``` + +This creates a new conversation with today's date/time, uploads documents, and **pre-fills** the message in the UI (it does **not** auto-send). + +### Frontend theme default (optional) + +By default, the UI theme is persisted in `localStorage`. If there is no saved theme yet, you can set a default theme via a Vite env var: + +```bash +# Example (starts in dark mode if there's no localStorage value yet) +VITE_DEFAULT_THEME=dark make dev +``` + +## Using Ollama on a Remote Server + +If you have Ollama running on a remote server or VM: + +1. In your project `.env`, set: + +```bash +OPENAI_COMPAT_BASE_URL=http://your-server-ip:11434 +COUNCIL_MODELS=llama3.2:3b,qwen2.5:3b,gemma2:2b +CHAIRMAN_MODEL=llama3.2:3b +MAX_TOKENS=2048 +LLM_MAX_CONCURRENCY=1 +``` + +2. Verify connectivity from your machine: + +```bash +curl http://your-server-ip:11434/api/tags +``` + +## Using Other OpenAI-Compatible Servers (vLLM, TGI, etc.) + +If you're running vLLM, TGI, or another OpenAI-compatible server: + +1. Ensure your server exposes: + - `POST /v1/chat/completions` + - `GET /v1/models` + +2. In your project `.env`, set: + +```bash +OPENAI_COMPAT_BASE_URL=http://your-server:port +COUNCIL_MODELS=your-model-1,your-model-2,your-model-3 +CHAIRMAN_MODEL=your-model-1 +MAX_TOKENS=2048 +LLM_MAX_CONCURRENCY=1 + +# (optional) if your server requires auth: +# OPENAI_COMPAT_API_KEY=... +``` + +3. Verify connectivity: + +```bash +curl http://your-server:port/v1/models +``` + +## Documentation + +- **[Architecture](ARCHITECTURE.md)** - System architecture and design +- **[Deployment Guide](docs/DEPLOYMENT.md)** - How to deploy with remote GPU VM +- **[Deployment Recommendations](docs/DEPLOYMENT_RECOMMENDATIONS.md)** - Professional deployment options + +## Tech Stack + +- **Backend:** FastAPI (Python 3.10+), async httpx, OpenAI-compatible API +- **Frontend:** React + Vite, react-markdown for rendering +- **Storage:** JSON files in `data/conversations/` +- **Package Management:** uv for Python, npm for JavaScript +- **LLM Backend:** Ollama, vLLM, TGI, or any OpenAI-compatible server diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..659fe16 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""LLM Council backend package.""" diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..8528479 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,109 @@ +"""Configuration for the LLM Council.""" + +import os +from dotenv import load_dotenv + +load_dotenv() + +# Helpers +def _parse_int_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + try: + return int(raw.strip()) + except ValueError: + return default + + +def _parse_float_env(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + try: + return float(raw.strip()) + except ValueError: + return default + + +def _parse_list_env(name: str) -> list[str] | None: + """ + Parses a list from an env var. + + Supported formats: + - Comma-separated: "a,b,c" + - Newline-separated: "a\\nb\\nc" + """ + raw = os.getenv(name) + if raw is None: + return None + raw = raw.strip() + if raw == "": + return [] + + # Allow either commas or newlines. + parts = [] + for chunk in raw.replace("\r\n", "\n").split("\n"): + parts.extend(chunk.split(",")) + return [p.strip() for p in parts if p.strip()] + + +# Council members - list of model identifiers (Ollama model names) +# Can be overridden via env var COUNCIL_MODELS (comma or newline separated). +_DEFAULT_COUNCIL_MODELS = [ + "llama3.2:3b", + "qwen2.5:3b", + "gemma2:2b", +] +COUNCIL_MODELS = _parse_list_env("COUNCIL_MODELS") or _DEFAULT_COUNCIL_MODELS + +# Chairman model - synthesizes final response +CHAIRMAN_MODEL = os.getenv("CHAIRMAN_MODEL") or "llama3.2:3b" + +# Maximum tokens per request +# Default: 2048 tokens (reasonable for most responses) +# Increase if you need longer responses +MAX_TOKENS = _parse_int_env("MAX_TOKENS", 2048) + +# Request timeout configuration (in seconds) +# Default timeout for general LLM queries (Stage 1: council responses) +# Used by llm_client.py and passed to openai_compat.query_model() +LLM_TIMEOUT_SECONDS = _parse_float_env("LLM_TIMEOUT_SECONDS", 120.0) +# Timeout for chairman synthesis (may need longer for complex responses) +CHAIRMAN_TIMEOUT_SECONDS = _parse_float_env("CHAIRMAN_TIMEOUT_SECONDS", 180.0) +# Timeout for title generation (short responses) +TITLE_GENERATION_TIMEOUT_SECONDS = _parse_float_env("TITLE_GENERATION_TIMEOUT_SECONDS", 120.0) + +# OpenAI-compatible provider tuning (Ollama / vLLM / TGI) +# If USE_LOCAL_OLLAMA=true, automatically set base URL to localhost:11434 (convenience flag) +if os.getenv("USE_LOCAL_OLLAMA", "").strip().lower() in ("true", "1", "yes"): + _openai_compat_base_url = "http://localhost:11434" +else: + _openai_compat_base_url = os.getenv("OPENAI_COMPAT_BASE_URL") +OPENAI_COMPAT_BASE_URL = _openai_compat_base_url + +# HTTP client timeout (fallback when timeout not explicitly passed to openai_compat functions) +# Used by: list_models() and as fallback in query_model() if called directly without timeout +# Should be >= LLM_TIMEOUT_SECONDS for safety, but list_models() is fast so can be lower +OPENAI_COMPAT_TIMEOUT_SECONDS = _parse_float_env("OPENAI_COMPAT_TIMEOUT_SECONDS", 300.0) +# HTTP client connection timeout (time to establish connection) +OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS = _parse_float_env("OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS", 10.0) +# HTTP client write timeout (time to send request) +OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS = _parse_float_env("OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS", 10.0) +# HTTP client pool timeout (time to get connection from pool) +OPENAI_COMPAT_POOL_TIMEOUT_SECONDS = _parse_float_env("OPENAI_COMPAT_POOL_TIMEOUT_SECONDS", 10.0) +# Number of retries for failed requests (retryable HTTP errors: 408, 409, 425, 429, 500, 502, 503, 504) +OPENAI_COMPAT_RETRIES = _parse_int_env("OPENAI_COMPAT_RETRIES", 2) +# Exponential backoff base delay between retries (seconds) - actual delay is backoff * (2^attempt) +OPENAI_COMPAT_RETRY_BACKOFF_SECONDS = _parse_float_env("OPENAI_COMPAT_RETRY_BACKOFF_SECONDS", 0.5) + +# Debug mode - show debug logs in console (set DEBUG=true in .env) +DEBUG = os.getenv("DEBUG", "").strip().lower() in ("true", "1", "yes") + +# Markdown uploads (per-conversation) +DOCS_DIR = os.getenv("DOCS_DIR") or "data/docs" +MAX_DOC_BYTES = _parse_int_env("MAX_DOC_BYTES", 1_000_000) # 1MB +MAX_DOC_PREVIEW_CHARS = _parse_int_env("MAX_DOC_PREVIEW_CHARS", 20_000) + +# Data directory for conversation storage +DATA_DIR = "data/conversations" diff --git a/backend/council.py b/backend/council.py new file mode 100644 index 0000000..fd62c65 --- /dev/null +++ b/backend/council.py @@ -0,0 +1,537 @@ +"""3-stage LLM Council orchestration.""" + +import time +from typing import List, Dict, Any, Tuple, Optional +from .llm_client import query_models_parallel, query_model +from .config import COUNCIL_MODELS, CHAIRMAN_MODEL, CHAIRMAN_TIMEOUT_SECONDS, TITLE_GENERATION_TIMEOUT_SECONDS + + +def _format_docs_context(docs_text: Optional[str]) -> str: + if not docs_text or not docs_text.strip(): + return "" + return ( + "\n\nREFERENCE DOCUMENTS (user-provided markdown):\n" + "Use these as additional context if relevant. Quote sparingly and cite sections when helpful.\n" + f"{docs_text.strip()}\n" + ) + + +async def stage1_collect_responses(user_query: str, docs_text: Optional[str] = None) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """ + Stage 1: Collect individual responses from all council models. + + Args: + user_query: The user's question + + Returns: + Tuple of (results list, metadata dict with timing info) + """ + start_time = time.time() + prompt = f"{user_query}{_format_docs_context(docs_text)}" + messages = [{"role": "user", "content": prompt}] + + # Query all models in parallel + responses = await query_models_parallel(COUNCIL_MODELS, messages) + duration = time.time() - start_time + + # Format results + stage1_results = [] + successful_models = [] + failed_models = [] + for model, response in responses.items(): + if response is not None: # Only include successful responses + stage1_results.append({ + "model": model, + "response": response.get('content', '') + }) + successful_models.append(model) + else: + failed_models.append(model) + + metadata = { + "duration_seconds": round(duration, 2), + "successful_models": successful_models, + "failed_models": failed_models, + "total_models": len(COUNCIL_MODELS) + } + + return stage1_results, metadata + + +async def stage1_collect_responses_streaming( + user_query: str, + docs_text: Optional[str] = None, + on_response = None +) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """ + Stage 1: Collect individual responses from all council models, streaming as they complete. + + Args: + user_query: The user's question + docs_text: Optional document context + on_response: Optional callback(model, response_dict) called as each response completes + + Returns: + Tuple of (results list, metadata dict with timing info) + """ + import asyncio + from .llm_client import query_model, LLM_TIMEOUT_SECONDS, MAX_TOKENS + + start_time = time.time() + prompt = f"{user_query}{_format_docs_context(docs_text)}" + messages = [{"role": "user", "content": prompt}] + + # Query all models in parallel, but yield results as they complete + async def query_and_notify(model: str): + response = await query_model(model, messages, timeout=LLM_TIMEOUT_SECONDS, max_tokens_override=MAX_TOKENS) + if on_response and response is not None: + result = {"model": model, "response": response.get('content', '')} + await on_response(model, result) + return model, response + + tasks = [query_and_notify(model) for model in COUNCIL_MODELS] + responses_dict = {} + + # Use as_completed to process results as they finish + for coro in asyncio.as_completed(tasks): + model, response = await coro + responses_dict[model] = response + + duration = time.time() - start_time + + # Format results + stage1_results = [] + successful_models = [] + failed_models = [] + for model, response in responses_dict.items(): + if response is not None: # Only include successful responses + stage1_results.append({ + "model": model, + "response": response.get('content', '') + }) + successful_models.append(model) + else: + failed_models.append(model) + + metadata = { + "duration_seconds": round(duration, 2), + "successful_models": successful_models, + "failed_models": failed_models, + "total_models": len(COUNCIL_MODELS) + } + + return stage1_results, metadata + + +async def stage2_collect_rankings( + user_query: str, + stage1_results: List[Dict[str, Any]], + docs_text: Optional[str] = None, +) -> Tuple[List[Dict[str, Any]], Dict[str, str], Dict[str, Any]]: + """ + Stage 2: Each model ranks the anonymized responses. + + Args: + user_query: The original user query + stage1_results: Results from Stage 1 + + Returns: + Tuple of (rankings list, label_to_model mapping, metadata dict with timing) + """ + start_time = time.time() + # Handle empty stage1_results + if not stage1_results: + return [], {}, {"duration_seconds": 0.0, "successful_models": [], "failed_models": [], "total_models": len(COUNCIL_MODELS)} + + # Create anonymized labels for responses (Response A, Response B, etc.) + labels = [chr(65 + i) for i in range(len(stage1_results))] # A, B, C, ... + + # Create mapping from label to model name + label_to_model = { + f"Response {label}": result['model'] + for label, result in zip(labels, stage1_results) + } + + # Build the ranking prompt + responses_text = "\n\n".join([ + f"Response {label}:\n{result['response']}" + for label, result in zip(labels, stage1_results) + ]) + + ranking_prompt = f"""You are evaluating different responses to the following question: + +Question: {user_query} +{_format_docs_context(docs_text)} + +Here are the responses from different models (anonymized): + +{responses_text} + +Your task: +1. First, evaluate each response individually. For each response, explain what it does well and what it does poorly. +2. Then, at the very end of your response, provide a final ranking. + +IMPORTANT: Your final ranking MUST be formatted EXACTLY as follows: +- Start with the line "FINAL RANKING:" (all caps, with colon) +- Then list the responses from best to worst as a numbered list +- Each line should be: number, period, space, then ONLY the response label (e.g., "1. Response A") +- Do not add any other text or explanations in the ranking section + +Example of the correct format for your ENTIRE response: + +Response A provides good detail on X but misses Y... +Response B is accurate but lacks depth on Z... +Response C offers the most comprehensive answer... + +FINAL RANKING: +1. Response C +2. Response A +3. Response B + +Now provide your evaluation and ranking:""" + + messages = [{"role": "user", "content": ranking_prompt}] + + # Get rankings from all council models in parallel + responses = await query_models_parallel(COUNCIL_MODELS, messages) + duration = time.time() - start_time + + # Format results + stage2_results = [] + successful_models = [] + failed_models = [] + for model, response in responses.items(): + if response is not None: + full_text = response.get('content', '') + parsed = parse_ranking_from_text(full_text) + stage2_results.append({ + "model": model, + "ranking": full_text, + "parsed_ranking": parsed + }) + successful_models.append(model) + else: + failed_models.append(model) + + metadata = { + "duration_seconds": round(duration, 2), + "successful_models": successful_models, + "failed_models": failed_models, + "total_models": len(COUNCIL_MODELS) + } + + return stage2_results, label_to_model, metadata + + +async def stage3_synthesize_final( + user_query: str, + stage1_results: List[Dict[str, Any]], + stage2_results: List[Dict[str, Any]], + docs_text: Optional[str] = None, +) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Stage 3: Chairman synthesizes final response. + + Args: + user_query: The original user query + stage1_results: Individual model responses from Stage 1 + stage2_results: Rankings from Stage 2 + + Returns: + Tuple of (result dict with 'model' and 'response' keys, metadata dict with timing) + """ + start_time = time.time() + # Handle empty inputs + if not stage1_results: + duration = time.time() - start_time + return { + "model": CHAIRMAN_MODEL, + "response": "Error: Cannot synthesize final answer - no responses from Stage 1." + }, { + "duration_seconds": round(duration, 2), + "model": CHAIRMAN_MODEL, + "success": False + } + + # Build comprehensive context for chairman + # Truncate very long responses to avoid exceeding token/context limits + # More aggressive truncation to keep total prompt under ~2000 tokens (~8000 chars) + MAX_RESPONSE_LENGTH = 2000 # Characters per response + MAX_RANKING_LENGTH = 1000 # Characters per ranking + MAX_DOCS_LENGTH = 2000 # Max characters for docs context + MAX_TOTAL_PROMPT_LENGTH = 8000 # Max total prompt length (safety limit) + + def truncate_text(text: str, max_length: int) -> str: + """Truncate text to max_length, adding ellipsis if truncated.""" + if len(text) <= max_length: + return text + return text[:max_length-3] + "..." + + # Truncate docs_text if provided + truncated_docs_text = docs_text + if docs_text and len(docs_text) > MAX_DOCS_LENGTH: + truncated_docs_text = truncate_text(docs_text, MAX_DOCS_LENGTH) + + stage1_text = "\n\n".join([ + f"Model: {result['model']}\nResponse: {truncate_text(result['response'], MAX_RESPONSE_LENGTH)}" + for result in stage1_results + ]) + + stage2_text = "\n\n".join([ + f"Model: {result['model']}\nRanking: {truncate_text(result['ranking'], MAX_RANKING_LENGTH)}" + for result in stage2_results + ]) if stage2_results else "No rankings available from Stage 2." + + chairman_prompt = f"""You are the Chairman of an LLM Council. Multiple AI models have provided responses to a user's question, and then ranked each other's responses. + +Original Question: {user_query} +{_format_docs_context(truncated_docs_text)} + +STAGE 1 - Individual Responses: +{stage1_text} + +STAGE 2 - Peer Rankings: +{stage2_text} + +Your task as Chairman is to synthesize all of this information into a single, comprehensive, accurate answer to the user's original question. Consider: +- The individual responses and their insights +- The peer rankings and what they reveal about response quality +- Any patterns of agreement or disagreement + +Provide a clear, well-reasoned final answer that represents the council's collective wisdom:""" + + # Apply final safety truncation if prompt is still too long + if len(chairman_prompt) > MAX_TOTAL_PROMPT_LENGTH: + # Truncate the prompt itself if it exceeds the limit + chairman_prompt = chairman_prompt[:MAX_TOTAL_PROMPT_LENGTH - 100] + "\n\n[Content truncated due to length limits...]\n\nProvide a clear, well-reasoned final answer:" + + messages = [{"role": "user", "content": chairman_prompt}] + + # Query the chairman model + # Note: For very long prompts, we might need to truncate or summarize + # For now, we'll try with the full prompt and handle errors gracefully + # Use the default max_tokens (2048) to stay within credit limits + # If you have more credits, you can increase MAX_TOKENS in config.py + response = await query_model(CHAIRMAN_MODEL, messages, timeout=CHAIRMAN_TIMEOUT_SECONDS) + duration = time.time() - start_time + + if response is None: + # Try to get more specific error info - check if prompt might be too long + prompt_length = len(chairman_prompt) + estimated_tokens = prompt_length // 4 # Rough estimate: ~4 chars per token + + error_msg = ( + "Error: Unable to generate final synthesis.\n\n" + "The chairman model failed to respond. Possible causes:\n" + "- Model '{}' not available on the server\n" + "- Server not running or unreachable\n" + "- Network/API errors\n" + "- Prompt too long (estimated ~{} tokens)\n" + "- Server timeout or overloaded\n\n" + "Check the backend terminal logs for the exact error message." + ).format(CHAIRMAN_MODEL, estimated_tokens) + return { + "model": CHAIRMAN_MODEL, + "response": error_msg + }, { + "duration_seconds": round(duration, 2), + "model": CHAIRMAN_MODEL, + "success": False + } + + return { + "model": CHAIRMAN_MODEL, + "response": response.get('content', '') + }, { + "duration_seconds": round(duration, 2), + "model": CHAIRMAN_MODEL, + "success": True + } + + +def parse_ranking_from_text(ranking_text: str) -> List[str]: + """ + Parse the FINAL RANKING section from the model's response. + + Args: + ranking_text: The full text response from the model + + Returns: + List of response labels in ranked order + """ + import re + + # Look for "FINAL RANKING:" section + if "FINAL RANKING:" in ranking_text: + # Extract everything after "FINAL RANKING:" + parts = ranking_text.split("FINAL RANKING:") + if len(parts) >= 2: + ranking_section = parts[1] + # Try to extract numbered list format (e.g., "1. Response A") + # This pattern looks for: number, period, optional space, "Response X" + numbered_matches = re.findall(r'\d+\.\s*Response [A-Z]', ranking_section) + if numbered_matches: + # Extract just the "Response X" part + return [re.search(r'Response [A-Z]', m).group() for m in numbered_matches] + + # Fallback: Extract all "Response X" patterns in order + matches = re.findall(r'Response [A-Z]', ranking_section) + return matches + + # Fallback: try to find any "Response X" patterns in order + matches = re.findall(r'Response [A-Z]', ranking_text) + return matches + + +def calculate_aggregate_rankings( + stage2_results: List[Dict[str, Any]], + label_to_model: Dict[str, str] +) -> List[Dict[str, Any]]: + """ + Calculate aggregate rankings across all models. + + Args: + stage2_results: Rankings from each model + label_to_model: Mapping from anonymous labels to model names + + Returns: + List of dicts with model name and average rank, sorted best to worst + """ + from collections import defaultdict + + # Track positions for each model + model_positions = defaultdict(list) + + for ranking in stage2_results: + ranking_text = ranking['ranking'] + + # Parse the ranking from the structured format + parsed_ranking = parse_ranking_from_text(ranking_text) + + for position, label in enumerate(parsed_ranking, start=1): + if label in label_to_model: + model_name = label_to_model[label] + model_positions[model_name].append(position) + + # Calculate average position for each model + aggregate = [] + for model, positions in model_positions.items(): + if positions: + avg_rank = sum(positions) / len(positions) + aggregate.append({ + "model": model, + "average_rank": round(avg_rank, 2), + "rankings_count": len(positions) + }) + + # Sort by average rank (lower is better) + aggregate.sort(key=lambda x: x['average_rank']) + + return aggregate + + +async def generate_conversation_title(user_query: str) -> str: + """ + Generate a short title for a conversation based on the first user message. + + Args: + user_query: The first user message + + Returns: + A short title (3-5 words) + """ + title_prompt = f"""Generate a very short title (3-5 words maximum) that summarizes the following question. +The title should be concise and descriptive. Do not use quotes or punctuation in the title. + +Question: {user_query} + +Title:""" + + messages = [{"role": "user", "content": title_prompt}] + + # Use chairman model for title generation + # Use configurable timeout (may need longer for local models which load on first request) + response = await query_model(CHAIRMAN_MODEL, messages, timeout=TITLE_GENERATION_TIMEOUT_SECONDS, max_tokens_override=50) + + if response is None: + # Fallback to a generic title + return "New Conversation" + + title = response.get('content', 'New Conversation').strip() + + # Clean up the title - remove quotes, limit length + title = title.strip('"\'') + + # Truncate if too long + if len(title) > 50: + title = title[:47] + "..." + + return title + + +async def run_full_council(user_query: str, docs_text: Optional[str] = None) -> Tuple[List, List, Dict, Dict]: + """ + Run the complete 3-stage council process. + + Args: + user_query: The user's question + + Returns: + Tuple of (stage1_results, stage2_results, stage3_result, metadata) + """ + total_start_time = time.time() + + # Stage 1: Collect individual responses + stage1_results, stage1_metadata = await stage1_collect_responses(user_query, docs_text=docs_text) + + # If no models responded successfully, return error with helpful message + if not stage1_results: + error_msg = ( + "All models failed to respond. This could be due to:\n" + "- Server not running or unreachable\n" + "- Model names not available on the server\n" + "- Network/API errors\n" + "- Server timeout or overloaded\n" + "- Invalid OPENAI_COMPAT_BASE_URL configuration\n\n" + "Check the backend logs for detailed error messages." + ) + total_duration = time.time() - total_start_time + return [], [], { + "model": "error", + "response": error_msg + }, { + "label_to_model": {}, + "aggregate_rankings": {}, + "stage1_metadata": stage1_metadata, + "stage2_metadata": {}, + "stage3_metadata": {}, + "total_duration_seconds": round(total_duration, 2) + } + + # Stage 2: Collect rankings + stage2_results, label_to_model, stage2_metadata = await stage2_collect_rankings(user_query, stage1_results, docs_text=docs_text) + + # Calculate aggregate rankings + aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model) + + # Stage 3: Synthesize final answer + stage3_result, stage3_metadata = await stage3_synthesize_final( + user_query, + stage1_results, + stage2_results, + docs_text=docs_text, + ) + + total_duration = time.time() - total_start_time + + # Prepare metadata + metadata = { + "label_to_model": label_to_model, + "aggregate_rankings": aggregate_rankings, + "stage1_metadata": stage1_metadata, + "stage2_metadata": stage2_metadata, + "stage3_metadata": stage3_metadata, + "total_duration_seconds": round(total_duration, 2) + } + + return stage1_results, stage2_results, stage3_result, metadata diff --git a/backend/docs_context.py b/backend/docs_context.py new file mode 100644 index 0000000..0b83c30 --- /dev/null +++ b/backend/docs_context.py @@ -0,0 +1,106 @@ +"""Helpers to load and format uploaded markdown docs as prompt context.""" + +from __future__ import annotations +import re +from typing import Optional, List + +from . import documents + + +def _normalize_filename_for_matching(filename: str) -> str: + """Normalize filename for matching @filename references.""" + # Convert to lowercase, replace spaces/underscores/hyphens with single underscore + normalized = filename.lower() + normalized = re.sub(r'[_\s\-]+', '_', normalized) + # Remove .md extension for matching + normalized = normalized.replace('.md', '') + return normalized + + +def _extract_filename_references(text: str) -> List[str]: + """Extract @filename references from text.""" + # Match @filename patterns (with or without .md extension) + pattern = r'@([a-zA-Z0-9_\s\-\+\.]+)' + matches = re.findall(pattern, text) + # Normalize each match + return [_normalize_filename_for_matching(m) for m in matches] + + +def _extract_numeric_references(text: str) -> List[int]: + """Extract numeric document references like @1, @2, @3 from text.""" + # Match @ followed by digits + pattern = r'@(\d+)' + matches = re.findall(pattern, text) + # Convert to integers (1-indexed, will be converted to 0-indexed when used) + return [int(m) for m in matches] + + +def build_docs_context( + conversation_id: str, + user_query: Optional[str] = None, + *, + max_chars: int = 8000, + max_docs: int = 5 +) -> Optional[str]: + """ + Return a single markdown string containing (truncated) docs for a conversation. + + If user_query is provided and contains references: + - @1, @2, @3 etc. (numeric): Include documents by their numbered position (1-indexed) + - @filename (text): Include documents whose filenames match (fuzzy matching) + - If both are present, numeric references take precedence + Otherwise, include all documents up to max_docs. + """ + all_metas = documents.list_documents(conversation_id) + if not all_metas: + return None + + # Check for numeric references first (e.g., @1, @2, @3) + if user_query: + numeric_refs = _extract_numeric_references(user_query) + if numeric_refs: + # Convert 1-indexed to 0-indexed and filter + filtered_metas = [] + for num in numeric_refs: + idx = num - 1 # Convert to 0-indexed + if 0 <= idx < len(all_metas): + filtered_metas.append(all_metas[idx]) + if filtered_metas: + all_metas = filtered_metas + else: + # If no numeric refs, check for filename references + refs = _extract_filename_references(user_query) + if refs: + filtered_metas = [] + for meta in all_metas: + normalized = _normalize_filename_for_matching(meta.filename) + # Check if any reference matches this filename + if any(ref in normalized or normalized in ref for ref in refs): + filtered_metas.append(meta) + if filtered_metas: + all_metas = filtered_metas + + # Limit to max_docs + metas = all_metas[:max_docs] + if not metas: + return None + + chunks = [] + remaining = max_chars + for meta in metas: + if remaining <= 0: + break + text = documents.read_document_text(conversation_id, meta.id) + header = f"\n\n---\nDOC: {meta.filename} ({meta.bytes} bytes)\n---\n" + body = text + if len(header) >= remaining: + break + remaining -= len(header) + if len(body) > remaining: + body = body[: max(0, remaining - 3)] + "..." + remaining -= len(body) + chunks.append(header + body) + + return "".join(chunks).strip() if chunks else None + + diff --git a/backend/documents.py b/backend/documents.py new file mode 100644 index 0000000..7adefcc --- /dev/null +++ b/backend/documents.py @@ -0,0 +1,103 @@ +"""Markdown document storage for conversations. + +Stores uploaded .md files on disk under data/docs//. +""" + +from __future__ import annotations + +import os +import re +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import List + +from .config import DOCS_DIR, MAX_DOC_BYTES + + +_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._ -]+") + + +def _safe_filename(name: str) -> str: + name = name.strip().replace("\\", "/").split("/")[-1] # drop any path + name = _SAFE_NAME_RE.sub("_", name) + name = name.strip(" .") + if not name: + name = "document.md" + if not name.lower().endswith(".md"): + name = f"{name}.md" + return name + + +def _conversation_dir(conversation_id: str) -> Path: + base = Path(DOCS_DIR) + return base / conversation_id + + +def ensure_docs_dir(conversation_id: str) -> Path: + d = _conversation_dir(conversation_id) + d.mkdir(parents=True, exist_ok=True) + return d + + +@dataclass(frozen=True) +class DocumentMeta: + id: str + filename: str + bytes: int + + +def save_markdown_document(conversation_id: str, filename: str, content: bytes) -> DocumentMeta: + if len(content) > MAX_DOC_BYTES: + raise ValueError(f"Document too large. Max {MAX_DOC_BYTES} bytes.") + + safe_name = _safe_filename(filename) + doc_id = str(uuid.uuid4()) + + d = ensure_docs_dir(conversation_id) + path = d / f"{doc_id}__{safe_name}" + path.write_bytes(content) + return DocumentMeta(id=doc_id, filename=safe_name, bytes=len(content)) + + +def list_documents(conversation_id: str) -> List[DocumentMeta]: + d = _conversation_dir(conversation_id) + if not d.exists(): + return [] + + out: List[DocumentMeta] = [] + for p in sorted(d.iterdir()): + if not p.is_file(): + continue + if "__" not in p.name: + continue + doc_id, fname = p.name.split("__", 1) + out.append(DocumentMeta(id=doc_id, filename=fname, bytes=p.stat().st_size)) + return out + + +def read_document_text(conversation_id: str, doc_id: str) -> str: + d = _conversation_dir(conversation_id) + if not d.exists(): + raise FileNotFoundError("Conversation docs not found") + + matches = [p for p in d.iterdir() if p.is_file() and p.name.startswith(f"{doc_id}__")] + if not matches: + raise FileNotFoundError("Document not found") + + raw = matches[0].read_bytes() + # Best-effort UTF-8; replace invalid sequences + return raw.decode("utf-8", errors="replace") + + +def delete_document(conversation_id: str, doc_id: str) -> None: + d = _conversation_dir(conversation_id) + if not d.exists(): + raise FileNotFoundError("Conversation docs not found") + + matches = [p for p in d.iterdir() if p.is_file() and p.name.startswith(f"{doc_id}__")] + if not matches: + raise FileNotFoundError("Document not found") + matches[0].unlink() + + diff --git a/backend/llm_client.py b/backend/llm_client.py new file mode 100644 index 0000000..2b4cf91 --- /dev/null +++ b/backend/llm_client.py @@ -0,0 +1,132 @@ +"""Unified LLM client. + +This module routes LLM requests to OpenAI-compatible servers (Ollama, vLLM, TGI, etc.). + +The base URL is determined by: +- If USE_LOCAL_OLLAMA=true: uses http://localhost:11434 +- Else if OPENAI_COMPAT_BASE_URL is set: uses that URL +- Else: raises an error (base URL must be configured) +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional + +from .config import MAX_TOKENS, OPENAI_COMPAT_BASE_URL, LLM_TIMEOUT_SECONDS, DEBUG + + +def _get_provider_name() -> str: + """Returns the provider name (always 'openai_compat' now).""" + return "openai_compat" + + +def _get_max_concurrency() -> int: + """ + Maximum number of in-flight model requests when calling query_models_parallel. + + - If LLM_MAX_CONCURRENCY is unset/empty/invalid: unlimited (0) + - If set to 1: strictly sequential + - If set to N>1: at most N in flight + """ + raw = (os.getenv("LLM_MAX_CONCURRENCY") or "").strip() + if not raw: + return 0 + try: + v = int(raw) + except ValueError: + return 0 + return max(0, v) + + +def get_provider_info() -> Dict[str, Any]: + """Get information about the configured provider.""" + from .config import OPENAI_COMPAT_BASE_URL + return { + "provider": "openai_compat", + "base_url": OPENAI_COMPAT_BASE_URL + } + + +async def list_models() -> Optional[List[str]]: + """List available models from the OpenAI-compatible server.""" + from .openai_compat import list_models as _list + return await _list() + + +async def query_model( + model: str, + messages: List[Dict[str, str]], + timeout: Optional[float] = None, + max_tokens_override: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + """Query a model via OpenAI-compatible API.""" + from .openai_compat import query_model as _query + + max_tokens = max_tokens_override if max_tokens_override is not None else MAX_TOKENS + resolved_timeout = timeout if timeout is not None else LLM_TIMEOUT_SECONDS + + return await _query( + model, + messages, + max_tokens=max_tokens, + timeout=resolved_timeout, + ) + + +async def query_models_parallel( + models: List[str], + messages: List[Dict[str, str]], + timeout: Optional[float] = None, + max_tokens_override: Optional[int] = None, +) -> Dict[str, Optional[Dict[str, Any]]]: + import asyncio + + resolved_timeout = timeout if timeout is not None else LLM_TIMEOUT_SECONDS + limit = _get_max_concurrency() + + # If limit is 1, run completely sequentially (one at a time, wait for each to finish) + if limit == 1: + results = {} + for model in models: + if DEBUG: + print(f"[DEBUG] Running model '{model}' sequentially (concurrency=1)") + results[model] = await query_model( + model, + messages, + timeout=resolved_timeout, + max_tokens_override=max_tokens_override, + ) + return results + + # If limit <= 0 or >= len(models), run all in parallel (no limit) + if limit <= 0 or limit >= len(models): + tasks = [ + query_model( + model, + messages, + timeout=resolved_timeout, + max_tokens_override=max_tokens_override, + ) + for model in models + ] + responses = await asyncio.gather(*tasks) + return {model: response for model, response in zip(models, responses)} + + # Otherwise, use semaphore to limit concurrency (2, 3, etc.) + sem = asyncio.Semaphore(limit) + + async def _run_one(model: str) -> Optional[Dict[str, Any]]: + async with sem: + return await query_model( + model, + messages, + timeout=resolved_timeout, + max_tokens_override=max_tokens_override, + ) + + tasks = [_run_one(model) for model in models] + responses = await asyncio.gather(*tasks) + return {model: response for model, response in zip(models, responses)} + + diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..fd869bf --- /dev/null +++ b/backend/main.py @@ -0,0 +1,579 @@ +"""FastAPI backend for LLM Council.""" + +from fastapi import FastAPI, HTTPException, UploadFile, File, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse, Response +from pydantic import BaseModel +from typing import List, Dict, Any +import uuid +import json +import asyncio +import time +from datetime import datetime + +from . import storage +from . import documents +from .config import MAX_DOC_PREVIEW_CHARS, COUNCIL_MODELS +from .docs_context import build_docs_context +from .llm_client import get_provider_info, list_models as llm_list_models, query_model, LLM_TIMEOUT_SECONDS, MAX_TOKENS +from .council import run_full_council, generate_conversation_title, stage1_collect_responses, stage2_collect_rankings, stage3_synthesize_final, calculate_aggregate_rankings, _format_docs_context + +app = FastAPI(title="LLM Council API") + +# Enable CORS for local development +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:5174", "http://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class CreateConversationRequest(BaseModel): + """Request to create a new conversation.""" + pass + + +class SendMessageRequest(BaseModel): + """Request to send a message in a conversation.""" + content: str + + +class ConversationMetadata(BaseModel): + """Conversation metadata for list view.""" + id: str + created_at: str + title: str + message_count: int + + +class Conversation(BaseModel): + """Full conversation with all messages.""" + id: str + created_at: str + title: str + messages: List[Dict[str, Any]] + + +@app.get("/") +async def root(): + """Health check endpoint.""" + return {"status": "ok", "service": "LLM Council API"} + + +@app.get("/api/llm/status") +async def llm_status(probe: bool = Query(False, description="If true, query the provider for available models")): + """ + Returns current LLM provider configuration and (optionally) probes the provider. + """ + info = get_provider_info() + if probe: + info["remote_models"] = await llm_list_models() + return info + + +@app.get("/api/conversations", response_model=List[ConversationMetadata]) +async def list_conversations(): + """List all conversations (metadata only).""" + return storage.list_conversations() + + +@app.post("/api/conversations", response_model=Conversation) +async def create_conversation(request: CreateConversationRequest): + """Create a new conversation.""" + conversation_id = str(uuid.uuid4()) + conversation = storage.create_conversation(conversation_id) + return conversation + + +@app.get("/api/conversations/{conversation_id}", response_model=Conversation) +async def get_conversation(conversation_id: str): + """Get a specific conversation with all its messages.""" + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + return conversation + + +@app.delete("/api/conversations/{conversation_id}") +async def delete_conversation(conversation_id: str): + """Delete a conversation and its associated documents.""" + try: + storage.delete_conversation(conversation_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + return {"ok": True} + + +@app.get("/api/conversations/{conversation_id}/documents") +async def list_conversation_documents(conversation_id: str): + """List uploaded markdown documents for a conversation.""" + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + docs = documents.list_documents(conversation_id) + return [{"id": d.id, "filename": d.filename, "bytes": d.bytes} for d in docs] + + +@app.post("/api/conversations/{conversation_id}/documents") +async def upload_conversation_document( + conversation_id: str, + files: List[UploadFile] = File(default=[]), + file: UploadFile = File(default=None), +): + """ + Upload one or more markdown documents (.md) for a conversation. + + Backwards compatible: + - old clients send a single "file" field + - new clients can send multiple "files" fields + """ + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + + incoming: List[UploadFile] = [] + if file is not None: + incoming.append(file) + if files: + incoming.extend(files) + + if not incoming: + raise HTTPException(status_code=400, detail="No files uploaded") + + uploaded = [] + for f in incoming: + filename = f.filename or "document.md" + if not filename.lower().endswith(".md"): + raise HTTPException(status_code=400, detail="Only .md files are supported") + content = await f.read() + try: + meta = documents.save_markdown_document(conversation_id, filename, content) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + uploaded.append({"id": meta.id, "filename": meta.filename, "bytes": meta.bytes}) + + # Back-compat: if single file uploaded, return the single object shape. + if len(uploaded) == 1: + return uploaded[0] + + return {"uploaded": uploaded} + + +@app.get("/api/conversations/{conversation_id}/documents/{doc_id}") +async def get_conversation_document(conversation_id: str, doc_id: str): + """Fetch a markdown document's text (truncated for safety).""" + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + + try: + text = documents.read_document_text(conversation_id, doc_id) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Document not found") + + if len(text) > MAX_DOC_PREVIEW_CHARS: + text = text[: MAX_DOC_PREVIEW_CHARS - 3] + "..." + return {"id": doc_id, "content": text} + + +@app.delete("/api/conversations/{conversation_id}/documents/{doc_id}") +async def delete_conversation_document(conversation_id: str, doc_id: str): + """Delete a previously uploaded document.""" + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + + try: + documents.delete_document(conversation_id, doc_id) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Document not found") + + return {"ok": True} + + +@app.patch("/api/conversations/{conversation_id}/title") +async def update_conversation_title_endpoint(conversation_id: str, request: dict): + """Update the title of a conversation.""" + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + + new_title = request.get('title', '').strip() + if not new_title: + raise HTTPException(status_code=400, detail="Title cannot be empty") + + storage.update_conversation_title(conversation_id, new_title) + return {"ok": True, "title": new_title} + + +@app.get("/api/conversations/search") +async def search_conversations(q: str = ""): + """Search conversations by title and content.""" + if not q or len(q.strip()) < 2: + return [] + + query = q.strip().lower() + all_conversations = storage.list_conversations() + results = [] + + for conv_meta in all_conversations: + # Search in title + title_match = query in (conv_meta.get('title', '') or '').lower() + + # Search in content + conv = storage.get_conversation(conv_meta['id']) + content_match = False + if conv: + for msg in conv.get('messages', []): + if query in msg.get('content', '').lower(): + content_match = True + break + + if title_match or content_match: + results.append(conv_meta) + + return results + + +@app.post("/api/conversations/{conversation_id}/message") +async def send_message(conversation_id: str, request: SendMessageRequest): + """ + Send a message and run the 3-stage council process. + Returns the complete response with all stages. + """ + # Check if conversation exists + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Check if this is the first message + is_first_message = len(conversation["messages"]) == 0 + + # Add user message + storage.add_user_message(conversation_id, request.content) + + # If this is the first message, generate a title + if is_first_message: + title = await generate_conversation_title(request.content) + storage.update_conversation_title(conversation_id, title) + + # Run the 3-stage council process + docs_text = build_docs_context(conversation_id, user_query=request.content) + stage1_results, stage2_results, stage3_result, metadata = await run_full_council( + request.content, + docs_text=docs_text, + ) + + # Add assistant message with all stages + storage.add_assistant_message( + conversation_id, + stage1_results, + stage2_results, + stage3_result, + metadata + ) + + # Return the complete response with metadata + return { + "stage1": stage1_results, + "stage2": stage2_results, + "stage3": stage3_result, + "metadata": metadata + } + + +@app.post("/api/conversations/{conversation_id}/message/stream") +async def send_message_stream(conversation_id: str, request: SendMessageRequest): + """ + Send a message and stream the 3-stage council process. + Returns Server-Sent Events as each stage completes. + """ + # Check if conversation exists + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Check if this is the first message + is_first_message = len(conversation["messages"]) == 0 + + async def event_generator(): + try: + # Add user message + storage.add_user_message(conversation_id, request.content) + + # Start title generation in parallel (don't await yet) + title_task = None + if is_first_message: + title_task = asyncio.create_task(generate_conversation_title(request.content)) + + # Load docs context once per request + docs_text = build_docs_context(conversation_id, user_query=request.content) + + # Stage 1: Collect responses - stream individual responses as they complete + yield f"data: {json.dumps({'type': 'stage1_start'})}\n\n" + + # Stream responses as they complete + from .config import COUNCIL_MODELS, OPENAI_COMPAT_BASE_URL, DEBUG + from .llm_client import query_model, LLM_TIMEOUT_SECONDS, MAX_TOKENS + from .council import _format_docs_context + + if DEBUG: + print(f"[DEBUG] Stage 1: Querying {len(COUNCIL_MODELS)} models: {COUNCIL_MODELS}") + print(f"[DEBUG] Using base URL: {OPENAI_COMPAT_BASE_URL}") + + start_time = time.time() + prompt = f"{request.content}{_format_docs_context(docs_text)}" + messages = [{"role": "user", "content": prompt}] + + stage1_results = [] + successful_models = [] + failed_models = [] + response_queue = asyncio.Queue() + + async def process_model(model: str): + try: + if DEBUG: + print(f"[DEBUG] Processing model: {model}") + response = await query_model(model, messages, timeout=LLM_TIMEOUT_SECONDS, max_tokens_override=MAX_TOKENS) + if response is not None: + result = {"model": model, "response": response.get('content', '')} + await response_queue.put(('success', model, result)) + if DEBUG: + print(f"[DEBUG] Model {model} succeeded") + else: + await response_queue.put(('failed', model, None)) + if DEBUG: + print(f"[DEBUG] Model {model} failed (returned None)") + except Exception as e: + await response_queue.put(('failed', model, None)) + if DEBUG: + print(f"[DEBUG] Model {model} exception: {e}") + + # Create tasks + tasks = [asyncio.create_task(process_model(model)) for model in COUNCIL_MODELS] + + # Process responses as they arrive + completed = 0 + while completed < len(COUNCIL_MODELS): + status, model, result = await response_queue.get() + if status == 'success': + stage1_results.append(result) + successful_models.append(model) + # Stream this response immediately + yield f"data: {json.dumps({'type': 'stage1_response', 'model': model, 'response': result})}\n\n" + else: + failed_models.append(model) + # Stream failure notification + yield f"data: {json.dumps({'type': 'stage1_response_failed', 'model': model})}\n\n" + completed += 1 + + # Wait for all tasks to complete + await asyncio.gather(*tasks, return_exceptions=True) + + duration = time.time() - start_time + stage1_metadata = { + "duration_seconds": round(duration, 2), + "successful_models": successful_models, + "failed_models": failed_models, + "total_models": len(COUNCIL_MODELS) + } + + yield f"data: {json.dumps({'type': 'stage1_complete', 'data': stage1_results, 'metadata': stage1_metadata})}\n\n" + + # Stage 2: Collect rankings + yield f"data: {json.dumps({'type': 'stage2_start'})}\n\n" + stage2_results, label_to_model, stage2_metadata = await stage2_collect_rankings(request.content, stage1_results, docs_text=docs_text) + aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model) + yield f"data: {json.dumps({'type': 'stage2_complete', 'data': stage2_results, 'metadata': {'label_to_model': label_to_model, 'aggregate_rankings': aggregate_rankings, 'stage2_metadata': stage2_metadata}})}\n\n" + + # Stage 3: Synthesize final answer + yield f"data: {json.dumps({'type': 'stage3_start'})}\n\n" + stage3_result, stage3_metadata = await stage3_synthesize_final(request.content, stage1_results, stage2_results, docs_text=docs_text) + yield f"data: {json.dumps({'type': 'stage3_complete', 'data': stage3_result, 'metadata': stage3_metadata})}\n\n" + + # Wait for title generation if it was started + if title_task: + title = await title_task + storage.update_conversation_title(conversation_id, title) + yield f"data: {json.dumps({'type': 'title_complete', 'data': {'title': title}})}\n\n" + + # Prepare metadata + metadata = { + "label_to_model": label_to_model, + "aggregate_rankings": aggregate_rankings, + "stage1_metadata": stage1_metadata, + "stage2_metadata": stage2_metadata, + "stage3_metadata": stage3_metadata + } + + # Save complete assistant message + storage.add_assistant_message( + conversation_id, + stage1_results, + stage2_results, + stage3_result, + metadata + ) + + # Send completion event + yield f"data: {json.dumps({'type': 'complete'})}\n\n" + + except Exception as e: + # Send error event with details + import traceback + from .config import DEBUG + error_msg = str(e) + if DEBUG: + error_msg += f"\n{traceback.format_exc()}" + print(f"[ERROR] Stream error: {error_msg}") + yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + } + ) + + +@app.get("/api/conversations/{conversation_id}/export") +async def export_conversation_report(conversation_id: str): + """Export conversation as a markdown report file.""" + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Get document list to map @1, @2, etc. to filenames + from . import documents + doc_list = documents.list_documents(conversation_id) + doc_map = {} # Maps @1 -> filename, @2 -> filename, etc. + for idx, doc in enumerate(doc_list, 1): + doc_map[f"@{idx}"] = doc.filename + doc_map[f"@ {idx}"] = doc.filename # Also handle @ 1 (with space) + + def replace_doc_references(text: str) -> str: + """Replace @1, @2, etc. with actual filenames.""" + import re + # Replace @1, @2, @3, etc. with filenames + for ref, filename in doc_map.items(): + # Match @1, @2, etc. (with optional space after @) + pattern = re.escape(ref) + text = re.sub(pattern, filename, text) + return text + + # Generate markdown report + lines = [] + lines.append(f"# {conversation.get('title', 'Conversation')}\n") + + # Add metadata + created_at = conversation.get('created_at', '') + if created_at: + try: + dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + lines.append(f"**Created:** {dt.strftime('%Y-%m-%d %H:%M:%S UTC')}\n") + except: + lines.append(f"**Created:** {created_at}\n") + lines.append(f"**Conversation ID:** {conversation_id}\n") + lines.append("\n---\n\n") + + # Add messages + for msg_idx, msg in enumerate(conversation.get('messages', []), 1): + if msg['role'] == 'user': + lines.append(f"## User Message {msg_idx}\n\n") + content = replace_doc_references(msg['content']) + lines.append(f"{content}\n\n") + lines.append("---\n\n") + elif msg['role'] == 'assistant': + lines.append(f"## LLM Council Response {msg_idx}\n\n") + + metadata = msg.get('metadata', {}) + + # Stage 1 + if msg.get('stage1'): + stage1_meta = metadata.get('stage1_metadata', {}) + duration = stage1_meta.get('duration_seconds', 0) + successful = len(stage1_meta.get('successful_models', [])) + total = stage1_meta.get('total_models', 0) + + lines.append("### Stage 1: Individual Responses\n\n") + if duration: + lines.append(f"*Duration: {duration}s | Successful: {successful}/{total} models*\n\n") + + for response in msg['stage1']: + lines.append(f"#### {response['model']}\n\n") + content = replace_doc_references(response['response']) + lines.append(f"{content}\n\n") + lines.append("\n---\n\n") + + # Stage 2 + if msg.get('stage2'): + stage2_meta = metadata.get('stage2_metadata', {}) + duration = stage2_meta.get('duration_seconds', 0) + successful = len(stage2_meta.get('successful_models', [])) + total = stage2_meta.get('total_models', 0) + + lines.append("### Stage 2: Peer Rankings\n\n") + if duration: + lines.append(f"*Duration: {duration}s | Successful: {successful}/{total} models*\n\n") + + for ranking in msg['stage2']: + lines.append(f"#### {ranking['model']}\n\n") + content = replace_doc_references(ranking['ranking']) + lines.append(f"{content}\n\n") + + # Add aggregate rankings if available + agg_rankings = metadata.get('aggregate_rankings', []) + if agg_rankings: + lines.append("#### Aggregate Rankings\n\n") + for item in agg_rankings: + lines.append(f"- **{item['model']}**: Average rank {item['average_rank']:.2f}\n") + lines.append("\n") + + lines.append("\n---\n\n") + + # Stage 3 + if msg.get('stage3'): + stage3_meta = metadata.get('stage3_metadata', {}) + duration = stage3_meta.get('duration_seconds', 0) + model = stage3_meta.get('model', msg['stage3'].get('model', 'Unknown')) + + lines.append("### Stage 3: Final Synthesis\n\n") + if duration: + lines.append(f"*Duration: {duration}s | Model: {model}*\n\n") + + content = replace_doc_references(msg['stage3'].get('response', '')) + lines.append(f"{content}\n\n") + + # Total duration + total_duration = metadata.get('total_duration_seconds') + if total_duration: + lines.append(f"**Total processing time:** {total_duration}s\n\n") + + lines.append("---\n\n") + + # Convert to string + content = "".join(lines) + + # Generate filename + title = conversation.get('title', 'conversation') + # Sanitize filename + safe_title = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in title) + safe_title = safe_title[:50].strip() # Limit length + filename = f"{safe_title}_{conversation_id[:8]}.md" + + return Response( + content=content, + media_type="text/markdown", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + } + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/backend/openai_compat.py b/backend/openai_compat.py new file mode 100644 index 0000000..1de27f4 --- /dev/null +++ b/backend/openai_compat.py @@ -0,0 +1,253 @@ +"""OpenAI-compatible API client (for Ollama / vLLM / TGI / OpenAI-style servers). + +This lets LLM Council talk to any OpenAI-compatible server (local Ollama, +remote Ollama, vLLM, TGI, etc.). +""" + +from __future__ import annotations + +import asyncio +import os +from typing import Any, Dict, List, Optional + +import httpx + +from .config import ( + OPENAI_COMPAT_BASE_URL, + OPENAI_COMPAT_RETRIES, + OPENAI_COMPAT_RETRY_BACKOFF_SECONDS, + OPENAI_COMPAT_TIMEOUT_SECONDS, + OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS, + OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS, + OPENAI_COMPAT_POOL_TIMEOUT_SECONDS, + DEBUG, +) + + +def _resolve_chat_completions_url(base_url: str) -> str: + """ + Accepts either: + - http://host:8000 -> http://host:8000/v1/chat/completions + - http://host:8000/v1 -> http://host:8000/v1/chat/completions + - http://host:8000/v1/ -> http://host:8000/v1/chat/completions + """ + base = base_url.rstrip("/") + if base.endswith("/v1"): + return f"{base}/chat/completions" + if "/v1/" in f"{base}/": + # Already has /v1 somewhere; assume caller gave full root including /v1 + return f"{base}/chat/completions" + return f"{base}/v1/chat/completions" + + +def _resolve_models_url(base_url: str) -> str: + base = base_url.rstrip("/") + if base.endswith("/v1"): + return f"{base}/models" + if "/v1/" in f"{base}/": + return f"{base}/models" + return f"{base}/v1/models" + + +def _resolve_ollama_tags_url(base_url: str) -> str: + """Resolve Ollama's native /api/tags endpoint URL.""" + base = base_url.rstrip("/") + return f"{base}/api/tags" + + +def _should_retry(status_code: int) -> bool: + return status_code in {408, 409, 425, 429, 500, 502, 503, 504} + + +async def query_model( + model: str, + messages: List[Dict[str, str]], + *, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + max_tokens: int = 2048, + timeout: Optional[float] = None, + client: Optional[httpx.AsyncClient] = None, +) -> Optional[Dict[str, Any]]: + """Query a model via an OpenAI-compatible chat completions endpoint.""" + resolved_base_url = base_url or OPENAI_COMPAT_BASE_URL + if not resolved_base_url: + print("Error querying OpenAI-compatible provider: OPENAI_COMPAT_BASE_URL not set") + return None + + resolved_api_key = api_key if api_key is not None else os.getenv("OPENAI_COMPAT_API_KEY") + resolved_timeout = OPENAI_COMPAT_TIMEOUT_SECONDS if timeout is None else timeout + retries = OPENAI_COMPAT_RETRIES + backoff = OPENAI_COMPAT_RETRY_BACKOFF_SECONDS + + url = _resolve_chat_completions_url(resolved_base_url) + headers = {"Content-Type": "application/json"} + if resolved_api_key: + headers["Authorization"] = f"Bearer {resolved_api_key}" + + payload: Dict[str, Any] = { + "model": model, + "messages": messages, + "max_tokens": max_tokens, + } + + if DEBUG: + print(f"[DEBUG] Querying model '{model}' at {url} (timeout={resolved_timeout}s, max_tokens={max_tokens})") + + close_client = False + try: + if client is None: + # Use explicit Timeout object to ensure read timeout is set correctly + # For LLM requests, we need a long read timeout since generation can take time + timeout_config = httpx.Timeout( + connect=OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS, + read=resolved_timeout, # Read timeout: use the configured timeout + write=OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS, + pool=OPENAI_COMPAT_POOL_TIMEOUT_SECONDS + ) + client = httpx.AsyncClient(timeout=timeout_config) + close_client = True + + attempt = 0 + while True: + if DEBUG: + print(f"[DEBUG] Attempt {attempt + 1}/{retries + 1}: POST {url}") + resp = await client.post(url, headers=headers, json=payload) + if resp.status_code != 200: + # Preserve server-provided error text for debugging. + try: + err_json = resp.json() + err_msg = err_json.get("error", {}).get("message", resp.text) + except Exception: + err_msg = resp.text + + if attempt < retries and _should_retry(resp.status_code): + await asyncio.sleep(backoff * (2**attempt)) + attempt += 1 + continue + + print(f"Error querying model {model} (HTTP {resp.status_code}): {err_msg}") + return None + + data = resp.json() + msg = data["choices"][0]["message"] + if DEBUG: + print(f"[DEBUG] Model '{model}' responded successfully") + return { + "content": msg.get("content"), + "reasoning_details": msg.get("reasoning_details"), + } + except httpx.TimeoutException as e: + print(f"[ERROR] Model '{model}' timeout after {resolved_timeout}s at {url}") + print( + f"[ERROR] This can mean the model is loading / slow, OR that the server/port is unreachable.\n" + f"[ERROR] Check connectivity: curl {resolved_base_url}/api/tags" + ) + return None + except httpx.ConnectError as e: + print(f"[ERROR] Cannot connect to {url}: {e}") + print(f"[ERROR] Is Ollama running? Check: curl {resolved_base_url}/api/tags") + return None + except Exception as e: + print(f"[ERROR] Unexpected error querying model '{model}' at {url}: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + return None + finally: + if close_client and client is not None: + await client.aclose() + + +async def list_models( + *, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + timeout: Optional[float] = None, + client: Optional[httpx.AsyncClient] = None, +) -> Optional[List[str]]: + """Return model IDs from an OpenAI-compatible server (/v1/models).""" + resolved_base_url = base_url or OPENAI_COMPAT_BASE_URL + if not resolved_base_url: + return None + + resolved_api_key = api_key if api_key is not None else os.getenv("OPENAI_COMPAT_API_KEY") + resolved_timeout = OPENAI_COMPAT_TIMEOUT_SECONDS if timeout is None else timeout + retries = OPENAI_COMPAT_RETRIES + backoff = OPENAI_COMPAT_RETRY_BACKOFF_SECONDS + + # Try OpenAI-compatible endpoint first + url = _resolve_models_url(resolved_base_url) + headers = {"Content-Type": "application/json"} + if resolved_api_key: + headers["Authorization"] = f"Bearer {resolved_api_key}" + + close_client = False + try: + if client is None: + # Use explicit Timeout object for list_models (faster operation) + timeout_config = httpx.Timeout( + connect=OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS, + read=resolved_timeout, + write=OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS, + pool=OPENAI_COMPAT_POOL_TIMEOUT_SECONDS + ) + client = httpx.AsyncClient(timeout=timeout_config) + close_client = True + + attempt = 0 + while True: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + # Try OpenAI-compatible format first + items = data.get("data", []) + if items: + ids: List[str] = [] + for it in items: + mid = it.get("id") + if mid: + ids.append(mid) + return ids + # Fallback: check if it's already in Ollama format + items = data.get("models", []) + if items: + ids: List[str] = [] + for it in items: + mid = it.get("name") or it.get("model") + if mid: + ids.append(mid) + return ids + return [] + + # If /v1/models fails, try Ollama's native /api/tags endpoint + if resp.status_code == 404 and attempt == 0: + ollama_url = _resolve_ollama_tags_url(resolved_base_url) + if DEBUG: + print(f"[DEBUG] /v1/models not found, trying Ollama native API: {ollama_url}") + resp = await client.get(ollama_url, headers=headers) + if resp.status_code == 200: + data = resp.json() + items = data.get("models", []) + if items: + ids: List[str] = [] + for it in items: + mid = it.get("name") or it.get("model") + if mid: + ids.append(mid) + return ids + + if attempt < retries and _should_retry(resp.status_code): + await asyncio.sleep(backoff * (2**attempt)) + attempt += 1 + continue + return None + except Exception as e: + if DEBUG: + msg = str(e) if str(e) else "(no message)" + print(f"[DEBUG] Error listing models: {type(e).__name__}: {msg}") + return None + finally: + if close_client and client is not None: + await client.aclose() + + diff --git a/backend/storage.py b/backend/storage.py new file mode 100644 index 0000000..76303f1 --- /dev/null +++ b/backend/storage.py @@ -0,0 +1,224 @@ +"""JSON-based storage for conversations.""" + +import json +import os +from datetime import datetime +from typing import List, Dict, Any, Optional +from pathlib import Path +from .config import DATA_DIR + + +def ensure_data_dir(): + """Ensure the data directory exists.""" + Path(DATA_DIR).mkdir(parents=True, exist_ok=True) + + +def get_conversation_path(conversation_id: str) -> str: + """Get the file path for a conversation.""" + return os.path.join(DATA_DIR, f"{conversation_id}.json") + + +def create_conversation(conversation_id: str) -> Dict[str, Any]: + """ + Create a new conversation. + + Args: + conversation_id: Unique identifier for the conversation + + Returns: + New conversation dict + """ + ensure_data_dir() + + conversation = { + "id": conversation_id, + "created_at": datetime.utcnow().isoformat(), + "title": "New Conversation", + "messages": [] + } + + # Save to file + path = get_conversation_path(conversation_id) + with open(path, 'w') as f: + json.dump(conversation, f, indent=2) + + return conversation + + +def get_conversation(conversation_id: str) -> Optional[Dict[str, Any]]: + """ + Load a conversation from storage. + + Args: + conversation_id: Unique identifier for the conversation + + Returns: + Conversation dict or None if not found + """ + path = get_conversation_path(conversation_id) + + if not os.path.exists(path): + return None + + with open(path, 'r') as f: + return json.load(f) + + +def save_conversation(conversation: Dict[str, Any]): + """ + Save a conversation to storage. + + Args: + conversation: Conversation dict to save + """ + ensure_data_dir() + + path = get_conversation_path(conversation['id']) + with open(path, 'w') as f: + json.dump(conversation, f, indent=2) + + +def list_conversations(include_archived: bool = False) -> List[Dict[str, Any]]: + """ + List all conversations (metadata only). + + Args: + include_archived: If True, include archived conversations + + Returns: + List of conversation metadata dicts + """ + ensure_data_dir() + + conversations = [] + for filename in os.listdir(DATA_DIR): + if filename.endswith('.json'): + path = os.path.join(DATA_DIR, filename) + with open(path, 'r') as f: + data = json.load(f) + # Return metadata only + conversations.append({ + "id": data["id"], + "created_at": data["created_at"], + "title": data.get("title", "New Conversation"), + "message_count": len(data["messages"]) + }) + + # Sort by creation time, newest first + conversations.sort(key=lambda x: x["created_at"], reverse=True) + + return conversations + + +def add_user_message(conversation_id: str, content: str): + """ + Add a user message to a conversation. + + Args: + conversation_id: Conversation identifier + content: User message content + """ + conversation = get_conversation(conversation_id) + if conversation is None: + raise ValueError(f"Conversation {conversation_id} not found") + + conversation["messages"].append({ + "role": "user", + "content": content + }) + + save_conversation(conversation) + + +def add_assistant_message( + conversation_id: str, + stage1: List[Dict[str, Any]], + stage2: List[Dict[str, Any]], + stage3: Dict[str, Any], + metadata: Optional[Dict[str, Any]] = None +): + """ + Add an assistant message with all 3 stages to a conversation. + + Args: + conversation_id: Conversation identifier + stage1: List of individual model responses + stage2: List of model rankings + stage3: Final synthesized response + metadata: Optional metadata dict with timing and other info + """ + conversation = get_conversation(conversation_id) + if conversation is None: + raise ValueError(f"Conversation {conversation_id} not found") + + message = { + "role": "assistant", + "stage1": stage1, + "stage2": stage2, + "stage3": stage3 + } + if metadata: + message["metadata"] = metadata + + conversation["messages"].append(message) + + save_conversation(conversation) + + +def update_conversation_title(conversation_id: str, title: str): + """ + Update the title of a conversation. + + Args: + conversation_id: Conversation identifier + title: New title for the conversation + """ + conversation = get_conversation(conversation_id) + if conversation is None: + raise ValueError(f"Conversation {conversation_id} not found") + + conversation["title"] = title + save_conversation(conversation) + + +def delete_conversation(conversation_id: str): + """ + Delete a conversation (and its associated documents). + + Args: + conversation_id: Conversation identifier + """ + path = get_conversation_path(conversation_id) + if not os.path.exists(path): + raise ValueError(f"Conversation {conversation_id} not found") + + # Delete the conversation file + os.remove(path) + + # Also delete associated documents directory + from .documents import _conversation_dir + docs_dir = _conversation_dir(conversation_id) + if docs_dir.exists(): + import shutil + shutil.rmtree(docs_dir, ignore_errors=True) + + +def archive_conversation(conversation_id: str, archived: bool = True): + """ + Archive or unarchive a conversation. + + Args: + conversation_id: Conversation identifier + archived: True to archive, False to unarchive + """ + conversation = get_conversation(conversation_id) + if conversation is None: + raise ValueError(f"Conversation {conversation_id} not found") + + conversation["archived"] = archived + if archived: + conversation["archived_at"] = datetime.utcnow().isoformat() + else: + conversation.pop("archived_at", None) + + save_conversation(conversation) diff --git a/backend/tests/test_config_env.py b/backend/tests/test_config_env.py new file mode 100644 index 0000000..f7c7344 --- /dev/null +++ b/backend/tests/test_config_env.py @@ -0,0 +1,35 @@ +import importlib +import os +import unittest + + +class TestConfigEnvOverrides(unittest.TestCase): + def setUp(self): + self._old_env = dict(os.environ) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._old_env) + + def test_council_models_override_from_env_csv(self): + os.environ["COUNCIL_MODELS"] = "a,b, c" + import backend.config as config + + importlib.reload(config) + self.assertEqual(config.COUNCIL_MODELS, ["a", "b", "c"]) + + def test_chairman_model_override(self): + os.environ["CHAIRMAN_MODEL"] = "chair" + import backend.config as config + + importlib.reload(config) + self.assertEqual(config.CHAIRMAN_MODEL, "chair") + + def test_max_tokens_override(self): + os.environ["MAX_TOKENS"] = "1234" + import backend.config as config + + importlib.reload(config) + self.assertEqual(config.MAX_TOKENS, 1234) + + diff --git a/backend/tests/test_doc_preview_truncation.py b/backend/tests/test_doc_preview_truncation.py new file mode 100644 index 0000000..1d9265b --- /dev/null +++ b/backend/tests/test_doc_preview_truncation.py @@ -0,0 +1,60 @@ +import importlib +import os +import shutil +import tempfile +import unittest + +import httpx + + +class TestDocPreviewTruncation(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self._old_env = dict(os.environ) + self.tmp = tempfile.mkdtemp(prefix="llm-council-docprev-") + os.environ["DOCS_DIR"] = self.tmp + os.environ["MAX_DOC_BYTES"] = "1000000" + os.environ["MAX_DOC_PREVIEW_CHARS"] = "10" + + import backend.config as config + import backend.documents as documents + import backend.main as main + + importlib.reload(config) + importlib.reload(documents) + self.main = importlib.reload(main) + + self.client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.main.app), + base_url="http://test", + ) + + # Create a conversation + resp = await self.client.post("/api/conversations", json={}) + resp.raise_for_status() + self.conversation_id = resp.json()["id"] + + # Upload a long doc + files = {"file": ("long.md", b"0123456789ABCDEFGHIJ", "text/markdown")} + up = await self.client.post( + f"/api/conversations/{self.conversation_id}/documents", + files=files, + ) + up.raise_for_status() + self.doc_id = up.json()["id"] + + async def asyncTearDown(self): + await self.client.aclose() + os.environ.clear() + os.environ.update(self._old_env) + shutil.rmtree(self.tmp, ignore_errors=True) + + async def test_preview_truncates(self): + resp = await self.client.get( + f"/api/conversations/{self.conversation_id}/documents/{self.doc_id}" + ) + self.assertEqual(resp.status_code, 200) + content = resp.json()["content"] + self.assertEqual(len(content), 10) + self.assertTrue(content.endswith("...")) + + diff --git a/backend/tests/test_docs_api.py b/backend/tests/test_docs_api.py new file mode 100644 index 0000000..a51676a --- /dev/null +++ b/backend/tests/test_docs_api.py @@ -0,0 +1,110 @@ +import os +import shutil +import tempfile +import unittest +import importlib + +import httpx + + +class TestDocumentsApi(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self._old_env = dict(os.environ) + self.tmp = tempfile.mkdtemp(prefix="llm-council-docsapi-") + os.environ["DOCS_DIR"] = self.tmp + os.environ["MAX_DOC_BYTES"] = "1000000" + + # Reload config/documents so they see DOCS_DIR override + import backend.config as config + import backend.documents as documents + import backend.main as main + + importlib.reload(config) + importlib.reload(documents) + self.main = importlib.reload(main) + + self.transport = httpx.ASGITransport(app=self.main.app) + self.client = httpx.AsyncClient(transport=self.transport, base_url="http://test") + + # Create a conversation + resp = await self.client.post("/api/conversations", json={}) + resp.raise_for_status() + self.conversation_id = resp.json()["id"] + + async def asyncTearDown(self): + await self.client.aclose() + os.environ.clear() + os.environ.update(self._old_env) + shutil.rmtree(self.tmp, ignore_errors=True) + + async def test_upload_and_list_documents(self): + # Upload + files = {"file": ("notes.md", b"# Hi\n", "text/markdown")} + resp = await self.client.post( + f"/api/conversations/{self.conversation_id}/documents", + files=files, + ) + self.assertEqual(resp.status_code, 200, resp.text) + meta = resp.json() + self.assertIn("id", meta) + self.assertEqual(meta["filename"], "notes.md") + + # List + resp2 = await self.client.get( + f"/api/conversations/{self.conversation_id}/documents" + ) + self.assertEqual(resp2.status_code, 200, resp2.text) + items = resp2.json() + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["id"], meta["id"]) + + async def test_upload_multiple_documents(self): + files = [ + ("files", ("a.md", b"one", "text/markdown")), + ("files", ("b.md", b"two", "text/markdown")), + ] + resp = await self.client.post( + f"/api/conversations/{self.conversation_id}/documents", + files=files, + ) + self.assertEqual(resp.status_code, 200, resp.text) + payload = resp.json() + self.assertIn("uploaded", payload) + self.assertEqual(len(payload["uploaded"]), 2) + self.assertEqual({d["filename"] for d in payload["uploaded"]}, {"a.md", "b.md"}) + + async def test_rejects_non_md(self): + files = {"file": ("notes.txt", b"hello", "text/plain")} + resp = await self.client.post( + f"/api/conversations/{self.conversation_id}/documents", + files=files, + ) + self.assertEqual(resp.status_code, 400) + + async def test_get_and_delete_document(self): + files = {"file": ("a.md", b"hello", "text/markdown")} + up = await self.client.post( + f"/api/conversations/{self.conversation_id}/documents", + files=files, + ) + self.assertEqual(up.status_code, 200) + doc_id = up.json()["id"] + + get = await self.client.get( + f"/api/conversations/{self.conversation_id}/documents/{doc_id}" + ) + self.assertEqual(get.status_code, 200) + self.assertEqual(get.json()["content"], "hello") + + dele = await self.client.delete( + f"/api/conversations/{self.conversation_id}/documents/{doc_id}" + ) + self.assertEqual(dele.status_code, 200) + self.assertTrue(dele.json()["ok"]) + + get2 = await self.client.get( + f"/api/conversations/{self.conversation_id}/documents/{doc_id}" + ) + self.assertEqual(get2.status_code, 404) + + diff --git a/backend/tests/test_docs_context.py b/backend/tests/test_docs_context.py new file mode 100644 index 0000000..e14e81c --- /dev/null +++ b/backend/tests/test_docs_context.py @@ -0,0 +1,38 @@ +import os +import shutil +import tempfile +import unittest +import importlib + + +class TestDocsContext(unittest.TestCase): + def setUp(self): + self._old_env = dict(os.environ) + self.tmp = tempfile.mkdtemp(prefix="llm-council-docsctx-") + os.environ["DOCS_DIR"] = self.tmp + os.environ["MAX_DOC_BYTES"] = "1000000" + + import backend.config as config + import backend.documents as documents + import backend.docs_context as docs_context + + self.config = importlib.reload(config) + self.documents = importlib.reload(documents) + self.docs_context = importlib.reload(docs_context) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._old_env) + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_build_docs_context_truncates(self): + conv = "c1" + self.documents.save_markdown_document(conv, "a.md", b"A" * 50) + self.documents.save_markdown_document(conv, "b.md", b"B" * 50) + + ctx = self.docs_context.build_docs_context(conv, max_chars=60, max_docs=5) + self.assertIsNotNone(ctx) + self.assertIn("DOC:", ctx) + self.assertTrue(len(ctx) <= 60) + + diff --git a/backend/tests/test_documents.py b/backend/tests/test_documents.py new file mode 100644 index 0000000..c14f142 --- /dev/null +++ b/backend/tests/test_documents.py @@ -0,0 +1,48 @@ +import os +import shutil +import tempfile +import unittest +import importlib + + +class TestDocumentsStorage(unittest.TestCase): + def setUp(self): + self._old_env = dict(os.environ) + self.tmp = tempfile.mkdtemp(prefix="llm-council-docs-") + os.environ["DOCS_DIR"] = self.tmp + os.environ["MAX_DOC_BYTES"] = "100" + + import backend.config as config + import backend.documents as documents + + self.config = importlib.reload(config) + self.documents = importlib.reload(documents) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._old_env) + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_save_and_list_document(self): + meta = self.documents.save_markdown_document( + "conv1", + "../weird/name.md", + b"# Hello\n", + ) + self.assertTrue(meta.id) + self.assertEqual(meta.filename, "name.md") + self.assertEqual(meta.bytes, 8) + + listed = self.documents.list_documents("conv1") + self.assertEqual(len(listed), 1) + self.assertEqual(listed[0].id, meta.id) + self.assertEqual(listed[0].filename, "name.md") + + text = self.documents.read_document_text("conv1", meta.id) + self.assertIn("# Hello", text) + + def test_rejects_too_large(self): + with self.assertRaises(ValueError): + self.documents.save_markdown_document("conv1", "a.md", b"x" * 101) + + diff --git a/backend/tests/test_llm_client.py b/backend/tests/test_llm_client.py new file mode 100644 index 0000000..e1654dc --- /dev/null +++ b/backend/tests/test_llm_client.py @@ -0,0 +1,65 @@ +import os +import unittest + + +class TestProviderSelection(unittest.TestCase): + def setUp(self): + self._old_env = dict(os.environ) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._old_env) + + def test_always_returns_openai_compat(self): + """Provider is always 'openai_compat' now (OpenRouter removed).""" + from backend.llm_client import _get_provider_name + + # Should always return openai_compat regardless of env vars + self.assertEqual(_get_provider_name(), "openai_compat") + + # Test with different env var combinations + os.environ["OPENAI_COMPAT_BASE_URL"] = "http://gpu:8000" + self.assertEqual(_get_provider_name(), "openai_compat") + + os.environ.pop("OPENAI_COMPAT_BASE_URL", None) + self.assertEqual(_get_provider_name(), "openai_compat") + + +class TestParallelConcurrency(unittest.IsolatedAsyncioTestCase): + async def test_query_models_parallel_respects_llm_max_concurrency(self): + import asyncio + import backend.llm_client as lc + + old_env = dict(os.environ) + old_query_model = lc.query_model + + in_flight = 0 + max_in_flight = 0 + lock = asyncio.Lock() + + async def fake_query_model(model, messages, timeout=120.0, max_tokens_override=None): + nonlocal in_flight, max_in_flight + async with lock: + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + # ensure overlap is possible without the semaphore + await asyncio.sleep(0.02) + async with lock: + in_flight -= 1 + return {"content": model} + + try: + os.environ["LLM_MAX_CONCURRENCY"] = "1" + lc.query_model = fake_query_model + + models = ["m1", "m2", "m3"] + out = await lc.query_models_parallel(models, [{"role": "user", "content": "hi"}]) + + self.assertEqual(set(out.keys()), set(models)) + self.assertEqual(max_in_flight, 1) + finally: + lc.query_model = old_query_model + os.environ.clear() + os.environ.update(old_env) + + diff --git a/backend/tests/test_llm_status.py b/backend/tests/test_llm_status.py new file mode 100644 index 0000000..719143a --- /dev/null +++ b/backend/tests/test_llm_status.py @@ -0,0 +1,36 @@ +import importlib +import os +import unittest + +import httpx + + +class TestLlmStatusEndpoint(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self._old_env = dict(os.environ) + os.environ["OPENAI_COMPAT_BASE_URL"] = "http://localhost:11434" + os.environ.pop("USE_LOCAL_OLLAMA", None) # Clear this so OPENAI_COMPAT_BASE_URL is used + + import backend.config as config + import backend.main as main + + importlib.reload(config) # Reload config to pick up env changes + self.main = importlib.reload(main) + self.client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.main.app), + base_url="http://test", + ) + + async def asyncTearDown(self): + await self.client.aclose() + os.environ.clear() + os.environ.update(self._old_env) + + async def test_status_without_probe(self): + resp = await self.client.get("/api/llm/status") + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(data["provider"], "openai_compat") + self.assertEqual(data["base_url"], "http://localhost:11434") + + diff --git a/backend/tests/test_openai_compat.py b/backend/tests/test_openai_compat.py new file mode 100644 index 0000000..72ff6cf --- /dev/null +++ b/backend/tests/test_openai_compat.py @@ -0,0 +1,87 @@ +import unittest + +import httpx +import json + +from backend.openai_compat import _resolve_chat_completions_url, _resolve_models_url, query_model, list_models + + +class TestOpenAICompatUrl(unittest.TestCase): + def test_resolve_url_when_no_v1(self): + self.assertEqual( + _resolve_chat_completions_url("http://gpu:8000"), + "http://gpu:8000/v1/chat/completions", + ) + + def test_resolve_url_when_v1(self): + self.assertEqual( + _resolve_chat_completions_url("http://gpu:8000/v1"), + "http://gpu:8000/v1/chat/completions", + ) + + def test_resolve_url_when_v1_with_trailing_slash(self): + self.assertEqual( + _resolve_chat_completions_url("http://gpu:8000/v1/"), + "http://gpu:8000/v1/chat/completions", + ) + + def test_resolve_models_url(self): + self.assertEqual( + _resolve_models_url("http://gpu:8000"), + "http://gpu:8000/v1/models", + ) + + +class TestOpenAICompatRequest(unittest.IsolatedAsyncioTestCase): + async def test_query_model_builds_payload_and_parses_response(self): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["auth"] = request.headers.get("authorization") + captured["json"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + json={ + "choices": [ + { + "message": {"content": "hello", "reasoning_details": None}, + } + ] + }, + ) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport, timeout=10.0) as client: + out = await query_model( + "my-model", + [{"role": "user", "content": "hi"}], + base_url="http://gpu:8000", + api_key="secret", + max_tokens=123, + timeout=10.0, + client=client, + ) + + self.assertEqual(captured["url"], "http://gpu:8000/v1/chat/completions") + self.assertEqual(captured["auth"], "Bearer secret") + self.assertEqual(captured["json"]["model"], "my-model") + self.assertEqual(captured["json"]["max_tokens"], 123) + self.assertEqual(out["content"], "hello") + + async def test_list_models_parses_ids(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"data": [{"id": "a"}, {"id": "b"}, {"nope": "c"}]}, + ) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport, timeout=10.0) as client: + ids = await list_models( + base_url="http://gpu:8000", + client=client, + ) + self.assertEqual(ids, ["a", "b"]) + + diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..4db9122 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,278 @@ +# Deployment Guide + +## Overview + +LLM Council can be deployed in several configurations depending on your needs: +- **Local Development**: Everything runs on your local machine +- **Hybrid**: Frontend/Backend local, LLM server on remote GPU VM +- **Full Remote**: Everything on a server/VM +- **Production**: Professional deployment with proper infrastructure + +## Architecture Options + +### Option 1: Hybrid (Recommended for Development) + +**Setup:** +- Frontend + Backend: Run on your local machine +- LLM Server (Ollama): Run on remote GPU VM + +**Pros:** +- Easy development and debugging +- GPU resources available remotely +- No need to deploy frontend/backend code +- Fast iteration + +**Cons:** +- Requires network connectivity to GPU VM +- Latency for LLM requests + +**Configuration:** +```bash +# .env on local machine +USE_LOCAL_OLLAMA=false +OPENAI_COMPAT_BASE_URL=http://your-gpu-vm-ip:11434 +``` + +### Option 2: Full Remote Deployment + +**Setup:** +- Everything runs on the GPU VM or dedicated server + +**Pros:** +- Centralized deployment +- Can be accessed from multiple machines +- Better for team use + +**Cons:** +- More complex setup +- Requires proper security configuration +- Slower development iteration + +### Option 3: Production Deployment (Professional) + +**Recommended Stack:** +- **Frontend**: Serve static build via nginx/CDN +- **Backend**: Run via systemd/gunicorn/uvicorn with reverse proxy +- **LLM Server**: Separate service on GPU VM +- **Security**: TLS/HTTPS, authentication, rate limiting + +## GPU VM Setup + +### Prerequisites + +1. GPU VM with: + - NVIDIA GPU with CUDA support + - Sufficient VRAM for your models + - Network access from your local machine + +2. Ollama installed on GPU VM: + ```bash + curl -fsSL https://ollama.ai/install.sh | sh + ``` + +### Step 1: Configure Ollama to Accept Remote Connections + +**On GPU VM:** + +```bash +# Option A: Environment variable (temporary) +export OLLAMA_HOST=0.0.0.0:11434 + +# Option B: Systemd service (persistent - recommended) +sudo systemctl edit ollama +``` + +Add to the override file: +```ini +[Service] +Environment="OLLAMA_HOST=0.0.0.0:11434" +Environment="OLLAMA_KEEP_ALIVE=24h" +Environment="OLLAMA_MAX_LOADED_MODELS=3" +``` + +Then restart: +```bash +sudo systemctl daemon-reload +sudo systemctl restart ollama +``` + +### Step 2: Configure Firewall + +**On GPU VM:** + +```bash +# Allow port 11434 from your local network +sudo ufw allow from YOUR_LOCAL_IP to any port 11434 +# Or allow from entire subnet (less secure) +sudo ufw allow 11434/tcp +``` + +### Step 3: Pull Required Models + +**On GPU VM:** + +```bash +ollama pull qwen2.5:7b +ollama pull llama3.1:8b +ollama pull qwen2.5:14b +ollama pull qwen2:latest +``` + +### Step 3.5 (GPU VM): Ensure Ollama Uses GPU + Stores Data on /mnt/data + +If your VM has a small root disk, keep Ollama's storage and HOME off `/` (common cause of weird failures). +Also note that on this setup, `OLLAMA_LLM_LIBRARY=cuda` caused Ollama to *skip* CUDA libraries; use `cuda_v12`. + +**On GPU VM:** + +```bash +sudo mkdir -p /etc/systemd/system/ollama.service.d +sudo tee /etc/systemd/system/ollama.service.d/override.conf >/dev/null <<'EOF' +[Service] +Environment="OLLAMA_HOST=0.0.0.0:11434" +Environment="OLLAMA_KEEP_ALIVE=24h" +Environment="OLLAMA_MODELS=/mnt/data/ollama" +Environment="HOME=/mnt/data/ollama/home" +Environment="OLLAMA_LLM_LIBRARY=cuda_v12" +Environment="LD_LIBRARY_PATH=/usr/local/lib/ollama:/usr/local/lib/ollama/cuda_v12" +EOF + +sudo systemctl daemon-reload +sudo systemctl restart ollama +``` + +**Verify GPU offload (on GPU VM):** + +```bash +ollama run qwen2:latest "Write 80 words about GPUs." +ollama ps +``` + +### Step 4: Verify Remote Access + +**From local machine:** + +```bash +curl http://YOUR_GPU_VM_IP:11434/api/tags +# Should return list of available models +curl http://YOUR_GPU_VM_IP:11434/v1/models +``` + +### Step 5: Configure LLM Council + +**On local machine `.env`:** + +```bash +USE_LOCAL_OLLAMA=false +OPENAI_COMPAT_BASE_URL=http://YOUR_GPU_VM_IP:11434 +# Local (small) example: +# COUNCIL_MODELS=llama3.2:1b,qwen2.5:0.5b,gemma2:2b +# CHAIRMAN_MODEL=llama3.2:3b + +# GPU (available models): +COUNCIL_MODELS=qwen2.5:7b,llama3.1:8b,qwen2:latest +CHAIRMAN_MODEL=qwen2.5:14b +``` + +## Security Considerations + +### For Development/Internal Use + +1. **Network Security:** + - Use VPN or private network + - Restrict firewall to specific IPs + - Consider SSH tunnel for extra security + +2. **Ollama Security:** + - Ollama has no built-in authentication + - Only expose on trusted networks + - Consider reverse proxy with auth (nginx + basic auth) + +### For Production + +1. **Authentication:** + - Add API key authentication to backend + - Use session-based auth for frontend + - Implement rate limiting + +2. **Network Security:** + - Use HTTPS/TLS everywhere + - Set up proper firewall rules + - Consider using a reverse proxy (nginx/traefik) + +3. **Infrastructure:** + - Use container orchestration (Docker Compose/Kubernetes) + - Set up monitoring and logging + - Implement backup strategy for conversations + +## Deployment Scripts + +### Quick Start (Local + Remote Ollama) + +```bash +# 1. Start Ollama on GPU VM (already running if systemd configured) +# 2. On local machine: +./start.sh +``` + +### Full Remote Deployment + +See `docs/DEPLOYMENT_FULL.md` for complete remote deployment instructions. + +## Troubleshooting + +### Connection Timeouts + +1. Check Ollama is listening on all interfaces: + ```bash + # On GPU VM + sudo netstat -tlnp | grep 11434 + # Should show 0.0.0.0:11434, not 127.0.0.1:11434 + ``` + +2. Check firewall rules: + ```bash + # On GPU VM + sudo ufw status + ``` + +3. Test connectivity: + ```bash + # From local machine + curl -v http://GPU_VM_IP:11434/api/tags + ``` + +### Model Loading Issues + +1. Check available VRAM: + ```bash + nvidia-smi + ``` + +2. Adjust `OLLAMA_MAX_LOADED_MODELS` if needed + +3. Check model sizes vs available memory + +## Performance Tuning + +### Ollama Settings + +```bash +# On GPU VM, edit systemd override: +Environment="OLLAMA_KEEP_ALIVE=24h" # Keep models loaded +Environment="OLLAMA_MAX_LOADED_MODELS=3" # Max concurrent models +Environment="OLLAMA_NUM_PARALLEL=1" # Parallel requests +``` + +### LLM Council Timeouts + +Adjust in `.env`: +```bash +LLM_TIMEOUT_SECONDS=600.0 # For slow models +CHAIRMAN_TIMEOUT_SECONDS=600.0 +OPENAI_COMPAT_TIMEOUT_SECONDS=600.0 +OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS=30.0 +OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS=30.0 +OPENAI_COMPAT_POOL_TIMEOUT_SECONDS=30.0 +``` + diff --git a/docs/DEPLOYMENT_RECOMMENDATIONS.md b/docs/DEPLOYMENT_RECOMMENDATIONS.md new file mode 100644 index 0000000..cecab56 --- /dev/null +++ b/docs/DEPLOYMENT_RECOMMENDATIONS.md @@ -0,0 +1,178 @@ +# Professional Deployment Recommendations + +## Recommended Architecture + +### For Development/Personal Use + +**Hybrid Approach (Recommended):** +``` +┌─────────────────┐ ┌──────────────────┐ +│ Local Machine │ │ GPU VM │ +│ │ │ │ +│ Frontend │ │ Ollama Server │ +│ (React/Vite) │ │ (LLM Models) │ +│ │◄────────┤ │ +│ Backend │ HTTP │ Port 11434 │ +│ (FastAPI) │ │ │ +└─────────────────┘ └──────────────────┘ +``` + +**Why this is best:** +- ✅ Fast development iteration +- ✅ Easy debugging (logs on local machine) +- ✅ GPU resources available remotely +- ✅ No complex deployment needed +- ✅ Can work offline (if models cached locally) + +### For Production/Team Use + +**Full Remote Deployment:** +``` +┌─────────────────┐ +│ Users │ +│ (Browsers) │ +└────────┬────────┘ + │ HTTPS + ▼ +┌─────────────────┐ +│ Reverse Proxy │ +│ (nginx/traefik)│ +└────────┬────────┘ + │ + ┌────┴────┐ + │ │ + ▼ ▼ +┌────────┐ ┌──────────┐ +│Frontend│ │ Backend │ +│(Static)│ │(FastAPI) │ +└────────┘ └────┬─────┘ + │ HTTP + ▼ + ┌──────────────┐ + │ GPU VM │ + │ Ollama │ + └──────────────┘ +``` + +## Comparison of Approaches + +| Aspect | Hybrid (Local + Remote) | Full Remote | Production | +|--------|------------------------|-------------|------------| +| **Setup Complexity** | Low | Medium | High | +| **Development Speed** | Fast | Medium | Slow | +| **Security** | Medium | Medium | High | +| **Scalability** | Low | Medium | High | +| **Cost** | Low | Medium | High | +| **Best For** | Dev/Personal | Team/Internal | Public/Enterprise | + +## Security Best Practices + +### Development/Internal Network + +1. **Network Isolation:** + - Use private network/VPN + - Restrict firewall to specific IPs + - Consider SSH tunnel for Ollama + +2. **Ollama Access:** + ```bash + # Only allow from specific IPs + sudo ufw allow from YOUR_IP to any port 11434 + ``` + +3. **SSH Tunnel Alternative:** + ```bash + # More secure - no direct network exposure + ssh -L 11434:localhost:11434 user@gpu-vm + # Then use localhost:11434 in .env + ``` + +### Production Deployment + +1. **Authentication:** + - Add API keys to backend + - Implement user sessions + - Use OAuth2/JWT for API + +2. **Network Security:** + - HTTPS/TLS everywhere + - WAF (Web Application Firewall) + - Rate limiting + - DDoS protection + +3. **Infrastructure:** + - Container orchestration (Docker/K8s) + - Service mesh for internal communication + - Monitoring and alerting + - Automated backups + +## Deployment Checklist + +### Hybrid Setup (Recommended for Dev) + +- [ ] GPU VM has Ollama installed +- [ ] Ollama configured to listen on 0.0.0.0:11434 +- [ ] Firewall allows connections from local machine +- [ ] Models pulled on GPU VM +- [ ] Local `.env` configured with GPU VM IP +- [ ] Test connection: `curl http://GPU_VM_IP:11434/api/tags` + +### Full Remote Setup + +- [ ] Server/VM provisioned +- [ ] Frontend built and served (nginx/static host) +- [ ] Backend running as service (systemd/supervisor) +- [ ] Reverse proxy configured (nginx/traefik) +- [ ] SSL certificates installed +- [ ] Authentication implemented +- [ ] Monitoring set up +- [ ] Backup strategy in place + +### Production Setup + +- [ ] All of Full Remote checklist +- [ ] Load balancing configured +- [ ] Database for conversations (optional upgrade) +- [ ] Logging and monitoring (Prometheus/Grafana) +- [ ] CI/CD pipeline +- [ ] Security audit +- [ ] Documentation for ops team +- [ ] Disaster recovery plan + +## Cost Considerations + +### Hybrid (Local + Remote GPU VM) +- **Cost**: GPU VM only (~$0.50-2/hour depending on GPU) +- **Best for**: Development, personal projects, small teams + +### Full Remote +- **Cost**: GPU VM + Application Server (~$1-3/hour) +- **Best for**: Teams, internal tools + +### Production +- **Cost**: $100-1000+/month depending on scale +- **Best for**: Public services, enterprise + +## Migration Path + +1. **Start**: Hybrid (local dev, remote GPU) +2. **Grow**: Full remote (when team needs it) +3. **Scale**: Production (when going public/enterprise) + +## Recommendation + +**For your use case (development/personal):** + +Use the **Hybrid approach**: +- Run frontend + backend locally +- Connect to Ollama on GPU VM +- Use SSH tunnel for extra security if needed +- Simple, fast, cost-effective + +This gives you: +- Fast development iteration +- Easy debugging +- GPU resources when needed +- Minimal infrastructure complexity +- Low cost + diff --git a/docs/GPU_VM_SETUP.md b/docs/GPU_VM_SETUP.md new file mode 100644 index 0000000..f7cbe77 --- /dev/null +++ b/docs/GPU_VM_SETUP.md @@ -0,0 +1,93 @@ +# GPU VM Setup - Quick Reference + +## Quick Setup Steps + +### 1. On GPU VM: Configure Ollama to Accept Remote Connections + +```bash +# Create systemd override +sudo mkdir -p /etc/systemd/system/ollama.service.d +sudo tee /etc/systemd/system/ollama.service.d/override.conf > /dev/null < + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9c639dd --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3649 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "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==", + "dev": true + }, + "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, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, + "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==" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, + "node_modules/@types/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", + "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.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, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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 + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", + "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "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, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001756", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", + "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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, + "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 + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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 + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "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, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.259", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", + "integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==", + "dev": true + }, + "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, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "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 + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "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 + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "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, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "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.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "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, + "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, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "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, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fe9f749 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "test": "node --test", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "^5.4.11" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b5e6ae6 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,16 @@ +* { + box-sizing: border-box; +} + +.app { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; + background: var(--bg-primary); + color: var(--text-primary); + transition: background-color 0.2s, color 0.2s; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..6a721c9 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,462 @@ +import { useState, useEffect } from 'react'; +import Sidebar from './components/Sidebar'; +import ChatInterface from './components/ChatInterface'; +import { api } from './api'; +import './App.css'; + +function App() { + // Theme state - default to light, or load from localStorage + const [theme, setTheme] = useState(() => { + const saved = localStorage.getItem('llm-council-theme'); + if (saved) return saved; + const envDefault = (import.meta.env.VITE_DEFAULT_THEME || '').toLowerCase(); + if (envDefault === 'dark' || envDefault === 'light') return envDefault; + return 'light'; + }); + const [conversations, setConversations] = useState([]); + const [currentConversationId, setCurrentConversationId] = useState(null); + const [currentConversation, setCurrentConversation] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [documents, setDocuments] = useState([]); + const [isUploadingDoc, setIsUploadingDoc] = useState(false); + const [selectedDoc, setSelectedDoc] = useState(null); // {id, filename, bytes} + const [selectedDocContent, setSelectedDocContent] = useState(''); + const [isLoadingDoc, setIsLoadingDoc] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [filteredConversations, setFilteredConversations] = useState([]); + const [prefillMessage, setPrefillMessage] = useState(''); + + // Apply theme class to document root + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('llm-council-theme', theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(prev => prev === 'light' ? 'dark' : 'light'); + }; + + // Load conversations on mount + useEffect(() => { + loadConversations(); + }, []); + + // Read message from URL once (used to prefill the textarea; not auto-sent) + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const msg = urlParams.get('message'); + if (msg) setPrefillMessage(msg); + }, []); + + // Check for conversation ID in URL after conversations load + useEffect(() => { + if (conversations.length > 0) { + const urlParams = new URLSearchParams(window.location.search); + const convIdFromUrl = urlParams.get('conversation'); + if (convIdFromUrl && !currentConversationId) { + const exists = conversations.some(c => c.id === convIdFromUrl); + if (exists) { + setCurrentConversationId(convIdFromUrl); + } else { + console.warn('Conversation from URL not found:', convIdFromUrl); + alert(`Conversation not found: ${convIdFromUrl}\n\nIt may have been deleted, or you're pointing at a different data directory/server.`); + // Remove the invalid param so we don't keep trying. + urlParams.delete('conversation'); + const newQuery = urlParams.toString(); + const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}${window.location.hash || ''}`; + window.history.replaceState({}, '', newUrl); + } + } + } + }, [conversations, currentConversationId]); + + // Filter conversations based on search + useEffect(() => { + if (!searchQuery.trim()) { + setFilteredConversations(conversations); + return; + } + + const search = async () => { + try { + const results = await api.searchConversations(searchQuery); + setFilteredConversations(results); + } catch (error) { + console.error('Search failed:', error); + // Fallback to client-side search + const query = searchQuery.toLowerCase(); + const filtered = conversations.filter(conv => + (conv.title || '').toLowerCase().includes(query) + ); + setFilteredConversations(filtered); + } + }; + + const timeoutId = setTimeout(search, 300); // Debounce + return () => clearTimeout(timeoutId); + }, [searchQuery, conversations]); + + // Load conversation details when selected + useEffect(() => { + if (currentConversationId) { + loadConversation(currentConversationId); + loadDocuments(currentConversationId); + } + }, [currentConversationId]); + + const loadConversations = async () => { + try { + const convs = await api.listConversations(); + setConversations(convs); + } catch (error) { + console.error('Failed to load conversations:', error); + } + }; + + const loadConversation = async (id) => { + try { + const conv = await api.getConversation(id); + setCurrentConversation(conv); + } catch (error) { + console.error('Failed to load conversation:', error); + } + }; + + const loadDocuments = async (id) => { + try { + const docs = await api.listDocuments(id); + setDocuments(docs); + // Clear selection if it no longer exists + setSelectedDoc((prev) => { + if (!prev) return prev; + return docs.some((d) => d.id === prev.id) ? prev : null; + }); + } catch (error) { + console.error('Failed to load documents:', error); + setDocuments([]); + } + }; + + const handleNewConversation = async () => { + try { + const newConv = await api.createConversation(); + setConversations([ + { id: newConv.id, created_at: newConv.created_at, message_count: 0 }, + ...conversations, + ]); + setCurrentConversationId(newConv.id); + } catch (error) { + console.error('Failed to create conversation:', error); + } + }; + + const handleSelectConversation = (id) => { + setCurrentConversationId(id); + }; + + const handleSendMessage = async (content) => { + if (!currentConversationId) return; + + setIsLoading(true); + try { + // Optimistically add user message to UI + const userMessage = { role: 'user', content }; + setCurrentConversation((prev) => ({ + ...prev, + messages: [...prev.messages, userMessage], + })); + + // Create a partial assistant message that will be updated progressively + const assistantMessage = { + role: 'assistant', + stage1: null, + stage2: null, + stage3: null, + metadata: null, + loading: { + stage1: false, + stage2: false, + stage3: false, + }, + }; + + // Add the partial assistant message + setCurrentConversation((prev) => ({ + ...prev, + messages: [...prev.messages, assistantMessage], + })); + + // Send message with streaming + await api.sendMessageStream(currentConversationId, content, (eventType, event) => { + console.log('[Stream Event]', eventType, event); // Debug logging + switch (eventType) { + case 'stage1_start': + setCurrentConversation((prev) => { + const messages = [...prev.messages]; + const lastMsg = messages[messages.length - 1]; + lastMsg.loading.stage1 = true; + lastMsg.stage1 = []; // Initialize empty array + return { ...prev, messages }; + }); + break; + + case 'stage1_response': + // Individual response completed - add it immediately + setCurrentConversation((prev) => { + const messages = [...prev.messages]; + const lastMsg = messages[messages.length - 1]; + if (!lastMsg.stage1) lastMsg.stage1 = []; + // Check if this model already exists, if so update it, otherwise add it + const existingIdx = lastMsg.stage1.findIndex(r => r.model === event.model); + if (existingIdx >= 0) { + lastMsg.stage1[existingIdx] = event.response; + } else { + lastMsg.stage1.push(event.response); + } + return { ...prev, messages }; + }); + break; + + case 'stage1_complete': + setCurrentConversation((prev) => { + const messages = [...prev.messages]; + const lastMsg = messages[messages.length - 1]; + lastMsg.stage1 = event.data; + if (!lastMsg.metadata) lastMsg.metadata = {}; + lastMsg.metadata.stage1_metadata = event.metadata; + lastMsg.loading.stage1 = false; + return { ...prev, messages }; + }); + break; + + case 'stage2_start': + setCurrentConversation((prev) => { + const messages = [...prev.messages]; + const lastMsg = messages[messages.length - 1]; + lastMsg.loading.stage2 = true; + return { ...prev, messages }; + }); + break; + + case 'stage2_complete': + setCurrentConversation((prev) => { + const messages = [...prev.messages]; + const lastMsg = messages[messages.length - 1]; + lastMsg.stage2 = event.data; + if (!lastMsg.metadata) lastMsg.metadata = {}; + lastMsg.metadata = { ...lastMsg.metadata, ...event.metadata }; + lastMsg.metadata.stage2_metadata = event.metadata.stage2_metadata; + lastMsg.loading.stage2 = false; + return { ...prev, messages }; + }); + break; + + case 'stage3_start': + setCurrentConversation((prev) => { + const messages = [...prev.messages]; + const lastMsg = messages[messages.length - 1]; + lastMsg.loading.stage3 = true; + return { ...prev, messages }; + }); + break; + + case 'stage3_complete': + setCurrentConversation((prev) => { + const messages = [...prev.messages]; + const lastMsg = messages[messages.length - 1]; + lastMsg.stage3 = event.data; + if (!lastMsg.metadata) lastMsg.metadata = {}; + lastMsg.metadata.stage3_metadata = event.metadata; + if (event.metadata?.duration_seconds && lastMsg.metadata.stage1_metadata && lastMsg.metadata.stage2_metadata) { + const total = (lastMsg.metadata.stage1_metadata.duration_seconds || 0) + + (lastMsg.metadata.stage2_metadata.duration_seconds || 0) + + (event.metadata.duration_seconds || 0); + lastMsg.metadata.total_duration_seconds = Math.round(total * 100) / 100; + } + lastMsg.loading.stage3 = false; + return { ...prev, messages }; + }); + break; + + case 'title_complete': + // Reload conversations to get updated title + loadConversations(); + break; + + case 'complete': + // Stream complete, reload conversations list and current conversation + loadConversations(); + if (currentConversationId) { + // Reload the conversation to get the saved assistant message + setTimeout(() => { + loadConversation(currentConversationId); + }, 100); + } + setIsLoading(false); + break; + + case 'error': + console.error('Stream error:', event.message); + alert(`Error: ${event.message}`); + setIsLoading(false); + break; + + case 'stage1_response_failed': + // Model failed - show notification + setCurrentConversation((prev) => { + const messages = [...prev.messages]; + const lastMsg = messages[messages.length - 1]; + if (!lastMsg.failed_models) lastMsg.failed_models = []; + if (!lastMsg.failed_models.includes(event.model)) { + lastMsg.failed_models.push(event.model); + } + return { ...prev, messages }; + }); + break; + + default: + console.log('Unknown event type:', eventType); + } + }); + } catch (error) { + console.error('Failed to send message:', error); + // Remove optimistic messages on error + setCurrentConversation((prev) => ({ + ...prev, + messages: prev.messages.slice(0, -2), + })); + setIsLoading(false); + } + }; + + const handleUploadDocument = async (files) => { + if (!currentConversationId) return; + setIsUploadingDoc(true); + try { + const list = Array.isArray(files) ? files : [files]; + if (list.length <= 1) { + await api.uploadDocument(currentConversationId, list[0]); + } else { + await api.uploadDocuments(currentConversationId, list); + } + await loadDocuments(currentConversationId); + } catch (error) { + console.error('Failed to upload document:', error); + alert(error?.message || 'Failed to upload document'); + } finally { + setIsUploadingDoc(false); + } + }; + + const handleViewDocument = async (doc) => { + if (!currentConversationId) return; + setSelectedDoc(doc); + setIsLoadingDoc(true); + try { + const res = await api.getDocument(currentConversationId, doc.id); + setSelectedDocContent(res.content || ''); + } catch (error) { + console.error('Failed to get document:', error); + alert(error?.message || 'Failed to load document'); + setSelectedDoc(null); + setSelectedDocContent(''); + } finally { + setIsLoadingDoc(false); + } + }; + + const handleDeleteDocument = async (doc) => { + if (!currentConversationId) return; + const ok = confirm(`Delete ${doc.filename}?`); + if (!ok) return; + + try { + await api.deleteDocument(currentConversationId, doc.id); + await loadDocuments(currentConversationId); + if (selectedDoc?.id === doc.id) { + setSelectedDoc(null); + setSelectedDocContent(''); + } + } catch (error) { + console.error('Failed to delete document:', error); + alert(error?.message || 'Failed to delete document'); + } + }; + + const handleExportReport = async () => { + if (!currentConversationId) return; + try { + await api.exportConversation(currentConversationId); + } catch (error) { + console.error('Failed to export conversation:', error); + alert(error?.message || 'Failed to export conversation'); + } + }; + + const handleDeleteConversation = async (conversationId, e) => { + e.stopPropagation(); // Prevent selecting the conversation when clicking delete + const ok = confirm('Delete this conversation? This cannot be undone.'); + if (!ok) return; + + try { + await api.deleteConversation(conversationId); + await loadConversations(); + if (currentConversationId === conversationId) { + setCurrentConversationId(null); + setCurrentConversation(null); + } + } catch (error) { + console.error('Failed to delete conversation:', error); + alert(error?.message || 'Failed to delete conversation'); + } + }; + + const handleRenameConversation = async (conversationId, newTitle) => { + try { + await api.updateConversationTitle(conversationId, newTitle); + await loadConversations(); + // Update current conversation if it's the one being renamed + if (currentConversationId === conversationId) { + await loadConversation(conversationId); + } + } catch (error) { + console.error('Failed to rename conversation:', error); + alert(error?.message || 'Failed to rename conversation'); + } + }; + + return ( +
+ + setPrefillMessage('')} + documents={documents} + isUploadingDoc={isUploadingDoc} + onUploadDocument={handleUploadDocument} + selectedDoc={selectedDoc} + selectedDocContent={selectedDocContent} + isLoadingDoc={isLoadingDoc} + onViewDocument={handleViewDocument} + onDeleteDocument={handleDeleteDocument} + onExportReport={handleExportReport} + /> +
+ ); +} + +export default App; diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..8359e6d --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,286 @@ +/** + * API client for the LLM Council backend. + */ + +const API_BASE = 'http://localhost:8001'; + +export const api = { + /** + * List all conversations. + */ + async listConversations() { + const response = await fetch(`${API_BASE}/api/conversations`); + if (!response.ok) { + throw new Error('Failed to list conversations'); + } + return response.json(); + }, + + /** + * Create a new conversation. + */ + async createConversation() { + const response = await fetch(`${API_BASE}/api/conversations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + if (!response.ok) { + throw new Error('Failed to create conversation'); + } + return response.json(); + }, + + /** + * Get a specific conversation. + */ + async getConversation(conversationId) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}` + ); + if (!response.ok) { + throw new Error('Failed to get conversation'); + } + return response.json(); + }, + + /** + * Delete a conversation. + */ + async deleteConversation(conversationId) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}`, + { method: 'DELETE' } + ); + if (!response.ok) { + throw new Error('Failed to delete conversation'); + } + return response.json(); + }, + + /** + * Update conversation title. + */ + async updateConversationTitle(conversationId, title) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/title`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title }), + } + ); + if (!response.ok) { + throw new Error('Failed to update conversation title'); + } + return response.json(); + }, + + /** + * Search conversations by title and content. + */ + async searchConversations(query) { + const response = await fetch( + `${API_BASE}/api/conversations/search?q=${encodeURIComponent(query)}` + ); + if (!response.ok) { + throw new Error('Failed to search conversations'); + } + return response.json(); + }, + + + /** + * List uploaded markdown documents for a conversation. + */ + async listDocuments(conversationId) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/documents` + ); + if (!response.ok) { + throw new Error('Failed to list documents'); + } + return response.json(); + }, + + /** + * Upload a markdown (.md) document for a conversation. + */ + async uploadDocument(conversationId, file) { + // Keep single-file backward compatibility (server accepts "file") + const form = new FormData(); + // Support both File and Blob (handy for tests and some environments) + form.append('file', file, file?.name || 'document.md'); + + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/documents`, + { + method: 'POST', + body: form, + } + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || 'Failed to upload document'); + } + return response.json(); + }, + + /** + * Upload multiple markdown (.md) documents for a conversation in one request. + * Returns either { uploaded: [...] } or a single {id, filename, bytes} if only one file was uploaded. + */ + async uploadDocuments(conversationId, files) { + const list = Array.isArray(files) ? files : [files]; + const form = new FormData(); + for (const f of list) { + form.append('files', f, f?.name || 'document.md'); + } + + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/documents`, + { + method: 'POST', + body: form, + } + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || 'Failed to upload documents'); + } + return response.json(); + }, + + /** + * Fetch a document's markdown content. + */ + async getDocument(conversationId, docId) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/documents/${docId}` + ); + if (!response.ok) { + throw new Error('Failed to get document'); + } + return response.json(); + }, + + /** + * Delete a document. + */ + async deleteDocument(conversationId, docId) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/documents/${docId}`, + { method: 'DELETE' } + ); + if (!response.ok) { + throw new Error('Failed to delete document'); + } + return response.json(); + }, + + /** + * Export conversation as markdown report. + */ + async exportConversation(conversationId) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/export` + ); + if (!response.ok) { + throw new Error('Failed to export conversation'); + } + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const contentDisposition = response.headers.get('Content-Disposition'); + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename="(.+)"/); + if (filenameMatch) { + a.download = filenameMatch[1]; + } + } else { + a.download = `conversation_${conversationId}.md`; + } + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }, + + /** + * Send a message in a conversation. + */ + async sendMessage(conversationId, content) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/message`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content }), + } + ); + if (!response.ok) { + throw new Error('Failed to send message'); + } + return response.json(); + }, + + /** + * Send a message and receive streaming updates. + * @param {string} conversationId - The conversation ID + * @param {string} content - The message content + * @param {function} onEvent - Callback function for each event: (eventType, data) => void + * @returns {Promise} + */ + async sendMessageStream(conversationId, content, onEvent) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/message/stream`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content }), + } + ); + + if (!response.ok) { + throw new Error('Failed to send message'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + console.log('[Stream] Done reading'); + break; + } + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + try { + const event = JSON.parse(data); + console.log('[Stream] Received event:', event.type); + onEvent(event.type, event); + } catch (e) { + console.error('Failed to parse SSE event:', e, 'Raw data:', data); + } + } else if (line.trim()) { + console.log('[Stream] Non-data line:', line); + } + } + } + }, +}; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/ChatInterface.css b/frontend/src/components/ChatInterface.css new file mode 100644 index 0000000..3a78af8 --- /dev/null +++ b/frontend/src/components/ChatInterface.css @@ -0,0 +1,460 @@ +.chat-interface { + flex: 1; + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg-primary); + transition: background-color 0.2s; +} + +.docs-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-light); + background: var(--bg-tertiary); + transition: background-color 0.2s, border-color 0.2s; +} + +.docs-left { + display: flex; + flex-direction: column; + gap: 2px; +} + +.docs-title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--text-primary); +} + +.docs-subtitle { + font-size: 12px; + color: var(--text-secondary); +} + +.docs-right { + display: flex; + gap: 8px; + align-items: center; +} + +.docs-export-btn, +.docs-upload-btn { + padding: 10px 14px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-weight: 600; + cursor: pointer; + font-size: 13px; + transition: background-color 0.2s, border-color 0.2s; +} + +.docs-export-btn:hover, +.docs-upload-btn:hover { + background: var(--hover-bg); +} + +.docs-upload-btn:disabled, +.docs-export-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.docs-list { + padding: 10px 16px; + border-bottom: 1px solid var(--border-light); + background: var(--bg-primary); + display: flex; + flex-direction: column; + gap: 6px; + transition: background-color 0.2s, border-color 0.2s; +} + +.doc-item { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 13px; + color: var(--text-primary); +} + +.doc-name-btn { + font-weight: 700; + border: 1px solid transparent; + background: transparent; + padding: 4px 8px; + border-radius: 8px; + text-align: left; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-primary); +} + +.doc-number { + font-weight: 600; + color: #4a90e2; + font-size: 12px; + background: rgba(74, 144, 226, 0.1); + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; +} + +.doc-name-btn:hover { + background: var(--hover-bg); +} + +.doc-name-btn.active { + border-color: var(--border-hover); + background: var(--hover-bg-light); +} + +.doc-actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.doc-bytes { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.doc-delete-btn { + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + font-weight: 600; + transition: background-color 0.2s, border-color 0.2s; +} + +.doc-delete-btn:hover { + border-color: #ffb4b4; + background: #fff5f5; +} + +.doc-preview { + border-bottom: 1px solid var(--border-light); + background: var(--bg-primary); + transition: background-color 0.2s, border-color 0.2s; +} + +.doc-preview-header { + padding: 10px 16px; + display: flex; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--border-light); +} + +.doc-preview-title { + font-weight: 800; + font-size: 13px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.doc-preview-meta { + font-size: 12px; + color: var(--text-secondary); + flex-shrink: 0; +} + +.doc-preview-body { + padding: 12px 16px; + max-height: 240px; + overflow: auto; +} + +.doc-preview-loading { + color: var(--text-secondary); + font-style: italic; +} + +.messages-container { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + text-align: center; +} + +.empty-state h2 { + margin: 0 0 8px 0; + font-size: 24px; + color: var(--text-primary); +} + +.empty-state p { + margin: 0; + font-size: 16px; +} + +.message-group { + margin-bottom: 32px; +} + +.user-message, +.assistant-message { + margin-bottom: 16px; +} + +.message-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.user-message .message-content { + background: var(--hover-bg-light); + padding: 16px; + border-radius: 8px; + border: 1px solid var(--border-hover); + color: var(--text-primary); + transition: background-color 0.2s, border-color 0.2s; + line-height: 1.6; + max-width: 80%; + white-space: pre-wrap; +} + +.loading-indicator { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + color: var(--text-secondary); + font-size: 14px; +} + +.stage-loading { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + margin: 12px 0; + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 14px; + font-style: italic; + transition: background-color 0.2s, border-color 0.2s; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid #e0e0e0; + border-top-color: #4a90e2; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.input-form { + display: flex; + align-items: flex-end; + gap: 12px; + padding: 24px; + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); + transition: background-color 0.2s, border-color 0.2s; +} + +.input-wrapper { + flex: 1; + position: relative; +} + +.message-input { + width: 100%; + padding: 16px; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + transition: background-color 0.2s, border-color 0.2s, color 0.2s; + font-size: 16px; + font-family: inherit; + line-height: 1.6; + outline: none; + resize: vertical; + min-height: 120px; + max-height: 400px; + box-sizing: border-box; +} + +.message-input:focus { + border-color: #4a90e2; + box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1); +} + +.message-input:disabled { + opacity: 0.5; + cursor: not-allowed; + background: var(--bg-secondary); +} + +.send-button { + padding: 14px 28px; + background: #4a90e2; + border: 1px solid #4a90e2; + border-radius: 8px; + color: #fff; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; + align-self: flex-end; +} + +.send-button:hover:not(:disabled) { + background: #357abd; + border-color: #357abd; +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; + background: #ccc; + border-color: #ccc; +} + +.debug-console { + border-top: 2px solid var(--border-color); + background: var(--bg-secondary); + max-height: 300px; + display: flex; + flex-direction: column; + transition: background-color 0.2s, border-color 0.2s; +} + +.debug-console-header { + padding: 8px 12px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: var(--text-secondary); +} + +.debug-console-header button { + padding: 4px 8px; + font-size: 11px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + cursor: pointer; +} + +.debug-console-content { + flex: 1; + overflow: auto; + padding: 12px; + font-size: 11px; + font-family: monospace; + color: var(--text-primary); +} + +.debug-console-content pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; +} + +.input-wrapper { + flex: 1; + position: relative; +} + +.autocomplete-dropdown { + position: absolute; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + max-height: 200px; + overflow-y: auto; + min-width: 300px; + bottom: calc(100% + 8px); + left: 0; +} + +.autocomplete-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + cursor: pointer; + border-bottom: 1px solid var(--border-light); + transition: background-color 0.15s; +} + +.autocomplete-item:last-child { + border-bottom: none; +} + +.autocomplete-item:hover, +.autocomplete-item.selected { + background: var(--hover-bg); +} + +.autocomplete-number { + font-weight: 600; + color: #4a90e2; + font-size: 13px; + background: rgba(74, 144, 226, 0.1); + padding: 3px 8px; + border-radius: 4px; + flex-shrink: 0; + min-width: 32px; + text-align: center; +} + +.autocomplete-filename { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + color: var(--text-primary); +} diff --git a/frontend/src/components/ChatInterface.jsx b/frontend/src/components/ChatInterface.jsx new file mode 100644 index 0000000..1f93a00 --- /dev/null +++ b/frontend/src/components/ChatInterface.jsx @@ -0,0 +1,424 @@ +import { useState, useEffect, useRef } from 'react'; +import ReactMarkdown from 'react-markdown'; +import Stage1 from './Stage1'; +import Stage2 from './Stage2'; +import Stage3 from './Stage3'; +import './ChatInterface.css'; + +export default function ChatInterface({ + conversation, + onSendMessage, + isLoading, + prefillMessage = '', + onPrefillConsumed, + documents = [], + isUploadingDoc = false, + onUploadDocument, + selectedDoc = null, + selectedDocContent = '', + isLoadingDoc = false, + onViewDocument, + onDeleteDocument, + onExportReport, +}) { + const [input, setInput] = useState(''); + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [selectedAutocompleteIndex, setSelectedAutocompleteIndex] = useState(0); + const messagesEndRef = useRef(null); + const fileInputRef = useRef(null); + const messageInputRef = useRef(null); + const autocompleteRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [conversation]); + + // Auto-focus input when a new conversation is created or selected + useEffect(() => { + if (conversation && !isLoading) { + // Small delay to ensure the textarea is rendered + const timer = setTimeout(() => { + messageInputRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + } + }, [conversation?.id, isLoading]); + + // Prefill input (e.g. from make test-setup URL param). Never overrides non-empty input. + useEffect(() => { + if (!conversation?.id) return; + if (!prefillMessage) return; + if (input.trim() !== '') return; + + setInput(prefillMessage); + onPrefillConsumed?.(); + // Ensure caret at end + setTimeout(() => { + const el = messageInputRef.current; + if (!el) return; + el.focus(); + const end = el.value.length; + el.setSelectionRange(end, end); + }, 0); + }, [conversation?.id, prefillMessage, input, onPrefillConsumed]); + + // Close autocomplete when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if ( + autocompleteRef.current && + !autocompleteRef.current.contains(event.target) && + messageInputRef.current && + !messageInputRef.current.contains(event.target) + ) { + setShowAutocomplete(false); + } + }; + + if (showAutocomplete) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [showAutocomplete]); + + const handleSubmit = (e) => { + e.preventDefault(); + if (input.trim() && !isLoading) { + onSendMessage(input); + setInput(''); + } + }; + + const handleKeyDown = (e) => { + // Handle autocomplete navigation + if (showAutocomplete) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedAutocompleteIndex((prev) => + Math.min(prev + 1, documents.length - 1) + ); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedAutocompleteIndex((prev) => Math.max(prev - 1, 0)); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + insertDocumentReference(selectedAutocompleteIndex); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setShowAutocomplete(false); + return; + } + } + + // Submit on Enter (without Shift) + if (e.key === 'Enter' && !e.shiftKey && !showAutocomplete) { + e.preventDefault(); + handleSubmit(e); + } + }; + + const insertDocumentReference = (index) => { + const ref = `@${index + 1}`; + const textarea = messageInputRef.current; + if (!textarea) return; + + const cursorPos = textarea.selectionStart; + const textBefore = input.substring(0, cursorPos); + const textAfter = input.substring(cursorPos); + + // Find the @ symbol position + const atPos = textBefore.lastIndexOf('@'); + if (atPos === -1) return; + + // Replace from @ to cursor with the reference + const newText = input.substring(0, atPos) + ref + ' ' + textAfter; + setInput(newText); + setShowAutocomplete(false); + + // Set cursor position after the inserted reference + setTimeout(() => { + const newCursorPos = atPos + ref.length + 1; + textarea.setSelectionRange(newCursorPos, newCursorPos); + textarea.focus(); + }, 0); + }; + + const handleInputChange = (e) => { + const value = e.target.value; + setInput(value); + + const cursorPos = e.target.selectionStart; + const textBefore = value.substring(0, cursorPos); + const lastAt = textBefore.lastIndexOf('@'); + + // Check if we're typing after an @ symbol + if (lastAt !== -1) { + const textAfterAt = textBefore.substring(lastAt + 1); + // Only show autocomplete if there's no space or newline after @ + if (!textAfterAt.match(/[\s\n]/) && documents.length > 0) { + setShowAutocomplete(true); + setSelectedAutocompleteIndex(0); + } else { + setShowAutocomplete(false); + } + } else { + setShowAutocomplete(false); + } + }; + + if (!conversation) { + return ( +
+
+

Welcome to LLM Council

+

Create a new conversation to get started

+
+
+ ); + } + + const handlePickFile = () => { + if (!fileInputRef.current) return; + fileInputRef.current.value = ''; + fileInputRef.current.click(); + }; + + const handleFileChange = async (e) => { + const selected = Array.from(e.target.files || []); + if (selected.length === 0) return; + const nonMd = selected.find((f) => !f.name.toLowerCase().endsWith('.md')); + if (nonMd) { + alert('Only .md files are supported'); + return; + } + if (onUploadDocument) { + await onUploadDocument(selected); + } + }; + + return ( +
+
+
+
Documents
+
+ {documents.length === 0 + ? 'No .md files attached' + : `${documents.length} attached`} +
+
+ +
+ + {onExportReport && conversation?.messages?.length > 0 && ( + + )} + +
+
+ + {documents.length > 0 && ( +
+ {documents.map((d, index) => ( +
+ +
+ {d.bytes} bytes + +
+
+ ))} +
+ )} + + {selectedDoc && ( +
+
+
{selectedDoc.filename}
+
{selectedDoc.bytes} bytes
+
+
+ {isLoadingDoc ? ( +
Loading…
+ ) : ( +
+ {selectedDocContent} +
+ )} +
+
+ )} + +
+ {conversation.messages.length === 0 ? ( +
+

Start a conversation

+

Ask a question to consult the LLM Council

+
+ ) : ( + conversation.messages.map((msg, index) => ( +
+ {msg.role === 'user' ? ( +
+
You
+
+
+ {msg.content} +
+
+
+ ) : ( +
+
LLM Council
+ + {/* Stage 1 */} + {msg.loading?.stage1 && ( +
+
+ Running Stage 1: Collecting individual responses... +
+ )} + {msg.stage1 && ( + + )} + + {/* Stage 2 */} + {msg.loading?.stage2 && ( +
+
+ Running Stage 2: Peer rankings... +
+ )} + {msg.stage2 && ( + + )} + + {/* Stage 3 */} + {msg.loading?.stage3 && ( +
+
+ Running Stage 3: Final synthesis... +
+ )} + {msg.stage3 && ( + + )} +
+ )} +
+ )) + )} + + {isLoading && ( +
+
+ Consulting the council... +
+ )} + +
+
+ +
+
+