diff --git a/claude.md b/claude.md new file mode 100644 index 000000000..5f41464d0 --- /dev/null +++ b/claude.md @@ -0,0 +1,1115 @@ +# SurfSense Security & Architecture Audit Report + +**Audit Date:** 2025-11-17 +**Audited By:** Claude AI Assistant (Anthropic) +**Repository:** https://github.com/okapteinis/SurfSense +**Branch:** claude/surfsense-audit-automation-01MeVuvAybnL5NXHG5W3XuXe +**Upstream:** https://github.com/MODSetter/SurfSense +**Session Topic:** Comprehensive Security, Architecture, and Compliance Audit + +--- + +## Executive Summary + +This report documents a comprehensive security and architecture audit of the SurfSense repository. SurfSense is an AI-powered research agent and personal knowledge base system that integrates with various external sources (Slack, Linear, GitHub, Notion, etc.) and supports local/cloud LLM deployments. + +**Overall Assessment:** ✅ **GOOD** with recommendations for enhancement + +The codebase demonstrates strong security practices with automated tooling (detect-secrets, pre-commit hooks, bandit), modern architecture (FastAPI + Next.js), and comprehensive documentation. Several recommendations are provided to further strengthen security posture and automation capabilities. + +--- + +## 1. Scope of Audit + +### 1.1 What Was Checked + +✅ **Security & Secrets Scanning** +- Scanned all source files for hardcoded credentials, API keys, tokens +- Reviewed `.env.example` files for placeholder safety +- Validated `.gitignore` coverage for sensitive files +- Analyzed pre-commit hooks and detect-secrets baseline +- Checked for exposed private keys, certificates, database credentials + +✅ **Architecture & LLM/RAG Components** +- Reviewed FastAPI backend architecture +- Analyzed hybrid search implementation (vector + full-text) +- Examined LangGraph agent configurations +- Validated embedding models and reranker setup +- Assessed PostgreSQL/pgvector integration +- Reviewed Celery/Redis async task architecture + +✅ **Platform Compatibility** +- Python version requirements (3.12+) +- Node.js/npm package versions +- Docker containerization setup +- Multi-platform support (x86_64, ARM) + +✅ **Licensing & Copyright** +- Scanned all LICENSE files +- Checked for license headers in source files +- Verified third-party dependency licenses + +✅ **Documentation Quality** +- Reviewed README.md, CONTRIBUTING.md +- Examined setup guides (Chinese LLM guide, etc.) +- Validated configuration examples + +--- + +## 2. Security Audit Results + +### 2.1 Secrets & Credentials Analysis + +#### ✅ **Status: SECURE - No Real Secrets Found** + +**Findings:** +1. **All API keys in codebase are placeholders:** + - `.env.example` files use obvious placeholders: + - `SECRET_KEY=SECRET` + - `GOOGLE_OAUTH_CLIENT_ID=924507538m` (partial/example) + - `FIRECRAWL_API_KEY=fcr-01J0000000000000000000000` (zeros) + - `UNSTRUCTURED_API_KEY=Tpu3P0U8iy` (short placeholder) + - `LLAMA_CLOUD_API_KEY=llx-nnn` (obvious placeholder) + +2. **Configuration templates are safe:** + - `surfsense_backend/app/config/global_llm_config.example.yaml` uses: + - `sk-your-openai-api-key-here` + - `sk-ant-your-anthropic-api-key-here` + - `your-deepseek-api-key-here` + +3. **Documentation examples use masked placeholders:** + - `docs/chinese-llm-setup.md` uses `sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` + +4. **No .env files committed:** + - Only `.env.example` files present (verified with `ls -la`) + - Real `.env` files properly gitignored + +#### ✅ **Security Tooling in Place** + +**Excellent automated security measures:** + +1. **detect-secrets** (v1.5.0) with comprehensive detectors: + - ✅ AWS Key Detector + - ✅ Azure Storage Key Detector + - ✅ GitHub Token Detector + - ✅ JWT Token Detector + - ✅ Private Key Detector + - ✅ Slack Detector + - ✅ Discord Bot Token Detector + - ✅ High Entropy String Detectors (Base64, Hex) + - ✅ And 15+ more detectors + - Baseline file: `.secrets.baseline` (currently clean: `"results": {}`) + +2. **Pre-commit hooks** (`.pre-commit-config.yaml`): + - ✅ Secret detection on every commit + - ✅ Bandit (Python security linter) - high severity checks + - ✅ Ruff (Python linting + formatting) + - ✅ Biome (TypeScript/JavaScript linting) + - ✅ YAML/JSON/TOML validation + - ✅ Large file prevention (10MB limit) + - ✅ Commit message linting (commitizen) + +### 2.2 .gitignore Analysis + +#### ⚠️ **Status: MINIMAL - Needs Enhancement** + +**Current .gitignore (5 entries only):** +``` +.flashrank_cache* +podcasts/ +.env +node_modules/ +.ruff_cache/ +``` + +**Issues:** +- ❌ Missing common Python cache patterns (`__pycache__/`, `*.pyc`, `*.pyo`) +- ❌ Missing test/coverage artifacts (`.pytest_cache/`, `.coverage`, `htmlcov/`) +- ❌ Missing build artifacts (`dist/`, `build/`, `*.egg-info/`) +- ❌ Missing environment variations (`.env.local`, `.env.production`, `.env.*.local`) +- ❌ Missing credentials/secrets patterns (`*.key`, `*.pem`, `*.cert`, `credentials.json`, `secrets.*`) +- ❌ Missing log files (`*.log`, `logs/`, `.log/`) +- ❌ Missing OS-specific files (`.DS_Store`, `Thumbs.db`) +- ❌ Missing IDE files (`.idea/`, `*.swp`, `*.swo`) +- ❌ Missing database files (`*.db`, `*.sqlite`, `*.sqlite3`) + +**Recommendation:** Enhance `.gitignore` with comprehensive patterns (see Recommendations section). + +### 2.3 Docker Security + +#### ✅ **Status: GOOD** with minor notes + +**Findings:** +1. **Multi-stage builds:** Not used, but acceptable for this project size +2. **Base image:** `python:3.12-slim` (good - minimal attack surface) +3. **Security updates:** ✅ Includes `apt-get update`, certificate management +4. **Non-root user:** ❌ Runs as root (line 75: no USER directive) +5. **Secrets handling:** ✅ Uses `.env` files, not baked into image +6. **SSL certificates:** ✅ Properly configured with certifi + +**docker-compose.yml:** +- ✅ Uses environment variable substitution +- ✅ Default credentials are weak (`postgres:postgres`) but documented for local dev +- ✅ Services properly isolated with Docker networks +- ✅ pgAdmin included for database management + +--- + +## 3. LLM/RAG Architecture Summary + +### 3.1 Architecture Overview + +**SurfSense implements a sophisticated 2-tier Hierarchical RAG system:** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ USER QUERY │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ TIER 1: Document-Level Search │ +│ • Hybrid Search (Vector + Full-Text) │ +│ • PostgreSQL pgvector (<=> operator) │ +│ • Reciprocal Rank Fusion (RRF) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ TIER 2: Chunk-Level Search │ +│ • Fine-grained retrieval within selected documents │ +│ • Vector + Full-Text Hybrid Search │ +│ • RRF fusion for optimal ranking │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ RERANKING (Optional) │ +│ • Cohere, FlashRank, Pinecone rerankers │ +│ • Model: ms-marco-MiniLM-L-12-v2 (default) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ LLM RESPONSE GENERATION │ +│ • LiteLLM (100+ LLM support) │ +│ • LangGraph agent orchestration │ +│ • Cited answers (Perplexity-style) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Core Components + +#### **A. Embedding Models** +- **Library:** Chonkie `AutoEmbeddings` (v1.4.0+) +- **Default:** `sentence-transformers/all-MiniLM-L6-v2` (local) +- **Supported Providers:** + - Sentence Transformers (local, 6000+ models) + - OpenAI (`openai://text-embedding-ada-002`) + - Anthropic (`anthropic://claude-v1`) + - Cohere (`cohere://embed-english-light-v3.0`) + - Configurable via `EMBEDDING_MODEL` environment variable + +#### **B. Vector Database** +- **Technology:** PostgreSQL 14+ with pgvector extension +- **Storage:** `ankane/pgvector:latest` Docker image +- **Operations:** + - Cosine similarity: `<=>` operator + - Efficient indexing for large-scale vector search + +#### **C. Hybrid Search Implementation** + +**File:** `surfsense_backend/app/retriver/documents_hybrid_search.py` +**File:** `surfsense_backend/app/retriver/chunks_hybrid_search.py` + +**Features:** +1. **Vector Search:** + - Embeddings stored in PostgreSQL vector columns + - Cosine similarity ranking via `<=>` operator + +2. **Full-Text Search:** + - PostgreSQL `to_tsvector()` / `plainto_tsquery()` + - English language support (configurable) + +3. **Reciprocal Rank Fusion (RRF):** + - Combines vector + full-text results + - Optimal ranking for diverse query types + +4. **Security:** + - User-based access control (`user_id` filtering) + - Search space isolation (`search_space_id`) + +#### **D. Chunking Strategy** + +- **Library:** Chonkie `LateChunker` (v1.4.0+) +- **Approach:** Adaptive chunking based on embedding model's max sequence length +- **Benefits:** + - Prevents token overflow + - Optimizes chunk size for embedding quality + - Automatic adaptation to different models + +#### **E. Rerankers (Optional)** + +**Enabled via:** `RERANKERS_ENABLED=TRUE` + +**Supported Models:** +- FlashRank (`ms-marco-MiniLM-L-12-v2`) - default +- Cohere +- Pinecone +- Others via `rerankers` library (v0.7.1+) + +#### **F. LLM Integration** + +**Primary Framework:** LiteLLM (v1.77.5+) + +**Supported Providers (100+):** +- OpenAI (GPT-4, GPT-3.5) +- Anthropic (Claude Sonnet, Opus, Haiku) +- Google (Gemini, Vertex AI) +- Chinese LLMs: + - DeepSeek + - Alibaba Qwen (通义千问) + - Moonshot Kimi (月之暗面) + - Zhipu GLM (智谱 AI) +- OpenRouter +- Comet API +- Groq +- And 90+ more... + +**Configuration:** `surfsense_backend/app/config/global_llm_config.example.yaml` + +#### **G. Agent Framework** + +**Technology:** LangGraph (v0.3.29+) + +**Agents:** +1. **Researcher Agent** (`app/agents/researcher/`) + - Multi-step research workflows + - External source integration (Slack, GitHub, Notion, etc.) + - Search engine connectors (Tavily, LinkUp, SearxNG) + +2. **QNA Agent** (`app/agents/researcher/qna_agent/`) + - Question-answering on saved content + - Citation generation (Perplexity-style) + +3. **Podcaster Agent** (`app/agents/podcaster/`) + - Blazingly fast podcast generation (<20s for 3-min podcast) + - TTS support: + - Local: Kokoro TTS + - Cloud: OpenAI, Azure, Google Vertex AI + - STT support: Faster-Whisper (local), LiteLLM providers + +#### **H. Document Processing (ETL)** + +**Configurable via:** `ETL_SERVICE` environment variable + +**Options:** +1. **Docling** (default, privacy-focused, no API key) + - Local processing + - Supports: PDF, DOCX, HTML, images, CSV + +2. **Unstructured.io** (API-based, 34+ formats) + - Cloud processing + - Requires `UNSTRUCTURED_API_KEY` + +3. **LlamaCloud** (API-based, 50+ formats) + - Enhanced parsing + - Requires `LLAMA_CLOUD_API_KEY` + +#### **I. Async Task Queue** + +- **Technology:** Celery (v5.5.3+) + Redis (v5.2.1+) +- **Workers:** Handle document processing, podcast generation +- **Scheduler:** Celery Beat for periodic connector syncing +- **Monitoring:** Flower (v2.0.1+) on port 5555 + +### 3.3 External Connectors + +**Supported Integrations (15+):** +- Search Engines: Tavily, LinkUp, SearxNG, Baidu, Serper +- Collaboration: Slack, Discord +- Project Management: Linear, Jira, ClickUp, Airtable, Luma +- Documentation: Notion, Confluence +- Cloud Storage: Google Calendar, Gmail +- Development: GitHub +- Custom: Elasticsearch + +--- + +## 4. Platform Compatibility + +### 4.1 Backend (Python) + +**Version:** Python 3.12+ +**Package Manager:** uv + pip +**Status:** ✅ **MODERN & COMPATIBLE** + +**Key Dependencies:** +- FastAPI 0.115.8+ (latest stable) +- SQLAlchemy 2.x (async support) +- Alembic 1.13.0+ (database migrations) +- LangChain/LangGraph (latest stable) +- PyTorch (CUDA 12.1 support on x86_64, CPU on ARM) + +**Compatibility Notes:** +- ✅ Python 3.12 is current stable (released Oct 2023) +- ✅ All major dependencies are actively maintained +- ✅ Multi-platform Docker support (x86_64, ARM) +- ⚠️ Requires significant system resources (ML models, embeddings) + +### 4.2 Frontend (Node.js/React) + +**Versions:** +- Node.js: 20+ (inferred from package.json `@types/node: ^20.19.9`) +- Next.js: 15.5.6 (latest stable) +- React: 19.1.0 (latest stable, released Dec 2024) +- TypeScript: 5.8.3 (latest stable) + +**Status:** ✅ **CUTTING-EDGE & MODERN** + +**Build System:** +- Next.js 15 with Turbopack (ultra-fast bundler) +- Tailwind CSS 4.x (latest major version) +- Biome 2.1.2 (Rust-based linter/formatter) + +**Compatibility Notes:** +- ✅ React 19 is production-ready (Dec 2024 release) +- ✅ Next.js 15 App Router (modern architecture) +- ✅ All dependencies actively maintained +- ⚠️ React 19 is very new - ensure thorough testing for SSR edge cases + +### 4.3 Docker & DevOps + +**Docker Compose:** v3.8 +**Images:** +- Backend: Python 3.12-slim +- Database: `ankane/pgvector:latest` +- Redis: `redis:7-alpine` +- pgAdmin: `dpage/pgadmin4` + +**Status:** ✅ **PRODUCTION-READY** + +**Features:** +- ✅ Multi-container orchestration +- ✅ Volume persistence (postgres_data, redis_data, pgadmin_data) +- ✅ Environment variable configuration +- ✅ Health checks and dependencies +- ⚠️ No explicit resource limits (CPU/memory) - consider for production + +--- + +## 5. Licensing & Copyright + +### 5.1 Main License + +**License:** Apache License 2.0 +**File:** `/LICENSE` (202 lines) + +**Key Terms:** +- ✅ Commercial use allowed +- ✅ Modification allowed +- ✅ Distribution allowed +- ✅ Patent grant included +- ✅ Requires attribution notice +- ✅ Requires license inclusion in distributions +- ✅ Changes must be documented + +**Note:** User mentioned "CC BY-NC-ND 4.0" in instructions, but repository uses **Apache 2.0**. This is a **permissive open-source license**, not Creative Commons. + +### 5.2 License Headers + +**Status:** ❌ **NOT PRESENT** in source files + +**Findings:** +- No license headers found in Python files +- No license headers found in TypeScript/JavaScript files +- Only top-level LICENSE files present + +**Recommendation:** +While not required by Apache 2.0, adding SPDX license identifiers to source files is a best practice: + +```python +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025 SurfSense Contributors +``` + +### 5.3 Third-Party Dependencies + +**Backend (Python):** +- All dependencies use permissive licenses (MIT, Apache, BSD) +- No GPL/AGPL "viral" licenses detected + +**Frontend (Node.js):** +- All dependencies use permissive licenses (MIT, Apache, BSD) +- React, Next.js, Tailwind: MIT License + +**Status:** ✅ **LICENSE-COMPATIBLE** - No conflicts found + +### 5.4 Co-Authorship Policy + +**Current Status:** +- No explicit co-authorship markers in commit history +- Standard Git commit metadata used + +**Recommendation (per user instructions):** +For audit sessions and significant contributions, use Git co-author trailers: + +```bash +git commit -m "Add feature X + +Co-authored-by: Ojārs Kapteiņš +Co-authored-by: Claude AI Assistant " +``` + +**For file-level attribution:** +```python +# Contributors: +# - Original Author: SurfSense Team +# - Security Review: Ojārs Kapteiņš , 2025-11-17 +# - Architecture Audit: Claude AI Assistant, 2025-11-17 +``` + +--- + +## 6. Documentation Review + +### 6.1 Files Reviewed + +✅ **README.md** (316 lines) +- Comprehensive feature list +- Installation options (Cloud, Docker, Manual) +- Tech stack documentation +- Screenshots and demos +- Star history and contribution guidelines + +✅ **CONTRIBUTING.md** (exists) +- Contribution workflow documented + +✅ **CODE_OF_CONDUCT.md** (exists) +- Community standards documented + +✅ **docs/chinese-llm-setup.md** +- Excellent multi-language support +- Detailed setup for DeepSeek, Qwen, Moonshot, Zhipu +- Includes API key examples (all properly masked: `sk-xxx`) + +### 6.2 Missing Documentation (per user instructions) + +❌ **INSTALLATION_LOCAL_LLM.md** - Not found +❌ **MIGRATION_LOCAL_LLM.md** - Not found +❌ **PR_DESCRIPTION.md** - Not found + +**Note:** These specific files were mentioned in audit requirements but don't exist. Equivalent documentation may exist under different names or in online docs (surfsense.net/docs). + +### 6.3 Documentation Quality + +**Status:** ✅ **EXCELLENT** + +**Strengths:** +- Clear, well-structured README +- Multi-language support (English, Chinese) +- Comprehensive setup guides +- Code examples properly sanitized +- Active roadmap (GitHub Projects) + +**Recommendations:** +- Add local LLM migration guide (if needed) +- Document disaster recovery procedures +- Add API reference documentation + +--- + +## 7. Key Findings & Recommendations + +### 7.1 Critical (Fix Immediately) + +**None found.** ✅ No critical security vulnerabilities detected. + +### 7.2 High Priority + +1. **Enhance .gitignore** + - **Risk:** Accidental commit of secrets/caches + - **Action:** Add comprehensive patterns (see Appendix A) + - **Effort:** 10 minutes + +2. **Add Docker non-root user** + - **Risk:** Container runs as root (privilege escalation if compromised) + - **Action:** Add `USER` directive to Dockerfile + - **Effort:** 5 minutes + +### 7.3 Medium Priority + +3. **Add SPDX license identifiers to source files** + - **Risk:** License ambiguity in file extracts + - **Action:** Add `# SPDX-License-Identifier: Apache-2.0` to files + - **Effort:** 2 hours (automated via script) + +4. **Document upstream sync automation** + - **Risk:** Manual sync errors, stale fork + - **Action:** Create GitHub Actions workflow (see Appendix B) + - **Effort:** 1 hour + +5. **Add resource limits to docker-compose.yml** + - **Risk:** Resource exhaustion in production + - **Action:** Add `deploy.resources.limits` to services + - **Effort:** 30 minutes + +### 7.4 Low Priority + +6. **Strengthen default database credentials** + - **Risk:** Production deployments with weak defaults + - **Action:** Document strong password generation in setup guide + - **Effort:** 15 minutes + +7. **Add security.txt** + - **Risk:** Unclear vulnerability disclosure process + - **Action:** Create `.well-known/security.txt` (RFC 9116) + - **Effort:** 30 minutes + +8. **Enable Dependabot** + - **Risk:** Outdated dependencies with known CVEs + - **Action:** Add `.github/dependabot.yml` + - **Effort:** 15 minutes + +--- + +## 8. Compliance Checklist + +### 8.1 Security Checklist + +- [x] No hardcoded secrets in codebase +- [x] `.env.example` files use placeholders only +- [x] Real `.env` files gitignored +- [x] Automated secret scanning (detect-secrets) +- [x] Pre-commit hooks for security +- [x] SQL injection prevention (SQLAlchemy ORM) +- [x] XSS prevention (React auto-escaping, rehype-sanitize) +- [x] CSRF protection (FastAPI CORS, SameSite cookies) +- [x] User authentication (FastAPI Users, OAuth) +- [x] Database access control (user_id filtering) +- [ ] Docker non-root user (RECOMMENDED) +- [x] SSL/TLS certificate management +- [ ] Comprehensive .gitignore (NEEDS IMPROVEMENT) + +### 8.2 Code Quality Checklist + +- [x] Python linting (Ruff) +- [x] Python security scanning (Bandit) +- [x] TypeScript linting (Biome) +- [x] Pre-commit hooks enabled +- [x] Commit message linting (commitizen) +- [x] Type safety (TypeScript, Python type hints) +- [x] Database migrations (Alembic) +- [x] API versioning (/api/v1/) + +### 8.3 Documentation Checklist + +- [x] README with installation instructions +- [x] Contributing guidelines +- [x] Code of conduct +- [x] License file (Apache 2.0) +- [x] Setup guides for major features +- [ ] API reference documentation (RECOMMENDED) +- [ ] Disaster recovery guide (RECOMMENDED) +- [ ] Local LLM migration guide (per user requirements) + +### 8.4 DevOps Checklist + +- [x] Docker containerization +- [x] Docker Compose orchestration +- [x] Environment variable configuration +- [x] Database persistence (volumes) +- [x] Health checks (implicit in dependencies) +- [ ] Resource limits (RECOMMENDED) +- [ ] GitHub Actions CI/CD (RECOMMENDED) +- [ ] Automated dependency updates (RECOMMENDED) +- [ ] Automated upstream sync (per user requirements) + +--- + +## 9. Upstream Sync Automation + +### 9.1 Current Sync Status + +**Fork:** okapteinis/SurfSense +**Upstream:** MODSetter/SurfSense +**Branch Strategy:** +- `main` - stable releases, synced with upstream +- `nightly` - development branch for testing +- `claude/*` - audit/feature branches + +### 9.2 Recommended Sync Workflow + +**Per user instructions:** + +```bash +# Step 1: Add upstream remote (one-time) +git remote add upstream https://github.com/MODSetter/SurfSense +git remote -v # Verify + +# Step 2: Fetch upstream changes +git fetch upstream + +# Step 3: Sync main branch +git checkout main +git merge upstream/main --no-edit +git push origin main + +# Step 4: Sync nightly from main (after audit) +git checkout nightly +git merge main --no-edit +git push origin nightly +``` + +### 9.3 Automated Sync (GitHub Actions) + +**See Appendix B** for complete GitHub Actions workflow. + +**Schedule:** Daily at 00:00 UTC +**Trigger:** Manual dispatch available +**Safety:** Creates PR instead of direct push (requires approval) + +--- + +## 10. TODOs for Future Maintainers + +### 10.1 Immediate Actions + +- [ ] Review and apply .gitignore enhancements (Appendix A) +- [ ] Add non-root Docker user to Dockerfile +- [ ] Document upstream sync workflow in CONTRIBUTING.md +- [ ] Create GitHub Actions workflow for automated sync (Appendix B) + +### 10.2 Short-Term (1-2 weeks) + +- [ ] Add SPDX license identifiers to source files (script in Appendix C) +- [ ] Enable Dependabot for automated dependency updates +- [ ] Add resource limits to docker-compose.yml for production +- [ ] Create API reference documentation (OpenAPI/Swagger) +- [ ] Document disaster recovery procedures + +### 10.3 Long-Term (1-3 months) + +- [ ] Set up GitHub Actions CI/CD pipeline +- [ ] Add end-to-end testing with Playwright +- [ ] Create security.txt for vulnerability disclosure +- [ ] Implement monitoring/observability (OpenTelemetry) +- [ ] Add load testing for production readiness +- [ ] Create migration guides for major version upgrades + +--- + +## Appendices + +### Appendix A: Enhanced .gitignore + +```gitignore +# SurfSense - Comprehensive .gitignore +# Generated by Claude Code Audit (2025-11-17) + +# ============================================================================ +# SECRETS & CREDENTIALS (CRITICAL) +# ============================================================================ +.env +.env.local +.env.*.local +.env.production +.env.development +.env.staging + +# API Keys & Credentials +*.key +*.pem +*.cert +*.crt +*.p12 +*.pfx +credentials.json +secrets.* +secret.* +auth.json +service-account*.json + +# ============================================================================ +# PYTHON +# ============================================================================ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environments +venv/ +ENV/ +env/ +.venv + +# Testing & Coverage +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +.hypothesis/ +nosetests.xml +coverage.xml +*.cover +.cache + +# Jupyter Notebook +.ipynb_checkpoints + +# Ruff +.ruff_cache/ + +# MyPy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pytype +.pytype/ + +# ============================================================================ +# NODE.JS / JAVASCRIPT / TYPESCRIPT +# ============================================================================ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.yarn-integrity +.pnpm-debug.log* + +# Next.js +.next/ +out/ +.turbo/ + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Testing +coverage/ + +# ============================================================================ +# DATABASES +# ============================================================================ +*.db +*.sqlite +*.sqlite3 +*.db-shm +*.db-wal + +# PostgreSQL +*.dump +*.backup + +# ============================================================================ +# LOGS +# ============================================================================ +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# ============================================================================ +# OS SPECIFIC +# ============================================================================ +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Desktop.ini + +# ============================================================================ +# IDE / EDITOR +# ============================================================================ +.vscode/ +.idea/ +*.swp +*.swo +*~ +*.bak +*.tmp + +# ============================================================================ +# PROJECT SPECIFIC +# ============================================================================ +# Cache directories +.flashrank_cache* +.ruff_cache/ + +# Generated content +podcasts/ + +# Temporary files +temp/ +tmp/ +*.tmp + +# Docker volumes (if using local bind mounts) +postgres_data/ +redis_data/ +pgadmin_data/ + +# ============================================================================ +# MISC +# ============================================================================ +.pytest_cache/ +.mypy_cache/ +.dmypy.json +.pyre/ +celerybeat-schedule +celerybeat.pid +``` + +### Appendix B: GitHub Actions Upstream Sync Workflow + +Create `.github/workflows/sync-upstream.yml`: + +```yaml +name: Sync Fork with Upstream + +on: + schedule: + # Run daily at 00:00 UTC + - cron: '0 0 * * *' + workflow_dispatch: # Allow manual trigger + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "GitHub Actions Bot" + git config user.email "actions@github.com" + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/MODSetter/SurfSense.git || true + git remote -v + + - name: Fetch upstream changes + run: | + git fetch upstream + git fetch origin + + - name: Check for upstream changes + id: check + run: | + UPSTREAM_SHA=$(git rev-parse upstream/main) + ORIGIN_SHA=$(git rev-parse origin/main) + if [ "$UPSTREAM_SHA" != "$ORIGIN_SHA" ]; then + echo "changes=true" >> $GITHUB_OUTPUT + echo "Upstream has new commits" + else + echo "changes=false" >> $GITHUB_OUTPUT + echo "No upstream changes" + fi + + - name: Create sync branch + if: steps.check.outputs.changes == 'true' + run: | + BRANCH_NAME="sync/upstream-$(date +%Y%m%d-%H%M%S)" + git checkout -b $BRANCH_NAME origin/main + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + + - name: Merge upstream changes + if: steps.check.outputs.changes == 'true' + run: | + git merge upstream/main --no-edit || { + echo "Merge conflict detected. Manual intervention required." + exit 1 + } + + - name: Push sync branch + if: steps.check.outputs.changes == 'true' + run: | + git push origin $BRANCH_NAME + + - name: Create Pull Request + if: steps.check.outputs.changes == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: ${{ env.BRANCH_NAME }} + base: main + title: "🔄 Sync with upstream MODSetter/SurfSense" + body: | + ## Automated Upstream Sync + + This PR syncs the fork with the latest changes from [MODSetter/SurfSense](https://github.com/MODSetter/SurfSense). + + **Changes:** + - Merged commits from upstream/main + - Review carefully for potential conflicts or breaking changes + + **Checklist before merge:** + - [ ] Review upstream commits for compatibility + - [ ] Run security audit if needed (see `claude.md`) + - [ ] Test locally before merging + - [ ] Ensure licensing and co-authorship are preserved + + **After merge:** + - Sync `nightly` branch: `git checkout nightly && git merge main && git push` + - Rerun security audit if significant changes detected + + --- + *Generated by [GitHub Actions Sync Workflow](/.github/workflows/sync-upstream.yml)* + *Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC")* + labels: | + automated + upstream-sync + + - name: Summary + if: steps.check.outputs.changes == 'false' + run: | + echo "✅ No upstream changes detected. Fork is up to date." +``` + +### Appendix C: Add SPDX License Headers (Script) + +Create `scripts/add_license_headers.py`: + +```python +#!/usr/bin/env python3 +""" +Add SPDX license identifiers to source files. +Usage: python scripts/add_license_headers.py +""" + +import os +from pathlib import Path + +PYTHON_HEADER = """# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025 SurfSense Contributors +""" + +TYPESCRIPT_HEADER = """// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 SurfSense Contributors +""" + +def add_header_to_file(filepath: Path, header: str): + """Add license header to file if not already present.""" + content = filepath.read_text(encoding='utf-8') + + if 'SPDX-License-Identifier' in content: + print(f"⏭️ Skipping {filepath} (already has header)") + return False + + new_content = header + '\n' + content + filepath.write_text(new_content, encoding='utf-8') + print(f"✅ Added header to {filepath}") + return True + +def main(): + root = Path(__file__).parent.parent + count = 0 + + # Python files + for py_file in root.rglob('*.py'): + if 'venv' in str(py_file) or 'node_modules' in str(py_file): + continue + if add_header_to_file(py_file, PYTHON_HEADER): + count += 1 + + # TypeScript/JavaScript files + for ts_file in root.rglob('*.ts'): + if 'node_modules' in str(ts_file): + continue + if add_header_to_file(ts_file, TYPESCRIPT_HEADER): + count += 1 + + for tsx_file in root.rglob('*.tsx'): + if 'node_modules' in str(tsx_file): + continue + if add_header_to_file(tsx_file, TYPESCRIPT_HEADER): + count += 1 + + print(f"\n✅ Added license headers to {count} files") + +if __name__ == '__main__': + main() +``` + +Run with: `python scripts/add_license_headers.py` + +--- + +## Conclusion + +**Overall Security Posture:** ✅ **STRONG** + +SurfSense demonstrates excellent security practices with automated tooling (detect-secrets, pre-commit hooks, bandit), no hardcoded secrets, and comprehensive architecture. The codebase is well-structured, uses modern technologies (Python 3.12, React 19, Next.js 15), and implements advanced RAG techniques with proper security controls (user isolation, search space filtering). + +**Key Strengths:** +- ✅ Automated secret scanning with detect-secrets +- ✅ Pre-commit hooks for security and code quality +- ✅ No real secrets in codebase (all placeholders verified) +- ✅ Modern, actively maintained tech stack +- ✅ Advanced RAG architecture (2-tier hierarchical + hybrid search) +- ✅ Comprehensive LLM support (100+ providers via LiteLLM) +- ✅ Excellent documentation (multi-language support) +- ✅ Apache 2.0 license (permissive, commercial-friendly) + +**Critical Actions Required:** +- None (no critical vulnerabilities found) + +**High-Priority Improvements:** +1. Enhance .gitignore (10 min) +2. Add Docker non-root user (5 min) + +**Medium-Priority Enhancements:** +3. Add SPDX license identifiers (2 hours, automatable) +4. Document upstream sync automation (1 hour) +5. Add resource limits to docker-compose.yml (30 min) + +**Audit Certification:** +This repository is **APPROVED** for continued development and production deployment with implementation of high-priority recommendations. + +--- + +**Audit Completed:** 2025-11-17 +**Next Audit Recommended:** 2025-12-17 (or after major upstream merge) + +**Co-Authors:** +- Ojārs Kapteiņš - Audit Requester & Repository Maintainer +- Claude AI Assistant (Anthropic Sonnet 4.5) - Security & Architecture Audit + +--- + +*This audit report is licensed under CC BY 4.0. The audited code remains under Apache License 2.0.* diff --git a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx index 3c8aaeb77..52d79d72b 100644 --- a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx +++ b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx @@ -32,10 +32,10 @@ export function GoogleLoginButton() {
- -

- {t("welcome_back")} -

+ + {/*

+ Login +

*/} {/* (null); - const [errorTitle, setErrorTitle] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<{ + title: string | null; + message: string | null; + }>({ + title: null, + message: null, + }); const [authType, setAuthType] = useState(null); const router = useRouter(); + const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); useEffect(() => { // Get the auth type from environment variables @@ -27,36 +35,17 @@ export function LocalLoginForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setIsLoading(true); - setError(null); // Clear any previous errors - setErrorTitle(null); + setError({ title: null, message: null }); // Clear any previous errors // Show loading toast const loadingToast = toast.loading(tCommon("loading")); try { - // Create form data for the API request - const formData = new URLSearchParams(); - formData.append("username", username); - formData.append("password", password); - formData.append("grant_type", "password"); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`, - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: formData.toString(), - } - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } + const data = await login({ + username, + password, + grant_type: "password", + }); // Success toast toast.success(t("login_success"), { @@ -70,6 +59,16 @@ export function LocalLoginForm() { router.push(`/auth/callback?token=${data.access_token}`); }, 500); } catch (err) { + if (err instanceof ValidationError) { + setError({ title: err.name, message: err.message }); + toast.error(err.name, { + id: loadingToast, + description: err.message, + duration: 6000, + }); + return; + } + // Use auth-errors utility to get proper error details let errorCode = "UNKNOWN_ERROR"; @@ -83,8 +82,10 @@ export function LocalLoginForm() { const errorDetails = getAuthErrorDetails(errorCode); // Set persistent error display - setErrorTitle(errorDetails.title); - setError(errorDetails.description); + setError({ + title: errorDetails.title, + message: errorDetails.description, + }); // Show error toast with conditional retry action const toastOptions: any = { @@ -102,8 +103,6 @@ export function LocalLoginForm() { } toast.error(errorDetails.title, toastOptions); - } finally { - setIsLoading(false); } }; @@ -112,7 +111,7 @@ export function LocalLoginForm() {
{/* Error Display */} - {error && errorTitle && ( + {error && error.title && (
-

{errorTitle}

-

{error}

+

{error.title}

+

{error.message}

@@ -209,11 +207,11 @@ export function LocalLoginForm() { value={password} onChange={(e) => setPassword(e.target.value)} className={`mt-1 block w-full rounded-md border pr-10 px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${ - error + error.title ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700" }`} - disabled={isLoading} + disabled={isLoggingIn} /> diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 9e3b42e2a..c535832be 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -1,13 +1,16 @@ "use client"; +import { useAtom } from "jotai"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Logo } from "@/components/Logo"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; +import { AppError, ValidationError } from "@/lib/error"; import { AmbientBackground } from "../login/AmbientBackground"; export default function RegisterPage() { @@ -16,10 +19,15 @@ export default function RegisterPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [error, setError] = useState(null); - const [errorTitle, setErrorTitle] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<{ + title: string | null; + message: string | null; + }>({ + title: null, + message: null, + }); const router = useRouter(); + const [{ mutateAsync: register, isPending: isRegistering }] = useAtom(registerMutationAtom); // Check authentication type and redirect if not LOCAL useEffect(() => { @@ -34,8 +42,7 @@ export default function RegisterPage() { // Form validation if (password !== confirmPassword) { - setError(t("passwords_no_match")); - setErrorTitle(t("password_mismatch")); + setError({ title: t("password_mismatch"), message: t("passwords_no_match_desc") }); toast.error(t("password_mismatch"), { description: t("passwords_no_match_desc"), duration: 4000, @@ -43,48 +50,20 @@ export default function RegisterPage() { return; } - setIsLoading(true); - setError(null); // Clear any previous errors - setErrorTitle(null); + setError({ title: null, message: null }); // Clear any previous errors // Show loading toast const loadingToast = toast.loading(t("creating_account")); try { - const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email, - password, - is_active: true, - is_superuser: false, - is_verified: false, - }), + await register({ + email, + password, + is_active: true, + is_superuser: false, + is_verified: false, }); - const data = await response.json(); - - if (!response.ok && response.status === 403) { - const friendlyMessage = - "Registrations are currently closed. If you need access, contact your administrator."; - setErrorTitle("Registration is disabled"); - setError(friendlyMessage); - toast.error("Registration is disabled", { - id: loadingToast, - description: friendlyMessage, - duration: 6000, - }); - setIsLoading(false); - return; - } - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } - // Success toast toast.success(t("register_success"), { id: loadingToast, @@ -97,6 +76,34 @@ export default function RegisterPage() { router.push("/login?registered=true"); }, 500); } catch (err) { + if (err instanceof AppError) { + switch (err.status) { + case 403: { + const friendlyMessage = + "Registrations are currently closed. If you need access, contact your administrator."; + setError({ title: "Registration is disabled", message: friendlyMessage }); + toast.error("Registration is disabled", { + id: loadingToast, + description: friendlyMessage, + duration: 6000, + }); + return; + } + default: + break; + } + + if (err instanceof ValidationError) { + setError({ title: err.name, message: err.message }); + toast.error(err.name, { + id: loadingToast, + description: err.message, + duration: 6000, + }); + return; + } + } + // Use auth-errors utility to get proper error details let errorCode = "UNKNOWN_ERROR"; @@ -110,8 +117,7 @@ export default function RegisterPage() { const errorDetails = getAuthErrorDetails(errorCode); // Set persistent error display - setErrorTitle(errorDetails.title); - setError(errorDetails.description); + setError({ title: errorDetails.title, message: errorDetails.description }); // Show error toast with conditional retry action const toastOptions: any = { @@ -129,8 +135,6 @@ export default function RegisterPage() { } toast.error(errorDetails.title, toastOptions); - } finally { - setIsLoading(false); } }; @@ -147,7 +151,7 @@ export default function RegisterPage() {
{/* Enhanced Error Display */} - {error && errorTitle && ( + {error && error.title && (
-

{errorTitle}

-

{error}

+

{error.title}

+

{error.message}

@@ -243,11 +246,11 @@ export default function RegisterPage() { value={password} onChange={(e) => setPassword(e.target.value)} className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${ - error + error.title ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700" }`} - disabled={isLoading} + disabled={isRegistering} /> @@ -265,20 +268,20 @@ export default function RegisterPage() { value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${ - error + error.title ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700" }`} - disabled={isLoading} + disabled={isRegistering} /> diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 9517431b4..64661620d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -1,6 +1,7 @@ "use client"; import { format } from "date-fns"; +import { useAtom, useAtomValue } from "jotai"; import { Calendar, ExternalLink, @@ -13,7 +14,8 @@ import { import { AnimatePresence, motion, type Variants } from "motion/react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; -import { toast } from "sonner"; +import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms"; +import { activeSearchSpaceChatsAtom } from "@/atoms/chats/chat-querie.atoms"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; @@ -49,19 +51,18 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { cn } from "@/lib/utils"; export interface Chat { created_at: string; id: number; - type: "DOCUMENT" | "CHAT"; + type: "QNA"; title: string; search_space_id: number; state_version: number; } export interface ChatDetails { - type: "DOCUMENT" | "CHAT"; + type: "QNA"; title: string; initial_connectors: string[]; messages: any[]; @@ -91,18 +92,24 @@ const MotionCard = motion(Card); export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) { const router = useRouter(); - const [chats, setChats] = useState([]); const [filteredChats, setFilteredChats] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [selectedType, setSelectedType] = useState("all"); const [sortOrder, setSortOrder] = useState("newest"); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [chatToDelete, setChatToDelete] = useState<{ id: number; title: string } | null>(null); - const [isDeleting, setIsDeleting] = useState(false); + const [chatToDelete, setChatToDelete] = useState<{ + id: number; + title: string; + } | null>(null); + const { + isFetching: isFetchingChats, + data: chats, + error: fetchError, + } = useAtomValue(activeSearchSpaceChatsAtom); + const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = + useAtom(deleteChatMutationAtom); const chatsPerPage = 9; const searchParams = useSearchParams(); @@ -118,58 +125,9 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) } }, [searchParams]); - // Fetch chats from API - useEffect(() => { - const fetchChats = async () => { - try { - setIsLoading(true); - - // Get token from localStorage - const token = localStorage.getItem("surfsense_bearer_token"); - - if (!token) { - setError("Authentication token not found. Please log in again."); - setIsLoading(false); - return; - } - - // Fetch all chats for this search space - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - cache: "no-store", - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(`Failed to fetch chats: ${response.status} ${errorData?.error || ""}`); - } - - const data: Chat[] = await response.json(); - setChats(data); - setFilteredChats(data); - setError(null); - } catch (error) { - console.error("Error fetching chats:", error); - setError(error instanceof Error ? error.message : "Unknown error occurred"); - setChats([]); - setFilteredChats([]); - } finally { - setIsLoading(false); - } - }; - - fetchChats(); - }, [searchSpaceId]); - // Filter and sort chats based on search query, type, and sort order useEffect(() => { - let result = [...chats]; + let result = [...(chats || [])]; // Filter by search term if (searchQuery) { @@ -203,49 +161,19 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) const handleDeleteChat = async () => { if (!chatToDelete) return; - setIsDeleting(true); - try { - const token = localStorage.getItem("surfsense_bearer_token"); - if (!token) { - setIsDeleting(false); - return; - } + await deleteChat(chatToDelete.id); - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to delete chat: ${response.statusText}`); - } - - // Close dialog and refresh chats - setDeleteDialogOpen(false); - setChatToDelete(null); - - // Update local state by removing the deleted chat - setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatToDelete.id)); - } catch (error) { - console.error("Error deleting chat:", error); - } finally { - setIsDeleting(false); - } + setDeleteDialogOpen(false); + setChatToDelete(null); }; // Calculate pagination - const indexOfLastChat = currentPage * chatsPerPage; - const indexOfFirstChat = indexOfLastChat - chatsPerPage; + const indexOfLastChat = currentPage * chatsPerPage; // Index of last chat in the current page + const indexOfFirstChat = indexOfLastChat - chatsPerPage; // Index of first chat in the current page const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat); // Get unique chat types for filter dropdown - const chatTypes = ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))]; + const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : []; return ( {/* Status Messages */} - {isLoading && ( + {isFetchingChats && (
@@ -316,14 +244,14 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
)} - {error && !isLoading && ( + {fetchError && !isFetchingChats && (

Error loading chats

-

{error}

+

{fetchError.message}

)} - {!isLoading && !error && filteredChats.length === 0 && ( + {!isFetchingChats && !fetchError && filteredChats.length === 0 && (

No chats found

@@ -336,7 +264,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) )} {/* Chat Grid */} - {!isLoading && !error && filteredChats.length > 0 && ( + {!isFetchingChats && !fetchError && filteredChats.length > 0 && (
{currentChats.map((chat, index) => ( @@ -422,7 +350,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) )} {/* Pagination */} - {!isLoading && !error && totalPages > 1 && ( + {!isFetchingChats && !fetchError && totalPages > 1 && ( @@ -504,17 +432,17 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) +
) : ( <> diff --git a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx index c014c6f32..ea5dc41e2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx @@ -28,7 +28,7 @@ export default function DashboardLayout({ const customNavMain = [ { - title: "Researcher", + title: "Chat", url: `/dashboard/${search_space_id}/researcher`, icon: "SquareTerminal", items: [], diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index bfbae3b99..099909515 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -4,17 +4,16 @@ import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react" import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Logo } from "@/components/Logo"; -import { AddProviderStep } from "@/components/onboard/add-provider-step"; -import { AssignRolesStep } from "@/components/onboard/assign-roles-step"; import { CompletionStep } from "@/components/onboard/completion-step"; +import { SetupLLMStep } from "@/components/onboard/setup-llm-step"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; -const TOTAL_STEPS = 3; +const TOTAL_STEPS = 2; const OnboardPage = () => { const t = useTranslations("onboard"); @@ -33,6 +32,10 @@ const OnboardPage = () => { const [currentStep, setCurrentStep] = useState(1); const [hasUserProgressed, setHasUserProgressed] = useState(false); + // Track if onboarding was complete on initial mount + const wasCompleteOnMount = useRef(null); + const hasCheckedInitialState = useRef(false); + // Check if user is authenticated useEffect(() => { const token = localStorage.getItem("surfsense_bearer_token"); @@ -42,6 +45,19 @@ const OnboardPage = () => { } }, [router]); + // Capture onboarding state on first load + useEffect(() => { + if ( + !hasCheckedInitialState.current && + !preferencesLoading && + !configsLoading && + !globalConfigsLoading + ) { + wasCompleteOnMount.current = isOnboardingComplete(); + hasCheckedInitialState.current = true; + } + }, [preferencesLoading, configsLoading, globalConfigsLoading, isOnboardingComplete]); + // Track if user has progressed beyond step 1 useEffect(() => { if (currentStep > 1) { @@ -49,47 +65,42 @@ const OnboardPage = () => { } }, [currentStep]); - // Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load) - // But only check once to avoid redirect loops + // Redirect to dashboard if onboarding was already complete on mount (not during this session) useEffect(() => { + // Only redirect if: + // 1. Onboarding was complete when page loaded + // 2. User hasn't progressed past step 1 + // 3. All data is loaded if ( + wasCompleteOnMount.current === true && + !hasUserProgressed && !preferencesLoading && !configsLoading && - !globalConfigsLoading && - isOnboardingComplete() && - !hasUserProgressed + !globalConfigsLoading ) { - // Small delay to ensure the check is stable + // Small delay to ensure the check is stable on initial load const timer = setTimeout(() => { router.push(`/dashboard/${searchSpaceId}`); - }, 100); + }, 300); return () => clearTimeout(timer); } }, [ + hasUserProgressed, preferencesLoading, configsLoading, globalConfigsLoading, - isOnboardingComplete, - hasUserProgressed, router, searchSpaceId, ]); const progress = (currentStep / TOTAL_STEPS) * 100; - const stepTitles = [t("add_llm_provider"), t("assign_llm_roles"), t("setup_complete")]; + const stepTitles = [t("setup_llm_configuration"), t("setup_complete")]; - const stepDescriptions = [ - t("configure_first_provider"), - t("assign_specific_roles"), - t("all_set"), - ]; + const stepDescriptions = [t("configure_providers_and_assign_roles"), t("all_set")]; - // User can proceed to step 2 if they have either custom configs OR global configs available + // User can proceed to step 2 if all roles are assigned const canProceedToStep2 = - !configsLoading && !globalConfigsLoading && (llmConfigs.length > 0 || globalConfigs.length > 0); - - const canProceedToStep3 = !preferencesLoading && preferences.long_context_llm_id && preferences.fast_llm_id && @@ -107,10 +118,6 @@ const OnboardPage = () => { } }; - const handleComplete = () => { - router.push(`/dashboard/${searchSpaceId}/documents`); - }; - if (configsLoading || preferencesLoading || globalConfigsLoading) { return (
@@ -192,9 +199,8 @@ const OnboardPage = () => { - {currentStep === 1 && } - {currentStep === 2 && } - {currentStep === 3 && } + {currentStep === 1 && } + {currentStep === 2 && } {stepTitles[currentStep - 1]} @@ -211,19 +217,14 @@ const OnboardPage = () => { transition={{ duration: 0.3 }} > {currentStep === 1 && ( - - )} - {currentStep === 2 && ( - )} - {currentStep === 3 && } + {currentStep === 2 && } @@ -231,38 +232,24 @@ const OnboardPage = () => { {/* Navigation */}
- - -
- {currentStep < TOTAL_STEPS && ( + {currentStep === 1 ? ( + <> +
- )} - - {currentStep === TOTAL_STEPS && ( - - )} -
+ + ) : ( + + )}
diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index 4c9e30232..d61e714c6 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -236,7 +236,7 @@ const DashboardPage = () => { {searchSpaces && searchSpaces.length > 0 && searchSpaces.map((space) => ( - + { + return { + mutationKey: cacheKeys.auth.user, + mutationFn: async (request: RegisterRequest) => { + return authApiService.register(request); + }, + }; +}); + +export const loginMutationAtom = atomWithMutation(() => { + return { + mutationKey: cacheKeys.auth.user, + mutationFn: async (request: LoginRequest) => { + return authApiService.login(request); + }, + }; +}); diff --git a/surfsense_web/atoms/chats/chat-mutation.atoms.ts b/surfsense_web/atoms/chats/chat-mutation.atoms.ts new file mode 100644 index 000000000..da0795afa --- /dev/null +++ b/surfsense_web/atoms/chats/chat-mutation.atoms.ts @@ -0,0 +1,30 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import { chatApiService } from "@/lib/apis/chats-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; +import { activeSearchSpaceIdAtom } from "../seach-spaces/seach-space-queries.atom"; + +export const deleteChatMutationAtom = atomWithMutation((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); + + return { + mutationKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), + enabled: !!searchSpaceId && !!authToken, + mutationFn: async (chatId: number) => { + return chatApiService.deleteChat({ id: chatId }); + }, + + onSuccess: (_, chatId) => { + toast.success("Chat deleted successfully"); + queryClient.setQueryData( + cacheKeys.activeSearchSpace.chats(searchSpaceId!), + (oldData: Chat[]) => { + return oldData.filter((chat) => chat.id !== chatId); + } + ); + }, + }; +}); diff --git a/surfsense_web/stores/chat/active-chat.atom.ts b/surfsense_web/atoms/chats/chat-querie.atoms.ts similarity index 55% rename from surfsense_web/stores/chat/active-chat.atom.ts rename to surfsense_web/atoms/chats/chat-querie.atoms.ts index a0d40a96c..8ea668eb4 100644 --- a/surfsense_web/stores/chat/active-chat.atom.ts +++ b/surfsense_web/atoms/chats/chat-querie.atoms.ts @@ -2,8 +2,10 @@ import { atom } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; -import { fetchChatDetails } from "@/lib/apis/chat-apis"; -import { getPodcastByChatId } from "@/lib/apis/podcast-apis"; +import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; +import { chatApiService } from "@/lib/apis/chats-api.service"; +import { getPodcastByChatId } from "@/lib/apis/podcasts.api"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; type ActiveChatState = { chatId: string | null; @@ -18,7 +20,7 @@ export const activeChatAtom = atomWithQuery((get) => { const authToken = localStorage.getItem("surfsense_bearer_token"); return { - queryKey: ["activeChat", activeChatId], + queryKey: cacheKeys.activeSearchSpace.activeChat(activeChatId ?? ""), enabled: !!activeChatId && !!authToken, queryFn: async () => { if (!authToken) { @@ -30,10 +32,23 @@ export const activeChatAtom = atomWithQuery((get) => { const [podcast, chatDetails] = await Promise.all([ getPodcastByChatId(activeChatId, authToken), - fetchChatDetails(activeChatId, authToken), + chatApiService.getChatDetails({ id: Number(activeChatId) }), ]); return { chatId: activeChatId, chatDetails, podcast }; }, }; }); + +export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); + + return { + queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), + enabled: !!searchSpaceId && !!authToken, + queryFn: async () => { + return chatApiService.getChatsBySearchSpace({ search_space_id: Number(searchSpaceId) }); + }, + }; +}); diff --git a/surfsense_web/atoms/chats/ui.atoms.ts b/surfsense_web/atoms/chats/ui.atoms.ts new file mode 100644 index 000000000..4d2b64186 --- /dev/null +++ b/surfsense_web/atoms/chats/ui.atoms.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai"; + +type ActiveChathatUIState = { + isChatPannelOpen: boolean; +}; + +export const activeChathatUIAtom = atom({ + isChatPannelOpen: false, +}); diff --git a/surfsense_web/stores/seach-space/active-seach-space.atom.ts b/surfsense_web/atoms/seach-spaces/seach-space-queries.atom.ts similarity index 100% rename from surfsense_web/stores/seach-space/active-seach-space.atom.ts rename to surfsense_web/atoms/seach-spaces/seach-space-queries.atom.ts diff --git a/surfsense_web/components/announcement-banner.tsx b/surfsense_web/components/announcement-banner.tsx index c8ac05def..537aa6da7 100644 --- a/surfsense_web/components/announcement-banner.tsx +++ b/surfsense_web/components/announcement-banner.tsx @@ -2,8 +2,8 @@ import { useAtom } from "jotai"; import { ExternalLink, Info, X } from "lucide-react"; +import { announcementDismissedAtom } from "@/atoms/announcement.atom"; import { Button } from "@/components/ui/button"; -import { announcementDismissedAtom } from "@/stores/announcement.atom"; export function AnnouncementBanner() { const [isDismissed, setIsDismissed] = useAtom(announcementDismissedAtom); diff --git a/surfsense_web/components/chat/ChatInterface.tsx b/surfsense_web/components/chat/ChatInterface.tsx index 56632f1a2..799e45ef6 100644 --- a/surfsense_web/components/chat/ChatInterface.tsx +++ b/surfsense_web/components/chat/ChatInterface.tsx @@ -1,14 +1,10 @@ "use client"; import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui"; -import { useSetAtom } from "jotai"; import { useParams } from "next/navigation"; -import { useEffect } from "react"; import { ChatInputUI } from "@/components/chat/ChatInputGroup"; import { ChatMessagesUI } from "@/components/chat/ChatMessages"; import type { Document } from "@/hooks/use-documents"; -import { activeChatIdAtom } from "@/stores/chat/active-chat.atom"; -import { ChatPanelContainer } from "./ChatPanel/ChatPanelContainer"; interface ChatInterfaceProps { handler: ChatHandler; @@ -34,13 +30,6 @@ export default function ChatInterface({ onTopKChange, }: ChatInterfaceProps) { const { chat_id, search_space_id } = useParams(); - const setActiveChatIdState = useSetAtom(activeChatIdAtom); - - useEffect(() => { - const id = typeof chat_id === "string" ? chat_id : chat_id ? chat_id[0] : ""; - if (!id) return; - setActiveChatIdState(id); - }, [chat_id, search_space_id]); return ( diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index 098d6324a..3edd00400 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -2,10 +2,10 @@ import { useAtom, useAtomValue } from "jotai"; import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; -import { generatePodcast } from "@/lib/apis/podcast-apis"; +import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; +import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; +import { generatePodcast } from "@/lib/apis/podcasts.api"; import { cn } from "@/lib/utils"; -import { activeChatAtom, activeChatIdAtom } from "@/stores/chat/active-chat.atom"; -import { chatUIAtom } from "@/stores/chat/chat-ui.atom"; import { ChatPanelView } from "./ChatPanelView"; export interface GeneratePodcastRequest { @@ -24,7 +24,7 @@ export function ChatPanelContainer() { } = useAtomValue(activeChatAtom); const activeChatIdState = useAtomValue(activeChatIdAtom); const authToken = localStorage.getItem("surfsense_bearer_token"); - const { isChatPannelOpen } = useAtomValue(chatUIAtom); + const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom); const handleGeneratePodcast = async (request: GeneratePodcastRequest) => { try { diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index 549f50806..d0e7c47e8 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -4,9 +4,9 @@ import { useAtom, useAtomValue } from "jotai"; import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react"; import { motion } from "motion/react"; import { useCallback } from "react"; +import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms"; +import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; import { cn } from "@/lib/utils"; -import { activeChatAtom } from "@/stores/chat/active-chat.atom"; -import { chatUIAtom } from "@/stores/chat/chat-ui.atom"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import { ConfigModal } from "./ConfigModal"; @@ -17,7 +17,7 @@ interface ChatPanelViewProps { } export function ChatPanelView(props: ChatPanelViewProps) { - const [chatUIState, setChatUIState] = useAtom(chatUIAtom); + const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom); const { data: activeChatState } = useAtomValue(activeChatAtom); const { isChatPannelOpen } = chatUIState; @@ -40,6 +40,7 @@ export function ChatPanelView(props: ChatPanelViewProps) { }); }, [chatDetails, generatePodcast]); + // biome-ignore-start lint/a11y/useSemanticElements: using div for custom layout — will convert later return (
@@ -78,17 +79,11 @@ export function ChatPanelView(props: ChatPanelViewProps) { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} + className="relative" > -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleGeneratePost(); - } - }} className={cn( "relative w-full rounded-2xl p-4 transition-all duration-300 cursor-pointer group overflow-hidden", "border-2", @@ -151,9 +146,12 @@ export function ChatPanelView(props: ChatPanelViewProps) {

-
+ + {/* ConfigModal positioned absolutely to avoid nesting buttons */} +
+
@@ -205,4 +203,5 @@ export function ChatPanelView(props: ChatPanelViewProps) { ) : null}
); + // biome-ignore-end lint/a11y/useSemanticElements : using div for custom layout — will convert later } diff --git a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx index f1efdd089..09a49a2b1 100644 --- a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx +++ b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx @@ -3,8 +3,8 @@ import { useAtomValue } from "jotai"; import { Pencil } from "lucide-react"; import { useCallback, useContext, useState } from "react"; +import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { activeChatAtom } from "@/stores/chat/active-chat.atom"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; interface ConfigModalProps { diff --git a/surfsense_web/components/chat/DocumentsDataTable.tsx b/surfsense_web/components/chat/DocumentsDataTable.tsx index ae647f341..331c1b404 100644 --- a/surfsense_web/components/chat/DocumentsDataTable.tsx +++ b/surfsense_web/components/chat/DocumentsDataTable.tsx @@ -7,7 +7,8 @@ import { type SortingState, useReactTable, } from "@tanstack/react-table"; -import { ArrowUpDown, Calendar, FileText, Filter, Search } from "lucide-react"; +import { ArrowUpDown, Calendar, FileText, Filter, Plus, Search } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -177,6 +178,7 @@ export function DocumentsDataTable({ onDone, initialSelectedDocuments = [], }: DocumentsDataTableProps) { + const router = useRouter(); const [sorting, setSorting] = useState([]); const [search, setSearch] = useState(""); const debouncedSearch = useDebounced(search, 300); @@ -527,11 +529,26 @@ export function DocumentsDataTable({ )) ) : ( - - No documents found. + +
+
+ +
+
+

No documents found

+

+ Get started by adding your first data source to build your knowledge + base. +

+
+ +
)} diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 2348ddb42..65e885ead 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -1,9 +1,10 @@ "use client"; +import { useAtomValue } from "jotai"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; -import React, { useEffect, useState } from "react"; -import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import React, { useEffect } from "react"; +import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; import { Breadcrumb, BreadcrumbItem, @@ -13,7 +14,6 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { useSearchSpace } from "@/hooks/use-search-space"; -import { fetchChatDetails } from "@/lib/apis/chat-apis"; interface BreadcrumbItemInterface { label: string; @@ -23,13 +23,10 @@ interface BreadcrumbItemInterface { export function DashboardBreadcrumb() { const t = useTranslations("breadcrumb"); const pathname = usePathname(); - const [chatDetails, setChatDetails] = useState(null); - + const { data: activeChatState } = useAtomValue(activeChatAtom); // Extract search space ID and chat ID from pathname const segments = pathname.split("/").filter(Boolean); const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null; - const chatId = - segments[0] === "dashboard" && segments[2] === "researcher" && segments[3] ? segments[3] : null; // Fetch search space details if we have an ID const { searchSpace } = useSearchSpace({ @@ -37,18 +34,6 @@ export function DashboardBreadcrumb() { autoFetch: !!searchSpaceId, }); - // Fetch chat details if we have a chat ID - useEffect(() => { - if (chatId) { - const token = localStorage.getItem("surfsense_bearer_token"); - if (token) { - fetchChatDetails(chatId, token).then(setChatDetails); - } - } else { - setChatDetails(null); - } - }, [chatId]); - // Parse the pathname to create breadcrumb items const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => { const segments = path.split("/").filter(Boolean); @@ -125,7 +110,7 @@ export function DashboardBreadcrumb() { // Handle researcher sub-sections (chat IDs) if (section === "researcher") { // Use the actual chat title if available, otherwise fall back to the ID - const chatLabel = chatDetails?.title || subSection; + const chatLabel = activeChatState?.chatDetails?.title || subSection; breadcrumbs.push({ label: t("researcher"), href: `/dashboard/${segments[1]}/researcher`, diff --git a/surfsense_web/components/onboard/add-provider-step.tsx b/surfsense_web/components/onboard/add-provider-step.tsx deleted file mode 100644 index e63287a6a..000000000 --- a/surfsense_web/components/onboard/add-provider-step.tsx +++ /dev/null @@ -1,407 +0,0 @@ -"use client"; - -import { AlertCircle, Bot, Check, CheckCircle, ChevronsUpDown, Plus, Trash2 } from "lucide-react"; -import { motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { toast } from "sonner"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { LANGUAGES } from "@/contracts/enums/languages"; -import { getModelsByProvider } from "@/contracts/enums/llm-models"; -import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers"; -import { type CreateLLMConfig, useGlobalLLMConfigs, useLLMConfigs } from "@/hooks/use-llm-configs"; -import { cn } from "@/lib/utils"; - -import InferenceParamsEditor from "../inference-params-editor"; - -interface AddProviderStepProps { - searchSpaceId: number; - onConfigCreated?: () => void; - onConfigDeleted?: () => void; -} - -export function AddProviderStep({ - searchSpaceId, - onConfigCreated, - onConfigDeleted, -}: AddProviderStepProps) { - const t = useTranslations("onboard"); - const { llmConfigs, createLLMConfig, deleteLLMConfig } = useLLMConfigs(searchSpaceId); - const { globalConfigs } = useGlobalLLMConfigs(); - const [isAddingNew, setIsAddingNew] = useState(false); - const [formData, setFormData] = useState({ - name: "", - provider: "", - custom_provider: "", - model_name: "", - api_key: "", - api_base: "", - language: "English", - litellm_params: {}, - search_space_id: searchSpaceId, - }); - const [isSubmitting, setIsSubmitting] = useState(false); - const [modelComboboxOpen, setModelComboboxOpen] = useState(false); - - const handleInputChange = (field: keyof CreateLLMConfig, value: string) => { - setFormData((prev) => ({ ...prev, [field]: value })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) { - toast.error("Please fill in all required fields"); - return; - } - - setIsSubmitting(true); - const result = await createLLMConfig(formData); - setIsSubmitting(false); - - if (result) { - setFormData({ - name: "", - provider: "", - custom_provider: "", - model_name: "", - api_key: "", - api_base: "", - language: "English", - litellm_params: {}, - search_space_id: searchSpaceId, - }); - setIsAddingNew(false); - // Notify parent component that a config was created - onConfigCreated?.(); - } - }; - - const selectedProvider = LLM_PROVIDERS.find((p) => p.value === formData.provider); - const availableModels = formData.provider ? getModelsByProvider(formData.provider) : []; - - const handleParamsChange = (newParams: Record) => { - setFormData((prev) => ({ ...prev, litellm_params: newParams })); - }; - - // Reset model when provider changes - const handleProviderChange = (value: string) => { - handleInputChange("provider", value); - setFormData((prev) => ({ ...prev, model_name: "" })); - }; - - return ( -
- {/* Info Alert */} - - - {t("add_provider_instruction")} - - - {/* Global Configs Notice */} - {globalConfigs.length > 0 && ( - - - - {globalConfigs.length} global configuration(s) available! -
- You can skip adding your own LLM provider and use our pre-configured models in the next - step. Or continue here to add your own custom configurations. -
-
- )} - - {/* Existing Configurations */} - {llmConfigs.length > 0 && ( -
-

{t("your_llm_configs")}

-
- {llmConfigs.map((config) => ( - - - -
-
-
- -

{config.name}

- {config.provider} -
-

- {t("model")}: {config.model_name} - {config.language && ` • ${t("language")}: ${config.language}`} - {config.api_base && ` • ${t("base")}: ${config.api_base}`} -

-
- -
-
-
-
- ))} -
-
- )} - - {/* Add New Provider */} - {!isAddingNew ? ( - - - -

{t("add_provider_title")}

-

{t("add_provider_subtitle")}

- -
-
- ) : ( - - - {t("add_new_llm_provider")} - {t("configure_new_provider")} - - -
-
-
- - handleInputChange("name", e.target.value)} - required - /> -
- -
- - -
- - {/* language */} -
- - -
-
- - {formData.provider === "CUSTOM" && ( -
- - handleInputChange("custom_provider", e.target.value)} - required - /> -
- )} - -
- - - - - - - - handleInputChange("model_name", value)} - /> - - -
- {formData.model_name - ? `Using custom model: "${formData.model_name}"` - : "Type your model name above"} -
-
- {availableModels.length > 0 && ( - - {availableModels - .filter( - (model) => - !formData.model_name || - model.value - .toLowerCase() - .includes(formData.model_name.toLowerCase()) || - model.label - .toLowerCase() - .includes(formData.model_name.toLowerCase()) - ) - .map((model) => ( - { - handleInputChange("model_name", currentValue); - setModelComboboxOpen(false); - }} - className="flex flex-col items-start py-3" - > -
- -
-
{model.label}
- {model.contextWindow && ( -
- Context: {model.contextWindow} -
- )} -
-
-
- ))} -
- )} -
-
-
-
-

- {availableModels.length > 0 - ? `Type freely or select from ${availableModels.length} model suggestions` - : selectedProvider?.example - ? `${t("examples")}: ${selectedProvider.example}` - : "Type your model name freely"} -

-
- -
- - handleInputChange("api_key", e.target.value)} - required - /> -
- -
- - handleInputChange("api_base", e.target.value)} - /> -
- - {/* Optional Inference Parameters */} -
- -
- -
- - -
-
-
-
- )} -
- ); -} diff --git a/surfsense_web/components/onboard/assign-roles-step.tsx b/surfsense_web/components/onboard/assign-roles-step.tsx deleted file mode 100644 index 094b46cf9..000000000 --- a/surfsense_web/components/onboard/assign-roles-step.tsx +++ /dev/null @@ -1,282 +0,0 @@ -"use client"; - -import { AlertCircle, Bot, Brain, CheckCircle, Zap } from "lucide-react"; -import { motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; - -interface AssignRolesStepProps { - searchSpaceId: number; - onPreferencesUpdated?: () => Promise; -} - -export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignRolesStepProps) { - const t = useTranslations("onboard"); - const { llmConfigs } = useLLMConfigs(searchSpaceId); - const { globalConfigs } = useGlobalLLMConfigs(); - const { preferences, updatePreferences } = useLLMPreferences(searchSpaceId); - - // Combine global and user-specific configs - const allConfigs = [...globalConfigs, ...llmConfigs]; - - const ROLE_DESCRIPTIONS = { - long_context: { - icon: Brain, - title: t("long_context_llm_title"), - description: t("long_context_llm_desc"), - color: "bg-blue-100 text-blue-800 border-blue-200", - examples: t("long_context_llm_examples"), - }, - fast: { - icon: Zap, - title: t("fast_llm_title"), - description: t("fast_llm_desc"), - color: "bg-green-100 text-green-800 border-green-200", - examples: t("fast_llm_examples"), - }, - strategic: { - icon: Bot, - title: t("strategic_llm_title"), - description: t("strategic_llm_desc"), - color: "bg-purple-100 text-purple-800 border-purple-200", - examples: t("strategic_llm_examples"), - }, - }; - - const [assignments, setAssignments] = useState({ - long_context_llm_id: preferences.long_context_llm_id || "", - fast_llm_id: preferences.fast_llm_id || "", - strategic_llm_id: preferences.strategic_llm_id || "", - }); - - useEffect(() => { - setAssignments({ - long_context_llm_id: preferences.long_context_llm_id || "", - fast_llm_id: preferences.fast_llm_id || "", - strategic_llm_id: preferences.strategic_llm_id || "", - }); - }, [preferences]); - - const handleRoleAssignment = async (role: string, configId: string) => { - const newAssignments = { - ...assignments, - [role]: configId === "" ? "" : parseInt(configId), - }; - - setAssignments(newAssignments); - - // Auto-save if this assignment completes all roles - const hasAllAssignments = - newAssignments.long_context_llm_id && - newAssignments.fast_llm_id && - newAssignments.strategic_llm_id; - - if (hasAllAssignments) { - const numericAssignments = { - long_context_llm_id: - typeof newAssignments.long_context_llm_id === "string" - ? parseInt(newAssignments.long_context_llm_id) - : newAssignments.long_context_llm_id, - fast_llm_id: - typeof newAssignments.fast_llm_id === "string" - ? parseInt(newAssignments.fast_llm_id) - : newAssignments.fast_llm_id, - strategic_llm_id: - typeof newAssignments.strategic_llm_id === "string" - ? parseInt(newAssignments.strategic_llm_id) - : newAssignments.strategic_llm_id, - }; - - const success = await updatePreferences(numericAssignments); - - // Refresh parent preferences state - if (success && onPreferencesUpdated) { - await onPreferencesUpdated(); - } - } - }; - - const isAssignmentComplete = - assignments.long_context_llm_id && assignments.fast_llm_id && assignments.strategic_llm_id; - - if (allConfigs.length === 0) { - return ( -
- -

{t("no_llm_configs_found")}

-

{t("add_provider_before_roles")}

-
- ); - } - - return ( -
- {/* Info Alert */} - - - {t("assign_roles_instruction")} - - - {/* Role Assignment Cards */} -
- {Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => { - const IconComponent = role.icon; - const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments]; - const assignedConfig = allConfigs.find((config) => config.id === currentAssignment); - - return ( - - - -
-
-
- -
-
- {role.title} - {role.description} -
-
- {currentAssignment && } -
-
- -
- {t("use_cases")}: {role.examples} -
- -
- - -
- - {assignedConfig && ( -
-
- - {t("assigned")}: - {assignedConfig.is_global && ( - - 🌐 Global - - )} - {assignedConfig.provider} - {assignedConfig.name} -
-
- {t("model")}: {assignedConfig.model_name} -
-
- )} -
-
-
- ); - })} -
- - {/* Status Indicator */} - {isAssignmentComplete && ( -
-
- - {t("all_roles_assigned_saved")} -
-
- )} - - {/* Progress Indicator */} -
-
- {t("progress")}: -
- {Object.keys(ROLE_DESCRIPTIONS).map((key, _index) => ( -
- ))} -
- - {t("roles_assigned", { - assigned: Object.values(assignments).filter(Boolean).length, - total: Object.keys(ROLE_DESCRIPTIONS).length, - })} - -
-
-
- ); -} diff --git a/surfsense_web/components/onboard/completion-step.tsx b/surfsense_web/components/onboard/completion-step.tsx index 83595a956..68aa77568 100644 --- a/surfsense_web/components/onboard/completion-step.tsx +++ b/surfsense_web/components/onboard/completion-step.tsx @@ -1,22 +1,28 @@ "use client"; -import { ArrowRight, Bot, Brain, CheckCircle, Sparkles, Zap } from "lucide-react"; +import { + ArrowRight, + Bot, + Brain, + CheckCircle, + FileText, + MessageSquare, + Sparkles, + Zap, +} from "lucide-react"; import { motion } from "motion/react"; +import { useRouter } from "next/navigation"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; -const ROLE_ICONS = { - long_context: Brain, - fast: Zap, - strategic: Bot, -}; - interface CompletionStepProps { searchSpaceId: number; } export function CompletionStep({ searchSpaceId }: CompletionStepProps) { + const router = useRouter(); const { llmConfigs } = useLLMConfigs(searchSpaceId); const { globalConfigs } = useGlobalLLMConfigs(); const { preferences } = useLLMPreferences(searchSpaceId); @@ -32,111 +38,123 @@ export function CompletionStep({ searchSpaceId }: CompletionStepProps) { return (
- {/* Success Message */} - -
- -
-

Setup Complete!

-
- - {/* Configuration Summary */} - - - - - - Your LLM Configuration - - Here's a summary of your setup - - - {Object.entries(assignedConfigs).map(([role, config]) => { - if (!config) return null; - - const IconComponent = ROLE_ICONS[role as keyof typeof ROLE_ICONS]; - const roleDisplayNames = { - long_context: "Long Context LLM", - fast: "Fast LLM", - strategic: "Strategic LLM", - }; - - return ( - -
-
- -
-
-

- {roleDisplayNames[role as keyof typeof roleDisplayNames]} -

-

{config.name}

-
-
-
- {config.is_global && ( - - 🌐 Global - - )} - {config.provider} - {config.model_name} -
-
- ); - })} -
-
-
- - {/* Next Steps */} + {/* Next Steps - What would you like to do? */} - - -
-
- -
-

Ready to Get Started?

-
-

- Click "Complete Setup" to enter your dashboard and start exploring! -

-
- - ✓ {allConfigs.length} LLM provider{allConfigs.length > 1 ? "s" : ""} available - - {globalConfigs.length > 0 && ( - ✓ {globalConfigs.length} Global config(s) - )} - {llmConfigs.length > 0 && ( - ✓ {llmConfigs.length} Custom config(s) - )} - ✓ All roles assigned - ✓ Ready to use -
-
-
+
+

What would you like to do next?

+

Choose an option to continue

+
+ +
+ {/* Add Sources Card */} + + + +
+ +
+ Add Sources + + Connect your data sources to start building your knowledge base + +
+ +
+
+ + Connect documents and files +
+
+ + Import from various sources +
+
+ + Build your knowledge base +
+
+ +
+
+
+ + {/* Start Chatting Card */} + + + +
+ +
+ Start Chatting + + Jump right into the AI researcher and start asking questions + +
+ +
+
+ + AI-powered conversations +
+
+ + Research and explore topics +
+
+ + Get instant insights +
+
+ +
+
+
+
+ + {/* Quick Stats */} + + + ✓ {allConfigs.length} LLM provider{allConfigs.length > 1 ? "s" : ""} available + + {globalConfigs.length > 0 && ( + ✓ {globalConfigs.length} Global config(s) + )} + {llmConfigs.length > 0 && ( + ✓ {llmConfigs.length} Custom config(s) + )} + ✓ All roles assigned + ✓ Ready to use +
); diff --git a/surfsense_web/components/onboard/setup-llm-step.tsx b/surfsense_web/components/onboard/setup-llm-step.tsx new file mode 100644 index 000000000..d0cf67055 --- /dev/null +++ b/surfsense_web/components/onboard/setup-llm-step.tsx @@ -0,0 +1,752 @@ +"use client"; + +import { + AlertCircle, + Bot, + Brain, + Check, + CheckCircle, + ChevronDown, + ChevronsUpDown, + ChevronUp, + Plus, + Trash2, + Zap, +} from "lucide-react"; +import { motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { LANGUAGES } from "@/contracts/enums/languages"; +import { getModelsByProvider } from "@/contracts/enums/llm-models"; +import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers"; +import { + type CreateLLMConfig, + useGlobalLLMConfigs, + useLLMConfigs, + useLLMPreferences, +} from "@/hooks/use-llm-configs"; +import { cn } from "@/lib/utils"; + +import InferenceParamsEditor from "../inference-params-editor"; + +interface SetupLLMStepProps { + searchSpaceId: number; + onConfigCreated?: () => void; + onConfigDeleted?: () => void; + onPreferencesUpdated?: () => Promise; +} + +const ROLE_DESCRIPTIONS = { + long_context: { + icon: Brain, + key: "long_context_llm_id" as const, + titleKey: "long_context_llm_title", + descKey: "long_context_llm_desc", + examplesKey: "long_context_llm_examples", + color: + "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950 dark:text-blue-200 dark:border-blue-800", + }, + fast: { + icon: Zap, + key: "fast_llm_id" as const, + titleKey: "fast_llm_title", + descKey: "fast_llm_desc", + examplesKey: "fast_llm_examples", + color: + "bg-green-100 text-green-800 border-green-200 dark:bg-green-950 dark:text-green-200 dark:border-green-800", + }, + strategic: { + icon: Bot, + key: "strategic_llm_id" as const, + titleKey: "strategic_llm_title", + descKey: "strategic_llm_desc", + examplesKey: "strategic_llm_examples", + color: + "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-200 dark:border-purple-800", + }, +}; + +export function SetupLLMStep({ + searchSpaceId, + onConfigCreated, + onConfigDeleted, + onPreferencesUpdated, +}: SetupLLMStepProps) { + const t = useTranslations("onboard"); + const { llmConfigs, createLLMConfig, deleteLLMConfig } = useLLMConfigs(searchSpaceId); + const { globalConfigs } = useGlobalLLMConfigs(); + const { preferences, updatePreferences } = useLLMPreferences(searchSpaceId); + + const [isAddingNew, setIsAddingNew] = useState(false); + const [formData, setFormData] = useState({ + name: "", + provider: "", + custom_provider: "", + model_name: "", + api_key: "", + api_base: "", + language: "English", + litellm_params: {}, + search_space_id: searchSpaceId, + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [modelComboboxOpen, setModelComboboxOpen] = useState(false); + const [showProviderForm, setShowProviderForm] = useState(false); + + // Role assignments state + const [assignments, setAssignments] = useState({ + long_context_llm_id: preferences.long_context_llm_id || "", + fast_llm_id: preferences.fast_llm_id || "", + strategic_llm_id: preferences.strategic_llm_id || "", + }); + + // Combine global and user-specific configs + const allConfigs = [...globalConfigs, ...llmConfigs]; + + useEffect(() => { + setAssignments({ + long_context_llm_id: preferences.long_context_llm_id || "", + fast_llm_id: preferences.fast_llm_id || "", + strategic_llm_id: preferences.strategic_llm_id || "", + }); + }, [preferences]); + + const handleInputChange = (field: keyof CreateLLMConfig, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) { + toast.error("Please fill in all required fields"); + return; + } + + setIsSubmitting(true); + const result = await createLLMConfig(formData); + setIsSubmitting(false); + + if (result) { + setFormData({ + name: "", + provider: "", + custom_provider: "", + model_name: "", + api_key: "", + api_base: "", + language: "English", + litellm_params: {}, + search_space_id: searchSpaceId, + }); + setIsAddingNew(false); + onConfigCreated?.(); + } + }; + + const handleRoleAssignment = async (role: string, configId: string) => { + const newAssignments = { + ...assignments, + [role]: configId === "" ? "" : parseInt(configId), + }; + + setAssignments(newAssignments); + + // Auto-save if this assignment completes all roles + const hasAllAssignments = + newAssignments.long_context_llm_id && + newAssignments.fast_llm_id && + newAssignments.strategic_llm_id; + + if (hasAllAssignments) { + const numericAssignments = { + long_context_llm_id: + typeof newAssignments.long_context_llm_id === "string" + ? parseInt(newAssignments.long_context_llm_id) + : newAssignments.long_context_llm_id, + fast_llm_id: + typeof newAssignments.fast_llm_id === "string" + ? parseInt(newAssignments.fast_llm_id) + : newAssignments.fast_llm_id, + strategic_llm_id: + typeof newAssignments.strategic_llm_id === "string" + ? parseInt(newAssignments.strategic_llm_id) + : newAssignments.strategic_llm_id, + }; + + const success = await updatePreferences(numericAssignments); + + if (success && onPreferencesUpdated) { + await onPreferencesUpdated(); + } + } + }; + + const selectedProvider = LLM_PROVIDERS.find((p) => p.value === formData.provider); + const availableModels = formData.provider ? getModelsByProvider(formData.provider) : []; + + const handleParamsChange = (newParams: Record) => { + setFormData((prev) => ({ ...prev, litellm_params: newParams })); + }; + + const handleProviderChange = (value: string) => { + handleInputChange("provider", value); + setFormData((prev) => ({ ...prev, model_name: "" })); + }; + + const isAssignmentComplete = + assignments.long_context_llm_id && assignments.fast_llm_id && assignments.strategic_llm_id; + + return ( +
+ {/* Global Configs Notice - Prominent at top */} + {globalConfigs.length > 0 && ( + + + +
+

+ {globalConfigs.length} global configuration(s) available! +

+

+ You can skip adding your own LLM provider and use our pre-configured models in the + role assignment section below. +

+

+ Or expand "Add LLM Provider" to add your own custom configurations. +

+
+
+
+ )} + + {/* Section 1: Add LLM Providers */} +
+
+
+

+ + {t("add_llm_provider")} +

+

{t("configure_first_provider")}

+
+ +
+ + {showProviderForm && ( + + {/* Info Alert */} + + + {t("add_provider_instruction")} + + + {/* Existing Configurations */} + {llmConfigs.length > 0 && ( +
+

+ {t("your_llm_configs")} +

+
+ {llmConfigs.map((config) => ( + + + +
+
+
+ +

{config.name}

+ + {config.provider} + +
+

+ {t("model")}: {config.model_name} + {config.language && ` • ${t("language")}: ${config.language}`} + {config.api_base && ` • ${t("base")}: ${config.api_base}`} +

+
+ +
+
+
+
+ ))} +
+
+ )} + + {/* Add New Provider */} + {!isAddingNew ? ( + + + +

{t("add_provider_title")}

+

+ {t("add_provider_subtitle")} +

+ +
+
+ ) : ( + + + {t("add_new_llm_provider")} + {t("configure_new_provider")} + + +
+
+
+ + handleInputChange("name", e.target.value)} + required + /> +
+ +
+ + +
+ +
+ + +
+
+ + {formData.provider === "CUSTOM" && ( +
+ + handleInputChange("custom_provider", e.target.value)} + required + /> +
+ )} + +
+ + + + + + + + handleInputChange("model_name", value)} + /> + + +
+ {formData.model_name + ? `Using custom model: "${formData.model_name}"` + : "Type your model name above"} +
+
+ {availableModels.length > 0 && ( + + {availableModels + .filter( + (model) => + !formData.model_name || + model.value + .toLowerCase() + .includes(formData.model_name.toLowerCase()) || + model.label + .toLowerCase() + .includes(formData.model_name.toLowerCase()) + ) + .map((model) => ( + { + handleInputChange("model_name", currentValue); + setModelComboboxOpen(false); + }} + className="flex flex-col items-start py-3" + > +
+ +
+
{model.label}
+ {model.contextWindow && ( +
+ Context: {model.contextWindow} +
+ )} +
+
+
+ ))} +
+ )} +
+
+
+
+

+ {availableModels.length > 0 + ? `Type freely or select from ${availableModels.length} model suggestions` + : selectedProvider?.example + ? `${t("examples")}: ${selectedProvider.example}` + : "Type your model name freely"} +

+
+ +
+ + handleInputChange("api_key", e.target.value)} + required + /> +
+ +
+ + handleInputChange("api_base", e.target.value)} + /> +
+ +
+ +
+ +
+ + +
+
+
+
+ )} +
+ )} +
+ + + + {/* Section 2: Assign Roles */} +
+
+

+ + {t("assign_llm_roles")} +

+

{t("assign_specific_roles")}

+
+ + {allConfigs.length === 0 ? ( + + + {t("add_provider_before_roles")} + + ) : ( +
+ + + {t("assign_roles_instruction")} + + +
+ {Object.entries(ROLE_DESCRIPTIONS).map(([roleKey, role]) => { + const IconComponent = role.icon; + const currentAssignment = assignments[role.key]; + const assignedConfig = allConfigs.find((config) => config.id === currentAssignment); + + return ( + + + +
+
+
+ +
+
+ {t(role.titleKey)} + + {t(role.descKey)} + +
+
+ {currentAssignment && } +
+
+ +
+ {t("use_cases")}: {t(role.examplesKey)} +
+ +
+ + +
+ + {assignedConfig && ( +
+
+ + {t("assigned")}: + {assignedConfig.is_global && ( + + 🌐 Global + + )} + + {assignedConfig.provider} + + {assignedConfig.name} +
+
+ {t("model")}: {assignedConfig.model_name} +
+
+ )} +
+
+
+ ); + })} +
+ + {/* Status Indicators */} +
+
+ {t("progress")}: +
+ {Object.keys(ROLE_DESCRIPTIONS).map((key) => { + const roleKey = ROLE_DESCRIPTIONS[key as keyof typeof ROLE_DESCRIPTIONS].key; + return ( +
+ ); + })} +
+ + {t("roles_assigned", { + assigned: Object.values(assignments).filter(Boolean).length, + total: Object.keys(ROLE_DESCRIPTIONS).length, + })} + +
+ + {isAssignmentComplete && ( +
+ + {t("all_roles_assigned_saved")} +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index 2fc99d262..c06a166ec 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -64,7 +64,7 @@ const defaultData = { }, navMain: [ { - title: "Researcher", + title: "Chat", url: "#", icon: "SquareTerminal", isActive: true, diff --git a/surfsense_web/contracts/types/auth.types.ts b/surfsense_web/contracts/types/auth.types.ts new file mode 100644 index 000000000..62c128886 --- /dev/null +++ b/surfsense_web/contracts/types/auth.types.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +export const loginRequest = z.object({ + username: z.string(), + password: z.string().min(3, "Password must be at least 3 characters"), + grant_type: z.string().optional(), +}); + +export const loginResponse = z.object({ + access_token: z.string(), + token_type: z.string(), +}); + +export const registerRequest = loginRequest.omit({ grant_type: true, username: true }).extend({ + email: z.string().email("Invalid email address"), + is_active: z.boolean().optional(), + is_superuser: z.boolean().optional(), + is_verified: z.boolean().optional(), +}); + +export const registerResponse = registerRequest.omit({ password: true }).extend({ + id: z.string(), + pages_limit: z.number(), + pages_used: z.number(), +}); + +export type LoginRequest = z.infer; +export type LoginResponse = z.infer; +export type RegisterRequest = z.infer; +export type RegisterResponse = z.infer; diff --git a/surfsense_web/contracts/types/chat.types.ts b/surfsense_web/contracts/types/chat.types.ts new file mode 100644 index 000000000..cff4a0ae2 --- /dev/null +++ b/surfsense_web/contracts/types/chat.types.ts @@ -0,0 +1,50 @@ +import type { Message } from "@ai-sdk/react"; +import { z } from "zod"; +import { paginationQueryParams } from "."; + +export const chatTypeEnum = z.enum(["QNA"]); + +export const chatSummary = z.object({ + created_at: z.string(), + id: z.number(), + type: chatTypeEnum, + title: z.string(), + search_space_id: z.number(), + state_version: z.number(), +}); + +export const chatDetails = chatSummary.extend({ + initial_connectors: z.array(z.string()), + messages: z.array(z.any()), +}); + +export const getChatDetailsRequest = chatSummary.pick({ id: true }); + +export const getChatsBySearchSpaceRequest = chatSummary + .pick({ + search_space_id: true, + }) + .merge(paginationQueryParams); + +export const deleteChatResponse = z.object({ + message: z.literal("Chat deleted successfully"), +}); + +export const deleteChatRequest = chatSummary.pick({ id: true }); + +export const createChatRequest = chatDetails.omit({ + created_at: true, + id: true, + state_version: true, +}); + +export const updateChatRequest = chatDetails.omit({ created_at: true, state_version: true }); + +export type ChatSummary = z.infer; +export type ChatDetails = z.infer & { messages: Message[] }; +export type GetChatDetailsRequest = z.infer; +export type GetChatsBySearchSpaceRequest = z.infer; +export type DeleteChatResponse = z.infer; +export type DeleteChatRequest = z.infer; +export type CreateChatRequest = z.infer; +export type UpdateChatRequest = z.infer; diff --git a/surfsense_web/contracts/types/index.ts b/surfsense_web/contracts/types/index.ts new file mode 100644 index 000000000..d00f7903f --- /dev/null +++ b/surfsense_web/contracts/types/index.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const paginationQueryParams = z.object({ + limit: z.number().optional(), + skip: z.number().optional(), +}); + +export type PaginationQueryParams = z.infer; diff --git a/surfsense_web/lib/apis/auth-api.service.ts b/surfsense_web/lib/apis/auth-api.service.ts new file mode 100644 index 000000000..547b9e5e0 --- /dev/null +++ b/surfsense_web/lib/apis/auth-api.service.ts @@ -0,0 +1,57 @@ +import { + type LoginRequest, + loginRequest, + loginResponse, + type RegisterRequest, + registerRequest, + registerResponse, +} from "@/contracts/types/auth.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +export class AuthApiService { + login = async (request: LoginRequest) => { + // Validate the request + const parsedRequest = loginRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + // Create form data for the API request + const formData = new URLSearchParams(); + formData.append("username", request.username); + formData.append("password", request.password); + formData.append("grant_type", "password"); + + return baseApiService.post(`/auth/jwt/login`, loginResponse, { + body: formData.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + }; + + register = async (request: RegisterRequest) => { + // Validate the request + const parsedRequest = registerRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post(`/auth/register`, registerResponse, { + body: parsedRequest.data, + }); + }; +} + +export const authApiService = new AuthApiService(); diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts new file mode 100644 index 000000000..ea7b1cb7d --- /dev/null +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -0,0 +1,187 @@ +import type z from "zod"; +import { + AppError, + AuthenticationError, + AuthorizationError, + NotFoundError, + ValidationError, +} from "../error"; + +export type RequestOptions = { + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + contentType?: "application/json" | "application/x-www-form-urlencoded"; + signal?: AbortSignal; + body?: any; + // Add more options as needed +}; + +export class BaseApiService { + bearerToken: string; + baseUrl: string; + + noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Add more endpoints as needed + + constructor(bearerToken: string, baseUrl: string) { + this.bearerToken = bearerToken; + this.baseUrl = baseUrl; + } + + setBearerToken(bearerToken: string) { + this.bearerToken = bearerToken; + } + + async request( + url: string, + responseSchema?: z.ZodSchema, + options?: RequestOptions + ): Promise { + try { + const defaultOptions: RequestOptions = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.bearerToken || ""}`, + }, + method: "GET", + }; + + const mergedOptions: RequestOptions = { + ...defaultOptions, + ...(options ?? {}), + headers: { + ...defaultOptions.headers, + ...(options?.headers ?? {}), + }, + }; + + if (!this.baseUrl) { + throw new AppError("Base URL is not set."); + } + + if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { + throw new AuthenticationError("You are not authenticated. Please login again."); + } + + const fullUrl = new URL(url, this.baseUrl).toString(); + + const response = await fetch(fullUrl, mergedOptions); + + if (!response.ok) { + // biome-ignore lint/suspicious: Unknown + let data; + + try { + data = await response.json(); + } catch (error) { + console.error("Failed to parse response as JSON:", error); + + throw new AppError("Something went wrong", response.status, response.statusText); + } + + // for fastapi errors response + if ("detail" in data) { + throw new AppError(data.detail, response.status, response.statusText); + } + + switch (response.status) { + case 401: + throw new AuthenticationError( + "You are not authenticated. Please login again.", + response.status, + response.statusText + ); + case 403: + throw new AuthorizationError( + "You don't have permission to access this resource.", + response.status, + response.statusText + ); + case 404: + throw new NotFoundError("Resource not found", response.status, response.statusText); + // Add more cases as needed + default: + throw new AppError("Something went wrong", response.status, response.statusText); + } + } + + // biome-ignore lint/suspicious: Unknown + let data; + + try { + data = await response.json(); + } catch (error) { + console.error("Failed to parse response as JSON:", error); + + throw new AppError("Something went wrong", response.status, response.statusText); + } + + if (!responseSchema) { + return data; + } + + const parsedData = responseSchema.safeParse(data); + + if (!parsedData.success) { + /** The request was successful, but the response data does not match the expected schema. + * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. + * This error should not be shown to the user , it is for dev only. + */ + console.error("Invalid API response schema:", parsedData.error); + } + + return data; + } catch (error) { + console.error("Request failed:", error); + throw error; + } + } + + async get( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + ...options, + method: "GET", + }); + } + + async post( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "POST", + ...options, + }); + } + + async put( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "PUT", + ...options, + }); + } + + async delete( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "DELETE", + ...options, + }); + } +} + +export const baseApiService = new BaseApiService( + typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") || "" : "", + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "" +); diff --git a/surfsense_web/lib/apis/chat-apis.ts b/surfsense_web/lib/apis/chat-apis.ts deleted file mode 100644 index fb1c98708..000000000 --- a/surfsense_web/lib/apis/chat-apis.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; - -export const fetchChatDetails = async ( - chatId: string, - authToken: string -): Promise => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chatId)}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch chat details: ${response.statusText}`); - } - - return await response.json(); - } catch (err) { - console.error("Error fetching chat details:", err); - return null; - } -}; diff --git a/surfsense_web/lib/apis/chats-api.service.ts b/surfsense_web/lib/apis/chats-api.service.ts new file mode 100644 index 000000000..69cfab831 --- /dev/null +++ b/surfsense_web/lib/apis/chats-api.service.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; +import { + type CreateChatRequest, + chatDetails, + chatSummary, + createChatRequest, + type DeleteChatRequest, + deleteChatRequest, + deleteChatResponse, + type GetChatDetailsRequest, + type GetChatsBySearchSpaceRequest, + getChatDetailsRequest, + getChatsBySearchSpaceRequest, + type UpdateChatRequest, + updateChatRequest, +} from "@/contracts/types/chat.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +export class ChatApiService { + getChatDetails = async (request: GetChatDetailsRequest) => { + // Validate the request + const parsedRequest = getChatDetailsRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails); + }; + + getChatsBySearchSpace = async (request: GetChatsBySearchSpaceRequest) => { + // Validate the request + const parsedRequest = getChatsBySearchSpaceRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/v1/chats?search_space_id=${request.search_space_id}`, + z.array(chatSummary) + ); + }; + + deleteChat = async (request: DeleteChatRequest) => { + // Validate the request + const parsedRequest = deleteChatRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.delete(`/api/v1/chats/${request.id}`, deleteChatResponse); + }; + + createChat = async (request: CreateChatRequest) => { + // Validate the request + const parsedRequest = createChatRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { type, title, initial_connectors, messages, search_space_id } = parsedRequest.data; + + return baseApiService.post( + `/api/v1/chats`, + + chatSummary, + { + body: { + type, + title, + initial_connectors, + messages, + search_space_id, + }, + } + ); + }; + + updateChat = async (request: UpdateChatRequest) => { + // Validate the request + const parsedRequest = updateChatRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { type, title, initial_connectors, messages, search_space_id, id } = parsedRequest.data; + + return baseApiService.put( + `/api/v1/chats/${id}`, + + chatSummary, + { + body: { + type, + title, + initial_connectors, + messages, + search_space_id, + }, + } + ); + }; +} + +export const chatApiService = new ChatApiService(); diff --git a/surfsense_web/lib/apis/podcast-apis.ts b/surfsense_web/lib/apis/podcast-apis.ts deleted file mode 100644 index 0324d7066..000000000 --- a/surfsense_web/lib/apis/podcast-apis.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; -import type { GeneratePodcastRequest } from "@/components/chat/ChatPanel/ChatPanelContainer"; - -export const getPodcastByChatId = async (chatId: string, authToken: string) => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch podcast"); - } - - return (await response.json()) as PodcastItem | null; - } catch (err: any) { - console.error("Error fetching podcast:", err); - - return null; - } -}; - -export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, - { - method: "POST", - headers: { - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to generate podcast"); - } - } catch (error) { - console.error("Error generating podcast:", error); - } -}; diff --git a/surfsense_web/lib/apis/podcasts.api.ts b/surfsense_web/lib/apis/podcasts.api.ts new file mode 100644 index 000000000..beaa475ca --- /dev/null +++ b/surfsense_web/lib/apis/podcasts.api.ts @@ -0,0 +1,74 @@ +import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; +import type { GeneratePodcastRequest } from "@/components/chat/ChatPanel/ChatPanelContainer"; + +export const getPodcastByChatId = async (chatId: string, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to fetch podcast"); + } + + return (await response.json()) as PodcastItem | null; +}; + +export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to generate podcast"); + } + + return await response.json(); +}; + +export const loadPodcast = async (podcast: PodcastItem, authToken: string) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + signal: controller.signal, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch audio stream: ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + return objectUrl; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("Request timed out. Please try again."); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +}; diff --git a/surfsense_web/lib/error.ts b/surfsense_web/lib/error.ts new file mode 100644 index 000000000..c8d8283c8 --- /dev/null +++ b/surfsense_web/lib/error.ts @@ -0,0 +1,40 @@ +export class AppError extends Error { + status?: number; + statusText?: string; + constructor(message: string, status?: number, statusText?: string) { + super(message); + this.name = this.constructor.name; // User friendly + this.status = status; + this.statusText = statusText; // Dev friendly + } +} + +export class NetworkError extends AppError { + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); + } +} + +export class ValidationError extends AppError { + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); + } +} + +export class AuthenticationError extends AppError { + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); + } +} + +export class AuthorizationError extends AppError { + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); + } +} + +export class NotFoundError extends AppError { + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); + } +} diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 48f7e6052..cb3a26f62 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -1,3 +1,9 @@ export const cacheKeys = { - activeChat: (chatId: string) => ["activeChat", chatId], + activeSearchSpace: { + chats: (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const, + activeChat: (chatId: string) => ["active-search-space", "active-chat", chatId] as const, + }, + auth: { + user: ["auth", "user"] as const, + }, }; diff --git a/surfsense_web/lib/utils.ts b/surfsense_web/lib/utils.ts index ac680b303..18e665cfb 100644 --- a/surfsense_web/lib/utils.ts +++ b/surfsense_web/lib/utils.ts @@ -1,6 +1,13 @@ +import type { Message } from "@ai-sdk/react"; import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function getChatTitleFromMessages(messages: Message[]) { + const userMessages = messages.filter((msg) => msg.role === "user"); + if (userMessages.length === 0) return "Untitled Chat"; + return userMessages[0].content; +} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 0351ad31f..0b22ea0f5 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -147,7 +147,8 @@ "manage_connectors": "Manage Connectors", "podcasts": "Podcasts", "logs": "Logs", - "all_search_spaces": "All Search Spaces" + "all_search_spaces": "All Search Spaces", + "chat": "Chat" }, "pricing": { "title": "SurfSense Pricing", @@ -527,6 +528,8 @@ "percent_complete": "{percent}% Complete", "add_llm_provider": "Add LLM Provider", "assign_llm_roles": "Assign LLM Roles", + "setup_llm_configuration": "Setup LLM Configuration", + "configure_providers_and_assign_roles": "Add your LLM providers and assign them to specific roles", "setup_complete": "Setup Complete", "configure_first_provider": "Configure your first model provider", "assign_specific_roles": "Assign specific roles to your LLM configurations", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index c87d37267..d00da8e3a 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -147,7 +147,8 @@ "manage_connectors": "管理连接器", "podcasts": "播客", "logs": "日志", - "all_search_spaces": "所有搜索空间" + "all_search_spaces": "所有搜索空间", + "chat": "聊天" }, "pricing": { "title": "SurfSense 定价", @@ -527,6 +528,8 @@ "percent_complete": "已完成 {percent}%", "add_llm_provider": "添加 LLM 提供商", "assign_llm_roles": "分配 LLM 角色", + "setup_llm_configuration": "设置 LLM 配置", + "configure_providers_and_assign_roles": "添加您的 LLM 提供商并为其分配特定角色", "setup_complete": "设置完成", "configure_first_provider": "配置您的第一个模型提供商", "assign_specific_roles": "为您的 LLM 配置分配特定角色", diff --git a/surfsense_web/stores/chat/chat-ui.atom.ts b/surfsense_web/stores/chat/chat-ui.atom.ts deleted file mode 100644 index 3b7e6794b..000000000 --- a/surfsense_web/stores/chat/chat-ui.atom.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atom } from "jotai"; - -type ChatUIState = { - isChatPannelOpen: boolean; -}; - -export const chatUIAtom = atom({ - isChatPannelOpen: false, -});