Spaces:
Sleeping
Sleeping
Prepare for HF Space deployment
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +187 -0
- .gitignore +8 -0
- CLAUDE.md +2 -2
- Dockerfile +654 -0
- Dockerfile.backend +225 -0
- Dockerfile.frontend +353 -0
- PROJECT_README.md +250 -0
- README.md +198 -147
- commands +6 -3
- docs/HF_SPACE_CONFIG.md +427 -0
- frontend/.dockerignore +221 -0
- frontend/.gitignore +41 -0
- frontend/.gitkeep +0 -0
- frontend/.prettierignore +21 -0
- frontend/README.md +36 -1
- frontend/eslint.config.mjs +120 -0
- frontend/next.config.ts +263 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +56 -0
- frontend/postcss.config.mjs +7 -0
- frontend/prettier.config.js +81 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- frontend/public/next.svg +1 -0
- frontend/public/vercel.svg +1 -0
- frontend/public/window.svg +1 -0
- frontend/src/app/favicon.ico +0 -0
- frontend/src/app/globals.css +438 -0
- frontend/src/app/layout.tsx +124 -0
- frontend/src/app/loading.tsx +30 -0
- frontend/src/app/page.tsx +107 -0
- frontend/src/app/providers.tsx +28 -0
- frontend/src/components/chat/__tests__/__snapshots__/empty-state.test.tsx.snap +368 -0
- frontend/src/components/chat/__tests__/__snapshots__/error-state.test.tsx.snap +222 -0
- frontend/src/components/chat/__tests__/empty-state.test.tsx +375 -0
- frontend/src/components/chat/__tests__/error-state.test.tsx +797 -0
- frontend/src/components/chat/__tests__/provider-toggle.test.tsx +1376 -0
- frontend/src/components/chat/__tests__/source-card.test.tsx +805 -0
- frontend/src/components/chat/__tests__/source-citations.test.tsx +852 -0
- frontend/src/components/chat/chat-container.tsx +905 -0
- frontend/src/components/chat/chat-input.tsx +721 -0
- frontend/src/components/chat/chat-message.tsx +467 -0
- frontend/src/components/chat/code-block.tsx +94 -0
- frontend/src/components/chat/empty-state.tsx +658 -0
- frontend/src/components/chat/error-state.tsx +652 -0
- frontend/src/components/chat/index.ts +189 -0
- frontend/src/components/chat/provider-selector.tsx +359 -0
- frontend/src/components/chat/provider-toggle.tsx +756 -0
- frontend/src/components/chat/sidebar.tsx +284 -0
- frontend/src/components/chat/source-card.tsx +580 -0
.dockerignore
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# .dockerignore - Exclude files from Docker build context
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# This file ensures clean, efficient Docker builds by excluding unnecessary
|
| 5 |
+
# files from the build context. Smaller context = faster builds.
|
| 6 |
+
# =============================================================================
|
| 7 |
+
|
| 8 |
+
# -----------------------------------------------------------------------------
|
| 9 |
+
# Python Artifacts
|
| 10 |
+
# -----------------------------------------------------------------------------
|
| 11 |
+
# Bytecode, compiled files, and Python build artifacts
|
| 12 |
+
__pycache__/
|
| 13 |
+
**/__pycache__/
|
| 14 |
+
*.py[cod]
|
| 15 |
+
*$py.class
|
| 16 |
+
*.so
|
| 17 |
+
.Python
|
| 18 |
+
|
| 19 |
+
# -----------------------------------------------------------------------------
|
| 20 |
+
# Virtual Environments
|
| 21 |
+
# -----------------------------------------------------------------------------
|
| 22 |
+
# Local Python virtual environments - Docker creates its own
|
| 23 |
+
therm_venv/
|
| 24 |
+
venv/
|
| 25 |
+
.venv/
|
| 26 |
+
env/
|
| 27 |
+
ENV/
|
| 28 |
+
.env.local
|
| 29 |
+
|
| 30 |
+
# -----------------------------------------------------------------------------
|
| 31 |
+
# IDE and Editor Files
|
| 32 |
+
# -----------------------------------------------------------------------------
|
| 33 |
+
# Editor-specific settings and temporary files
|
| 34 |
+
.vscode/
|
| 35 |
+
.idea/
|
| 36 |
+
*.swp
|
| 37 |
+
*.swo
|
| 38 |
+
*~
|
| 39 |
+
.project
|
| 40 |
+
.pydevproject
|
| 41 |
+
.settings/
|
| 42 |
+
*.sublime-project
|
| 43 |
+
*.sublime-workspace
|
| 44 |
+
|
| 45 |
+
# -----------------------------------------------------------------------------
|
| 46 |
+
# Version Control
|
| 47 |
+
# -----------------------------------------------------------------------------
|
| 48 |
+
# Git history and configuration (not needed in container)
|
| 49 |
+
.git/
|
| 50 |
+
.gitignore
|
| 51 |
+
.gitattributes
|
| 52 |
+
|
| 53 |
+
# -----------------------------------------------------------------------------
|
| 54 |
+
# Testing and Coverage
|
| 55 |
+
# -----------------------------------------------------------------------------
|
| 56 |
+
# Test files, caches and coverage reports
|
| 57 |
+
tests/
|
| 58 |
+
.pytest_cache/
|
| 59 |
+
htmlcov/
|
| 60 |
+
.coverage
|
| 61 |
+
.coverage.*
|
| 62 |
+
coverage.xml
|
| 63 |
+
*.cover
|
| 64 |
+
.hypothesis/
|
| 65 |
+
.tox/
|
| 66 |
+
.nox/
|
| 67 |
+
|
| 68 |
+
# -----------------------------------------------------------------------------
|
| 69 |
+
# Development Tools
|
| 70 |
+
# -----------------------------------------------------------------------------
|
| 71 |
+
# Linting, type checking, and pre-commit caches
|
| 72 |
+
.pre-commit-config.yaml
|
| 73 |
+
.mypy_cache/
|
| 74 |
+
.ruff_cache/
|
| 75 |
+
.dmypy.json
|
| 76 |
+
dmypy.json
|
| 77 |
+
|
| 78 |
+
# -----------------------------------------------------------------------------
|
| 79 |
+
# Data and Build Pipeline
|
| 80 |
+
# -----------------------------------------------------------------------------
|
| 81 |
+
# Raw data and build scripts (artifacts downloaded at runtime)
|
| 82 |
+
data/
|
| 83 |
+
scripts/
|
| 84 |
+
|
| 85 |
+
# -----------------------------------------------------------------------------
|
| 86 |
+
# Frontend
|
| 87 |
+
# -----------------------------------------------------------------------------
|
| 88 |
+
# Next.js frontend has its own Dockerfile
|
| 89 |
+
frontend/
|
| 90 |
+
|
| 91 |
+
# -----------------------------------------------------------------------------
|
| 92 |
+
# Documentation and Project Files
|
| 93 |
+
# -----------------------------------------------------------------------------
|
| 94 |
+
# Markdown docs, documentation directories, and project-specific files
|
| 95 |
+
*.md
|
| 96 |
+
docs/
|
| 97 |
+
CLAUDE.md
|
| 98 |
+
PROJECT_README.md
|
| 99 |
+
RAG_Chatbot_Plan.md
|
| 100 |
+
IMPLEMENTATION_PLAN.md
|
| 101 |
+
README.rst
|
| 102 |
+
LICENSE
|
| 103 |
+
|
| 104 |
+
# -----------------------------------------------------------------------------
|
| 105 |
+
# Development Files
|
| 106 |
+
# -----------------------------------------------------------------------------
|
| 107 |
+
# Development-only files not needed in production
|
| 108 |
+
commands
|
| 109 |
+
raw_data/
|
| 110 |
+
|
| 111 |
+
# -----------------------------------------------------------------------------
|
| 112 |
+
# Environment and Secrets
|
| 113 |
+
# -----------------------------------------------------------------------------
|
| 114 |
+
# Environment files containing secrets (injected at runtime)
|
| 115 |
+
.env
|
| 116 |
+
.env.*
|
| 117 |
+
*.local
|
| 118 |
+
.secrets
|
| 119 |
+
|
| 120 |
+
# -----------------------------------------------------------------------------
|
| 121 |
+
# Build Artifacts
|
| 122 |
+
# -----------------------------------------------------------------------------
|
| 123 |
+
# Python package build outputs
|
| 124 |
+
dist/
|
| 125 |
+
build/
|
| 126 |
+
*.egg-info/
|
| 127 |
+
*.egg
|
| 128 |
+
wheels/
|
| 129 |
+
pip-wheel-metadata/
|
| 130 |
+
share/python-wheels/
|
| 131 |
+
MANIFEST
|
| 132 |
+
|
| 133 |
+
# -----------------------------------------------------------------------------
|
| 134 |
+
# Jupyter Notebooks
|
| 135 |
+
# -----------------------------------------------------------------------------
|
| 136 |
+
# Notebooks and checkpoints (development only)
|
| 137 |
+
*.ipynb
|
| 138 |
+
.ipynb_checkpoints/
|
| 139 |
+
|
| 140 |
+
# -----------------------------------------------------------------------------
|
| 141 |
+
# Logs
|
| 142 |
+
# -----------------------------------------------------------------------------
|
| 143 |
+
# Log files generated during development
|
| 144 |
+
*.log
|
| 145 |
+
logs/
|
| 146 |
+
log/
|
| 147 |
+
|
| 148 |
+
# -----------------------------------------------------------------------------
|
| 149 |
+
# Docker Files
|
| 150 |
+
# -----------------------------------------------------------------------------
|
| 151 |
+
# Prevent recursive Docker context issues
|
| 152 |
+
Dockerfile*
|
| 153 |
+
docker-compose*
|
| 154 |
+
.docker/
|
| 155 |
+
.dockerignore
|
| 156 |
+
|
| 157 |
+
# -----------------------------------------------------------------------------
|
| 158 |
+
# Miscellaneous
|
| 159 |
+
# -----------------------------------------------------------------------------
|
| 160 |
+
# OS-generated files and other artifacts
|
| 161 |
+
.DS_Store
|
| 162 |
+
Thumbs.db
|
| 163 |
+
*.bak
|
| 164 |
+
*.tmp
|
| 165 |
+
*.temp
|
| 166 |
+
.cache/
|
| 167 |
+
tmp/
|
| 168 |
+
temp/
|
| 169 |
+
|
| 170 |
+
# -----------------------------------------------------------------------------
|
| 171 |
+
# CI/CD Configuration
|
| 172 |
+
# -----------------------------------------------------------------------------
|
| 173 |
+
# CI configuration files not needed in container
|
| 174 |
+
.github/
|
| 175 |
+
.gitlab-ci.yml
|
| 176 |
+
.travis.yml
|
| 177 |
+
.circleci/
|
| 178 |
+
Makefile
|
| 179 |
+
Taskfile.yml
|
| 180 |
+
|
| 181 |
+
# =============================================================================
|
| 182 |
+
# IMPORTANT: Files that ARE included (not ignored):
|
| 183 |
+
# - src/ (application source code)
|
| 184 |
+
# - pyproject.toml (Poetry dependency specification)
|
| 185 |
+
# - poetry.lock (reproducible dependency versions)
|
| 186 |
+
# - py.typed (type hint marker file)
|
| 187 |
+
# =============================================================================
|
.gitignore
CHANGED
|
@@ -152,8 +152,16 @@ cython_debug/
|
|
| 152 |
# User requested exclusions
|
| 153 |
IMPLEMENTATION_PLAN.md
|
| 154 |
RAG_Chatbot_Plan.md
|
|
|
|
|
|
|
|
|
|
| 155 |
data/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
.*/
|
| 157 |
!.gitignore
|
| 158 |
!.pre-commit-config.yaml
|
| 159 |
!.env.example
|
|
|
|
|
|
| 152 |
# User requested exclusions
|
| 153 |
IMPLEMENTATION_PLAN.md
|
| 154 |
RAG_Chatbot_Plan.md
|
| 155 |
+
PROJECT_README.md
|
| 156 |
+
CLAUDE.md
|
| 157 |
+
commands
|
| 158 |
data/
|
| 159 |
+
raw_data/
|
| 160 |
+
tests/
|
| 161 |
+
|
| 162 |
+
# Hidden directories (except specific ones)
|
| 163 |
.*/
|
| 164 |
!.gitignore
|
| 165 |
!.pre-commit-config.yaml
|
| 166 |
!.env.example
|
| 167 |
+
!.dockerignore
|
CLAUDE.md
CHANGED
|
@@ -55,7 +55,7 @@ Downloads prebuilt artifacts from HF dataset, serves FastAPI backend with hybrid
|
|
| 55 |
- `src/rag_chatbot/chunking/` - Structure-aware chunking with heading inheritance
|
| 56 |
- `src/rag_chatbot/embeddings/` - BGE encoder with float16 storage
|
| 57 |
- `src/rag_chatbot/retrieval/` - Hybrid retriever (dense + BM25 with RRF fusion)
|
| 58 |
-
- `src/rag_chatbot/llm/` - Provider registry with fallback: Gemini ->
|
| 59 |
- `src/rag_chatbot/api/` - FastAPI endpoints with SSE streaming
|
| 60 |
- `src/rag_chatbot/qlog/` - Async query logging to HF dataset
|
| 61 |
|
|
@@ -77,7 +77,7 @@ Downloads prebuilt artifacts from HF dataset, serves FastAPI backend with hybrid
|
|
| 77 |
## Environment Variables
|
| 78 |
|
| 79 |
Required secrets for deployment:
|
| 80 |
-
- `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `
|
| 81 |
- `HF_TOKEN` - HuggingFace authentication
|
| 82 |
|
| 83 |
Key configuration:
|
|
|
|
| 55 |
- `src/rag_chatbot/chunking/` - Structure-aware chunking with heading inheritance
|
| 56 |
- `src/rag_chatbot/embeddings/` - BGE encoder with float16 storage
|
| 57 |
- `src/rag_chatbot/retrieval/` - Hybrid retriever (dense + BM25 with RRF fusion)
|
| 58 |
+
- `src/rag_chatbot/llm/` - Provider registry with fallback: Gemini -> DeepSeek -> Anthropic
|
| 59 |
- `src/rag_chatbot/api/` - FastAPI endpoints with SSE streaming
|
| 60 |
- `src/rag_chatbot/qlog/` - Async query logging to HF dataset
|
| 61 |
|
|
|
|
| 77 |
## Environment Variables
|
| 78 |
|
| 79 |
Required secrets for deployment:
|
| 80 |
+
- `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY` - LLM providers
|
| 81 |
- `HF_TOKEN` - HuggingFace authentication
|
| 82 |
|
| 83 |
Key configuration:
|
Dockerfile
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# RAG Chatbot Combined Dockerfile - HuggingFace Spaces Production Deployment
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# This is a multi-stage Dockerfile that combines the Next.js frontend and
|
| 5 |
+
# FastAPI backend into a single container, using nginx as a reverse proxy.
|
| 6 |
+
#
|
| 7 |
+
# Architecture Overview:
|
| 8 |
+
# - External port: 7860 (HuggingFace Spaces requirement)
|
| 9 |
+
# - nginx: Reverse proxy on port 7860
|
| 10 |
+
# - Frontend: Next.js standalone on internal port 3000
|
| 11 |
+
# - Backend: FastAPI/uvicorn on internal port 8000
|
| 12 |
+
#
|
| 13 |
+
# Routing:
|
| 14 |
+
# - /api/* -> Backend (FastAPI) on port 8000
|
| 15 |
+
# - /* -> Frontend (Next.js) on port 3000
|
| 16 |
+
#
|
| 17 |
+
# Stages (4 total):
|
| 18 |
+
# 1. frontend-deps: Install npm dependencies
|
| 19 |
+
# 2. frontend-builder: Build Next.js standalone output
|
| 20 |
+
# 3. backend-builder: Export Python dependencies via Poetry
|
| 21 |
+
# 4. runtime: Final image with nginx + Node.js + Python
|
| 22 |
+
#
|
| 23 |
+
# Target image size: < 1.5GB (no torch, no build dependencies)
|
| 24 |
+
# Base image: python:3.11-slim (Debian-based for nginx availability)
|
| 25 |
+
#
|
| 26 |
+
# Build command:
|
| 27 |
+
# docker build -t rag-chatbot .
|
| 28 |
+
#
|
| 29 |
+
# Run command:
|
| 30 |
+
# docker run -p 7860:7860 \
|
| 31 |
+
# -e GEMINI_API_KEY=xxx \
|
| 32 |
+
# -e HF_TOKEN=xxx \
|
| 33 |
+
# rag-chatbot
|
| 34 |
+
#
|
| 35 |
+
# =============================================================================
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# =============================================================================
|
| 39 |
+
# STAGE 1: FRONTEND DEPENDENCIES
|
| 40 |
+
# =============================================================================
|
| 41 |
+
# Purpose: Install all npm dependencies in an isolated stage
|
| 42 |
+
# This stage uses Node.js Alpine for minimal footprint during dependency install.
|
| 43 |
+
# The node_modules are passed to the builder stage; this stage is discarded.
|
| 44 |
+
# =============================================================================
|
| 45 |
+
FROM node:22-alpine AS frontend-deps
|
| 46 |
+
|
| 47 |
+
# -----------------------------------------------------------------------------
|
| 48 |
+
# Install System Dependencies
|
| 49 |
+
# -----------------------------------------------------------------------------
|
| 50 |
+
# libc6-compat: Required for some native Node.js modules that expect glibc
|
| 51 |
+
# Alpine uses musl libc by default, and this compatibility layer helps with
|
| 52 |
+
# packages that have native bindings compiled against glibc.
|
| 53 |
+
# -----------------------------------------------------------------------------
|
| 54 |
+
RUN apk add --no-cache libc6-compat
|
| 55 |
+
|
| 56 |
+
# -----------------------------------------------------------------------------
|
| 57 |
+
# Set Working Directory
|
| 58 |
+
# -----------------------------------------------------------------------------
|
| 59 |
+
WORKDIR /app
|
| 60 |
+
|
| 61 |
+
# -----------------------------------------------------------------------------
|
| 62 |
+
# Copy Package Files
|
| 63 |
+
# -----------------------------------------------------------------------------
|
| 64 |
+
# Copy only package.json and package-lock.json for optimal layer caching.
|
| 65 |
+
# This layer is cached until these files change, avoiding unnecessary
|
| 66 |
+
# reinstalls when only source code changes.
|
| 67 |
+
# -----------------------------------------------------------------------------
|
| 68 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 69 |
+
|
| 70 |
+
# -----------------------------------------------------------------------------
|
| 71 |
+
# Install Dependencies
|
| 72 |
+
# -----------------------------------------------------------------------------
|
| 73 |
+
# npm ci (Clean Install) is preferred because:
|
| 74 |
+
# - Faster: Uses exact versions from lock file
|
| 75 |
+
# - Reproducible: Guarantees identical dependency tree
|
| 76 |
+
# - Strict: Fails if lock file is out of sync
|
| 77 |
+
# - Clean: Removes existing node_modules before install
|
| 78 |
+
#
|
| 79 |
+
# --prefer-offline: Use cached packages when available (faster in CI/CD)
|
| 80 |
+
# Clean npm cache after install to reduce layer size.
|
| 81 |
+
# -----------------------------------------------------------------------------
|
| 82 |
+
RUN npm ci --prefer-offline && npm cache clean --force
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# =============================================================================
|
| 86 |
+
# STAGE 2: FRONTEND BUILDER
|
| 87 |
+
# =============================================================================
|
| 88 |
+
# Purpose: Build the Next.js application with standalone output
|
| 89 |
+
# This stage compiles TypeScript, bundles assets, and creates the minimal
|
| 90 |
+
# standalone server that will be copied to the runtime image.
|
| 91 |
+
# =============================================================================
|
| 92 |
+
FROM node:22-alpine AS frontend-builder
|
| 93 |
+
|
| 94 |
+
# -----------------------------------------------------------------------------
|
| 95 |
+
# Set Working Directory
|
| 96 |
+
# -----------------------------------------------------------------------------
|
| 97 |
+
WORKDIR /app
|
| 98 |
+
|
| 99 |
+
# -----------------------------------------------------------------------------
|
| 100 |
+
# Copy Dependencies from frontend-deps Stage
|
| 101 |
+
# -----------------------------------------------------------------------------
|
| 102 |
+
# Reuse the installed node_modules to avoid reinstalling.
|
| 103 |
+
# This ensures consistent dependencies across stages.
|
| 104 |
+
# -----------------------------------------------------------------------------
|
| 105 |
+
COPY --from=frontend-deps /app/node_modules ./node_modules
|
| 106 |
+
|
| 107 |
+
# -----------------------------------------------------------------------------
|
| 108 |
+
# Copy Frontend Source Code
|
| 109 |
+
# -----------------------------------------------------------------------------
|
| 110 |
+
# Copy all frontend files needed for the build.
|
| 111 |
+
# The .dockerignore in frontend/ should exclude node_modules, .next, etc.
|
| 112 |
+
# -----------------------------------------------------------------------------
|
| 113 |
+
COPY frontend/ .
|
| 114 |
+
|
| 115 |
+
# -----------------------------------------------------------------------------
|
| 116 |
+
# Build-Time Environment Variables
|
| 117 |
+
# -----------------------------------------------------------------------------
|
| 118 |
+
# NEXT_PUBLIC_API_URL: Empty string = same-origin requests via nginx
|
| 119 |
+
# The frontend will use relative URLs like /api/query, and nginx will
|
| 120 |
+
# proxy these to the backend. This avoids CORS and simplifies deployment.
|
| 121 |
+
#
|
| 122 |
+
# NEXT_TELEMETRY_DISABLED: Prevent telemetry during build
|
| 123 |
+
#
|
| 124 |
+
# NODE_ENV: Production mode for optimized output
|
| 125 |
+
# -----------------------------------------------------------------------------
|
| 126 |
+
ENV NEXT_PUBLIC_API_URL=""
|
| 127 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 128 |
+
ENV NODE_ENV=production
|
| 129 |
+
|
| 130 |
+
# -----------------------------------------------------------------------------
|
| 131 |
+
# Build the Application
|
| 132 |
+
# -----------------------------------------------------------------------------
|
| 133 |
+
# Run Next.js production build. The --webpack flag ensures compatibility
|
| 134 |
+
# with the custom webpack configuration in next.config.ts.
|
| 135 |
+
#
|
| 136 |
+
# Output structure:
|
| 137 |
+
# .next/standalone/ - Minimal Node.js server with bundled deps
|
| 138 |
+
# .next/static/ - Static assets (JS, CSS chunks)
|
| 139 |
+
# public/ - Static files (images, fonts)
|
| 140 |
+
# -----------------------------------------------------------------------------
|
| 141 |
+
RUN npx next build --webpack
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# =============================================================================
|
| 145 |
+
# STAGE 3: BACKEND BUILDER
|
| 146 |
+
# =============================================================================
|
| 147 |
+
# Purpose: Use Poetry to export serve-only dependencies to requirements.txt
|
| 148 |
+
# This stage is discarded after generating the requirements file.
|
| 149 |
+
# We don't need torch, sentence-transformers, or build tools in production.
|
| 150 |
+
# =============================================================================
|
| 151 |
+
FROM python:3.11-slim AS backend-builder
|
| 152 |
+
|
| 153 |
+
# -----------------------------------------------------------------------------
|
| 154 |
+
# Install Poetry
|
| 155 |
+
# -----------------------------------------------------------------------------
|
| 156 |
+
# Poetry manages Python dependencies. We use the official installer for
|
| 157 |
+
# proper isolation. The version is pinned for reproducible builds.
|
| 158 |
+
# -----------------------------------------------------------------------------
|
| 159 |
+
ENV POETRY_VERSION=1.8.2
|
| 160 |
+
ENV POETRY_HOME=/opt/poetry
|
| 161 |
+
ENV PATH="${POETRY_HOME}/bin:${PATH}"
|
| 162 |
+
|
| 163 |
+
# Install Poetry using the official installer script
|
| 164 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 165 |
+
curl \
|
| 166 |
+
&& curl -sSL https://install.python-poetry.org | python3 - \
|
| 167 |
+
&& apt-get purge -y curl \
|
| 168 |
+
&& apt-get autoremove -y \
|
| 169 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 170 |
+
|
| 171 |
+
# -----------------------------------------------------------------------------
|
| 172 |
+
# Set Working Directory
|
| 173 |
+
# -----------------------------------------------------------------------------
|
| 174 |
+
WORKDIR /build
|
| 175 |
+
|
| 176 |
+
# -----------------------------------------------------------------------------
|
| 177 |
+
# Copy Dependency Files
|
| 178 |
+
# -----------------------------------------------------------------------------
|
| 179 |
+
# Copy only pyproject.toml and poetry.lock for dependency resolution.
|
| 180 |
+
# This layer is cached until these files change.
|
| 181 |
+
# -----------------------------------------------------------------------------
|
| 182 |
+
COPY pyproject.toml poetry.lock ./
|
| 183 |
+
|
| 184 |
+
# -----------------------------------------------------------------------------
|
| 185 |
+
# Export Requirements
|
| 186 |
+
# -----------------------------------------------------------------------------
|
| 187 |
+
# Export only main + serve dependencies (no dense, build, or dev groups).
|
| 188 |
+
#
|
| 189 |
+
# INCLUDED:
|
| 190 |
+
# - Core: pydantic, numpy, httpx, tiktoken, rank-bm25, faiss-cpu, etc.
|
| 191 |
+
# - Serve: fastapi, uvicorn, sse-starlette
|
| 192 |
+
#
|
| 193 |
+
# EXCLUDED:
|
| 194 |
+
# - Dense: torch, sentence-transformers (too large, not needed for CPU serving)
|
| 195 |
+
# - Build: pymupdf4llm, pyarrow, datasets (offline pipeline only)
|
| 196 |
+
# - Dev: mypy, ruff, pytest (development tools only)
|
| 197 |
+
#
|
| 198 |
+
# --without-hashes: Required for pip compatibility
|
| 199 |
+
# -----------------------------------------------------------------------------
|
| 200 |
+
RUN poetry export --only main,serve --without-hashes -f requirements.txt -o requirements.txt
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# =============================================================================
|
| 204 |
+
# STAGE 4: RUNTIME
|
| 205 |
+
# =============================================================================
|
| 206 |
+
# Purpose: Final production image with nginx, Node.js, and Python
|
| 207 |
+
# This image serves both frontend and backend through nginx reverse proxy.
|
| 208 |
+
#
|
| 209 |
+
# Components:
|
| 210 |
+
# - nginx: Reverse proxy on port 7860
|
| 211 |
+
# - Node.js: Runs Next.js standalone server on port 3000
|
| 212 |
+
# - Python: Runs FastAPI/uvicorn on port 8000
|
| 213 |
+
#
|
| 214 |
+
# Security:
|
| 215 |
+
# - Non-root nginx worker processes
|
| 216 |
+
# - Minimal installed packages
|
| 217 |
+
# - No build tools or development dependencies
|
| 218 |
+
# =============================================================================
|
| 219 |
+
FROM python:3.11-slim AS runtime
|
| 220 |
+
|
| 221 |
+
# -----------------------------------------------------------------------------
|
| 222 |
+
# Environment Variables
|
| 223 |
+
# -----------------------------------------------------------------------------
|
| 224 |
+
# PYTHONDONTWRITEBYTECODE: Don't write .pyc files (reduces image size)
|
| 225 |
+
# PYTHONUNBUFFERED: Ensure logs appear immediately in container output
|
| 226 |
+
# PYTHONPATH: Add src/ to Python path for module imports
|
| 227 |
+
# NODE_ENV: Production mode for Node.js
|
| 228 |
+
# -----------------------------------------------------------------------------
|
| 229 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 230 |
+
ENV PYTHONUNBUFFERED=1
|
| 231 |
+
ENV PYTHONPATH=/app/backend/src
|
| 232 |
+
ENV NODE_ENV=production
|
| 233 |
+
|
| 234 |
+
# -----------------------------------------------------------------------------
|
| 235 |
+
# Install System Dependencies
|
| 236 |
+
# -----------------------------------------------------------------------------
|
| 237 |
+
# nginx: Reverse proxy server
|
| 238 |
+
# curl: Health checks
|
| 239 |
+
# supervisor: Process manager to run multiple services
|
| 240 |
+
# gnupg: Required for adding NodeSource GPG key
|
| 241 |
+
# ca-certificates: SSL certificates for HTTPS
|
| 242 |
+
#
|
| 243 |
+
# Node.js 22.x is installed from NodeSource repository for Debian.
|
| 244 |
+
# This provides an up-to-date Node.js version compatible with Next.js.
|
| 245 |
+
# -----------------------------------------------------------------------------
|
| 246 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 247 |
+
nginx \
|
| 248 |
+
curl \
|
| 249 |
+
supervisor \
|
| 250 |
+
gnupg \
|
| 251 |
+
ca-certificates \
|
| 252 |
+
# Add NodeSource repository for Node.js 22.x
|
| 253 |
+
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
| 254 |
+
&& apt-get install -y --no-install-recommends nodejs \
|
| 255 |
+
# Clean up apt cache to reduce image size
|
| 256 |
+
&& apt-get clean \
|
| 257 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 258 |
+
|
| 259 |
+
# -----------------------------------------------------------------------------
|
| 260 |
+
# Create Application User
|
| 261 |
+
# -----------------------------------------------------------------------------
|
| 262 |
+
# Create a non-root user for running the application services.
|
| 263 |
+
# Using UID/GID 1000 for compatibility with common host systems.
|
| 264 |
+
#
|
| 265 |
+
# Note: nginx master process runs as root (required for port binding),
|
| 266 |
+
# but worker processes run as www-data (configured in nginx.conf).
|
| 267 |
+
# -----------------------------------------------------------------------------
|
| 268 |
+
RUN groupadd --gid 1000 appgroup \
|
| 269 |
+
&& useradd --uid 1000 --gid appgroup --shell /bin/false --no-create-home appuser
|
| 270 |
+
|
| 271 |
+
# -----------------------------------------------------------------------------
|
| 272 |
+
# Set Working Directory
|
| 273 |
+
# -----------------------------------------------------------------------------
|
| 274 |
+
WORKDIR /app
|
| 275 |
+
|
| 276 |
+
# -----------------------------------------------------------------------------
|
| 277 |
+
# Copy Python Requirements from Backend Builder
|
| 278 |
+
# -----------------------------------------------------------------------------
|
| 279 |
+
# The requirements.txt was generated by Poetry and contains only serve deps.
|
| 280 |
+
# -----------------------------------------------------------------------------
|
| 281 |
+
COPY --from=backend-builder /build/requirements.txt ./requirements.txt
|
| 282 |
+
|
| 283 |
+
# -----------------------------------------------------------------------------
|
| 284 |
+
# Install Python Dependencies
|
| 285 |
+
# -----------------------------------------------------------------------------
|
| 286 |
+
# Install all serve dependencies using pip.
|
| 287 |
+
# Flags:
|
| 288 |
+
# --no-cache-dir: Don't cache packages (reduces image size)
|
| 289 |
+
# --no-compile: Don't compile .py to .pyc (PYTHONDONTWRITEBYTECODE handles this)
|
| 290 |
+
# -----------------------------------------------------------------------------
|
| 291 |
+
RUN pip install --no-cache-dir --no-compile -r requirements.txt
|
| 292 |
+
|
| 293 |
+
# -----------------------------------------------------------------------------
|
| 294 |
+
# Copy Backend Source Code
|
| 295 |
+
# -----------------------------------------------------------------------------
|
| 296 |
+
# Copy the Python source code to /app/backend/src/
|
| 297 |
+
# PYTHONPATH=/app/backend/src allows importing rag_chatbot module
|
| 298 |
+
# -----------------------------------------------------------------------------
|
| 299 |
+
COPY src/ /app/backend/src/
|
| 300 |
+
|
| 301 |
+
# -----------------------------------------------------------------------------
|
| 302 |
+
# Copy Frontend Standalone Build
|
| 303 |
+
# -----------------------------------------------------------------------------
|
| 304 |
+
# Copy the Next.js standalone output from the frontend-builder stage.
|
| 305 |
+
# Structure:
|
| 306 |
+
# /app/frontend/server.js - Next.js production server
|
| 307 |
+
# /app/frontend/.next/ - Compiled application
|
| 308 |
+
# /app/frontend/node_modules/ - Minimal runtime dependencies
|
| 309 |
+
# /app/frontend/public/ - Static assets
|
| 310 |
+
# -----------------------------------------------------------------------------
|
| 311 |
+
COPY --from=frontend-builder /app/public /app/frontend/public
|
| 312 |
+
COPY --from=frontend-builder /app/.next/standalone /app/frontend
|
| 313 |
+
COPY --from=frontend-builder /app/.next/static /app/frontend/.next/static
|
| 314 |
+
|
| 315 |
+
# -----------------------------------------------------------------------------
|
| 316 |
+
# Create nginx Configuration
|
| 317 |
+
# -----------------------------------------------------------------------------
|
| 318 |
+
# Configure nginx as reverse proxy:
|
| 319 |
+
# - Listen on port 7860 (HuggingFace Spaces requirement)
|
| 320 |
+
# - Proxy /api/* requests to backend on port 8000
|
| 321 |
+
# - Proxy all other requests to frontend on port 3000
|
| 322 |
+
# - Enable gzip compression for text content
|
| 323 |
+
# - Configure appropriate timeouts for SSE streaming
|
| 324 |
+
# -----------------------------------------------------------------------------
|
| 325 |
+
RUN cat > /etc/nginx/nginx.conf << 'EOF'
|
| 326 |
+
# =============================================================================
|
| 327 |
+
# nginx Configuration for RAG Chatbot
|
| 328 |
+
# =============================================================================
|
| 329 |
+
# This configuration sets up nginx as a reverse proxy for the combined
|
| 330 |
+
# frontend (Next.js) and backend (FastAPI) application.
|
| 331 |
+
# =============================================================================
|
| 332 |
+
|
| 333 |
+
# Run nginx master process as root (required for port binding)
|
| 334 |
+
# Worker processes run as www-data for security
|
| 335 |
+
user www-data;
|
| 336 |
+
|
| 337 |
+
# Auto-detect number of CPU cores for worker processes
|
| 338 |
+
worker_processes auto;
|
| 339 |
+
|
| 340 |
+
# Error log location and level
|
| 341 |
+
error_log /var/log/nginx/error.log warn;
|
| 342 |
+
|
| 343 |
+
# PID file location
|
| 344 |
+
pid /var/run/nginx.pid;
|
| 345 |
+
|
| 346 |
+
# -----------------------------------------------------------------------------
|
| 347 |
+
# Events Configuration
|
| 348 |
+
# -----------------------------------------------------------------------------
|
| 349 |
+
events {
|
| 350 |
+
# Maximum simultaneous connections per worker
|
| 351 |
+
worker_connections 1024;
|
| 352 |
+
|
| 353 |
+
# Use epoll for better performance on Linux
|
| 354 |
+
use epoll;
|
| 355 |
+
|
| 356 |
+
# Accept multiple connections at once
|
| 357 |
+
multi_accept on;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
# -----------------------------------------------------------------------------
|
| 361 |
+
# HTTP Configuration
|
| 362 |
+
# -----------------------------------------------------------------------------
|
| 363 |
+
http {
|
| 364 |
+
# Include MIME types for proper content-type headers
|
| 365 |
+
include /etc/nginx/mime.types;
|
| 366 |
+
default_type application/octet-stream;
|
| 367 |
+
|
| 368 |
+
# Logging format
|
| 369 |
+
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
| 370 |
+
'$status $body_bytes_sent "$http_referer" '
|
| 371 |
+
'"$http_user_agent" "$http_x_forwarded_for"';
|
| 372 |
+
|
| 373 |
+
access_log /var/log/nginx/access.log main;
|
| 374 |
+
|
| 375 |
+
# Performance optimizations
|
| 376 |
+
sendfile on;
|
| 377 |
+
tcp_nopush on;
|
| 378 |
+
tcp_nodelay on;
|
| 379 |
+
|
| 380 |
+
# Keep-alive settings
|
| 381 |
+
keepalive_timeout 65;
|
| 382 |
+
|
| 383 |
+
# Gzip compression for text content
|
| 384 |
+
gzip on;
|
| 385 |
+
gzip_vary on;
|
| 386 |
+
gzip_proxied any;
|
| 387 |
+
gzip_comp_level 6;
|
| 388 |
+
gzip_types text/plain text/css text/xml application/json application/javascript
|
| 389 |
+
application/xml application/xml+rss text/javascript application/x-javascript;
|
| 390 |
+
|
| 391 |
+
# Upstream definitions for backend and frontend
|
| 392 |
+
upstream backend {
|
| 393 |
+
server 127.0.0.1:8000;
|
| 394 |
+
keepalive 32;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
upstream frontend {
|
| 398 |
+
server 127.0.0.1:3000;
|
| 399 |
+
keepalive 32;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
# -------------------------------------------------------------------------
|
| 403 |
+
# Main Server Block
|
| 404 |
+
# -------------------------------------------------------------------------
|
| 405 |
+
server {
|
| 406 |
+
# Listen on port 7860 (HuggingFace Spaces requirement)
|
| 407 |
+
listen 7860;
|
| 408 |
+
server_name _;
|
| 409 |
+
|
| 410 |
+
# Client body size limit (for potential file uploads)
|
| 411 |
+
client_max_body_size 10M;
|
| 412 |
+
|
| 413 |
+
# ---------------------------------------------------------------------
|
| 414 |
+
# Backend API Routes
|
| 415 |
+
# ---------------------------------------------------------------------
|
| 416 |
+
# Proxy all /api/* requests to the FastAPI backend
|
| 417 |
+
# This includes /api/query, /api/health, etc.
|
| 418 |
+
# ---------------------------------------------------------------------
|
| 419 |
+
location /api/ {
|
| 420 |
+
proxy_pass http://backend/;
|
| 421 |
+
proxy_http_version 1.1;
|
| 422 |
+
|
| 423 |
+
# Headers for proper proxying
|
| 424 |
+
proxy_set_header Host $host;
|
| 425 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 426 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 427 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 428 |
+
|
| 429 |
+
# Connection upgrade for WebSocket (if needed in future)
|
| 430 |
+
proxy_set_header Connection "";
|
| 431 |
+
|
| 432 |
+
# Timeouts for long-running requests (SSE streaming)
|
| 433 |
+
# These are generous to support streaming LLM responses
|
| 434 |
+
proxy_connect_timeout 60s;
|
| 435 |
+
proxy_send_timeout 300s;
|
| 436 |
+
proxy_read_timeout 300s;
|
| 437 |
+
|
| 438 |
+
# Disable buffering for SSE (Server-Sent Events)
|
| 439 |
+
# This ensures streaming responses are sent immediately
|
| 440 |
+
proxy_buffering off;
|
| 441 |
+
proxy_cache off;
|
| 442 |
+
|
| 443 |
+
# SSE-specific headers
|
| 444 |
+
proxy_set_header Accept-Encoding "";
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
# ---------------------------------------------------------------------
|
| 448 |
+
# Health Check Endpoint (direct to backend)
|
| 449 |
+
# ---------------------------------------------------------------------
|
| 450 |
+
# Allow direct access to health endpoints for container orchestration
|
| 451 |
+
# ---------------------------------------------------------------------
|
| 452 |
+
location /health {
|
| 453 |
+
proxy_pass http://backend/health;
|
| 454 |
+
proxy_http_version 1.1;
|
| 455 |
+
proxy_set_header Host $host;
|
| 456 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 457 |
+
proxy_set_header Connection "";
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
# ---------------------------------------------------------------------
|
| 461 |
+
# Frontend Routes (catch-all)
|
| 462 |
+
# ---------------------------------------------------------------------
|
| 463 |
+
# Proxy all other requests to the Next.js frontend
|
| 464 |
+
# This includes pages, static assets, and _next/* resources
|
| 465 |
+
# ---------------------------------------------------------------------
|
| 466 |
+
location / {
|
| 467 |
+
proxy_pass http://frontend;
|
| 468 |
+
proxy_http_version 1.1;
|
| 469 |
+
|
| 470 |
+
# Headers for proper proxying
|
| 471 |
+
proxy_set_header Host $host;
|
| 472 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 473 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 474 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 475 |
+
proxy_set_header Connection "";
|
| 476 |
+
|
| 477 |
+
# Standard timeouts for frontend
|
| 478 |
+
proxy_connect_timeout 60s;
|
| 479 |
+
proxy_send_timeout 60s;
|
| 480 |
+
proxy_read_timeout 60s;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
# ---------------------------------------------------------------------
|
| 484 |
+
# Static Files Caching
|
| 485 |
+
# ---------------------------------------------------------------------
|
| 486 |
+
# Cache static assets with long expiry for performance
|
| 487 |
+
# Next.js includes content hashes in filenames, so this is safe
|
| 488 |
+
# ---------------------------------------------------------------------
|
| 489 |
+
location /_next/static {
|
| 490 |
+
proxy_pass http://frontend;
|
| 491 |
+
proxy_http_version 1.1;
|
| 492 |
+
proxy_set_header Host $host;
|
| 493 |
+
proxy_set_header Connection "";
|
| 494 |
+
|
| 495 |
+
# Cache static assets for 1 year (immutable content-hashed files)
|
| 496 |
+
expires 1y;
|
| 497 |
+
add_header Cache-Control "public, immutable";
|
| 498 |
+
}
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
EOF
|
| 502 |
+
|
| 503 |
+
# -----------------------------------------------------------------------------
|
| 504 |
+
# Create Supervisor Configuration
|
| 505 |
+
# -----------------------------------------------------------------------------
|
| 506 |
+
# Supervisor manages multiple processes in a single container:
|
| 507 |
+
# - nginx: Reverse proxy (master process)
|
| 508 |
+
# - frontend: Next.js server (node process)
|
| 509 |
+
# - backend: FastAPI/uvicorn (python process)
|
| 510 |
+
#
|
| 511 |
+
# All processes are started together and supervisor monitors their health.
|
| 512 |
+
# If any process crashes, supervisor will restart it automatically.
|
| 513 |
+
# -----------------------------------------------------------------------------
|
| 514 |
+
RUN cat > /etc/supervisor/conf.d/supervisord.conf << 'EOF'
|
| 515 |
+
; =============================================================================
|
| 516 |
+
; Supervisor Configuration for RAG Chatbot
|
| 517 |
+
; =============================================================================
|
| 518 |
+
; Manages nginx, frontend (Next.js), and backend (FastAPI) processes.
|
| 519 |
+
; All processes run in the foreground (nodaemon=true).
|
| 520 |
+
; =============================================================================
|
| 521 |
+
|
| 522 |
+
[supervisord]
|
| 523 |
+
nodaemon=true
|
| 524 |
+
user=root
|
| 525 |
+
logfile=/var/log/supervisor/supervisord.log
|
| 526 |
+
pidfile=/var/run/supervisord.pid
|
| 527 |
+
childlogdir=/var/log/supervisor
|
| 528 |
+
|
| 529 |
+
; -----------------------------------------------------------------------------
|
| 530 |
+
; nginx - Reverse Proxy
|
| 531 |
+
; -----------------------------------------------------------------------------
|
| 532 |
+
; nginx master process must run as root for port binding.
|
| 533 |
+
; Worker processes run as www-data (configured in nginx.conf).
|
| 534 |
+
; -----------------------------------------------------------------------------
|
| 535 |
+
[program:nginx]
|
| 536 |
+
command=/usr/sbin/nginx -g "daemon off;"
|
| 537 |
+
autostart=true
|
| 538 |
+
autorestart=true
|
| 539 |
+
priority=10
|
| 540 |
+
stdout_logfile=/dev/stdout
|
| 541 |
+
stdout_logfile_maxbytes=0
|
| 542 |
+
stderr_logfile=/dev/stderr
|
| 543 |
+
stderr_logfile_maxbytes=0
|
| 544 |
+
|
| 545 |
+
; -----------------------------------------------------------------------------
|
| 546 |
+
; Frontend - Next.js Server
|
| 547 |
+
; -----------------------------------------------------------------------------
|
| 548 |
+
; Runs the Next.js standalone server on port 3000.
|
| 549 |
+
; The standalone server is minimal and includes only required dependencies.
|
| 550 |
+
; -----------------------------------------------------------------------------
|
| 551 |
+
[program:frontend]
|
| 552 |
+
command=node /app/frontend/server.js
|
| 553 |
+
directory=/app/frontend
|
| 554 |
+
autostart=true
|
| 555 |
+
autorestart=true
|
| 556 |
+
priority=20
|
| 557 |
+
user=appuser
|
| 558 |
+
environment=NODE_ENV="production",PORT="3000",HOSTNAME="0.0.0.0"
|
| 559 |
+
stdout_logfile=/dev/stdout
|
| 560 |
+
stdout_logfile_maxbytes=0
|
| 561 |
+
stderr_logfile=/dev/stderr
|
| 562 |
+
stderr_logfile_maxbytes=0
|
| 563 |
+
|
| 564 |
+
; -----------------------------------------------------------------------------
|
| 565 |
+
; Backend - FastAPI/Uvicorn Server
|
| 566 |
+
; -----------------------------------------------------------------------------
|
| 567 |
+
; Runs the FastAPI application on port 8000.
|
| 568 |
+
; Uses the app factory pattern for proper initialization.
|
| 569 |
+
;
|
| 570 |
+
; LLM Provider API Keys (inherited from container environment):
|
| 571 |
+
; - GEMINI_API_KEY: Google Gemini API (primary provider)
|
| 572 |
+
; - DEEPSEEK_API_KEY: DeepSeek API (fallback provider)
|
| 573 |
+
; - ANTHROPIC_API_KEY: Anthropic API (fallback provider)
|
| 574 |
+
; - GROQ_API_KEY: Groq API (optional provider)
|
| 575 |
+
; - HF_TOKEN: HuggingFace token for dataset access and logging
|
| 576 |
+
;
|
| 577 |
+
; Note: The environment= directive ADDS to the inherited environment.
|
| 578 |
+
; API keys set via "docker run -e" or HuggingFace Spaces secrets are
|
| 579 |
+
; automatically passed through to this process without explicit listing.
|
| 580 |
+
; -----------------------------------------------------------------------------
|
| 581 |
+
[program:backend]
|
| 582 |
+
command=python -m uvicorn rag_chatbot.api.main:create_app --factory --host 0.0.0.0 --port 8000
|
| 583 |
+
directory=/app/backend
|
| 584 |
+
autostart=true
|
| 585 |
+
autorestart=true
|
| 586 |
+
priority=30
|
| 587 |
+
user=appuser
|
| 588 |
+
environment=PYTHONPATH="/app/backend/src",PYTHONDONTWRITEBYTECODE="1",PYTHONUNBUFFERED="1"
|
| 589 |
+
stdout_logfile=/dev/stdout
|
| 590 |
+
stdout_logfile_maxbytes=0
|
| 591 |
+
stderr_logfile=/dev/stderr
|
| 592 |
+
stderr_logfile_maxbytes=0
|
| 593 |
+
EOF
|
| 594 |
+
|
| 595 |
+
# -----------------------------------------------------------------------------
|
| 596 |
+
# Create Log Directories
|
| 597 |
+
# -----------------------------------------------------------------------------
|
| 598 |
+
# Create directories for nginx and supervisor logs with proper permissions.
|
| 599 |
+
# -----------------------------------------------------------------------------
|
| 600 |
+
RUN mkdir -p /var/log/nginx /var/log/supervisor \
|
| 601 |
+
&& chown -R www-data:www-data /var/log/nginx \
|
| 602 |
+
&& chmod -R 755 /var/log/supervisor
|
| 603 |
+
|
| 604 |
+
# -----------------------------------------------------------------------------
|
| 605 |
+
# Set Permissions
|
| 606 |
+
# -----------------------------------------------------------------------------
|
| 607 |
+
# Ensure appuser can read application files and write to necessary locations.
|
| 608 |
+
# nginx runs as www-data, frontend/backend run as appuser.
|
| 609 |
+
# -----------------------------------------------------------------------------
|
| 610 |
+
RUN chown -R appuser:appgroup /app/frontend /app/backend
|
| 611 |
+
|
| 612 |
+
# -----------------------------------------------------------------------------
|
| 613 |
+
# Expose Port
|
| 614 |
+
# -----------------------------------------------------------------------------
|
| 615 |
+
# HuggingFace Spaces expects the application to listen on port 7860.
|
| 616 |
+
# All traffic flows through nginx on this port.
|
| 617 |
+
# -----------------------------------------------------------------------------
|
| 618 |
+
EXPOSE 7860
|
| 619 |
+
|
| 620 |
+
# -----------------------------------------------------------------------------
|
| 621 |
+
# Health Check
|
| 622 |
+
# -----------------------------------------------------------------------------
|
| 623 |
+
# Docker health check for container orchestration.
|
| 624 |
+
# Checks the /health endpoint via nginx, which proxies to the backend.
|
| 625 |
+
#
|
| 626 |
+
# Options:
|
| 627 |
+
# --interval=30s: Check every 30 seconds
|
| 628 |
+
# --timeout=10s: Wait up to 10 seconds for response
|
| 629 |
+
# --start-period=60s: Grace period for all services to start
|
| 630 |
+
# --retries=3: Mark unhealthy after 3 consecutive failures
|
| 631 |
+
#
|
| 632 |
+
# Note: Using /health/ready because it returns 200 when ready, 503 when loading.
|
| 633 |
+
# The start-period is longer (60s) because we need nginx + frontend + backend
|
| 634 |
+
# to all be ready before the health check can pass.
|
| 635 |
+
# -----------------------------------------------------------------------------
|
| 636 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 637 |
+
CMD curl --fail http://localhost:7860/health/ready || exit 1
|
| 638 |
+
|
| 639 |
+
# -----------------------------------------------------------------------------
|
| 640 |
+
# Entrypoint
|
| 641 |
+
# -----------------------------------------------------------------------------
|
| 642 |
+
# Start supervisor, which manages all three services:
|
| 643 |
+
# 1. nginx (reverse proxy on port 7860)
|
| 644 |
+
# 2. frontend (Next.js on port 3000)
|
| 645 |
+
# 3. backend (FastAPI on port 8000)
|
| 646 |
+
#
|
| 647 |
+
# Supervisor runs in the foreground (nodaemon=true) as the main process.
|
| 648 |
+
# It monitors all child processes and restarts them if they crash.
|
| 649 |
+
#
|
| 650 |
+
# Environment variables (GEMINI_API_KEY, HF_TOKEN, etc.) should be passed
|
| 651 |
+
# at runtime via docker run -e or configured in HuggingFace Spaces secrets.
|
| 652 |
+
# Supervisor passes them through to the backend process.
|
| 653 |
+
# -----------------------------------------------------------------------------
|
| 654 |
+
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
Dockerfile.backend
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# RAG Chatbot Backend - Production Dockerfile
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# This is a multi-stage Dockerfile optimized for HuggingFace Spaces deployment.
|
| 5 |
+
# It creates a minimal runtime image that excludes heavy build-time dependencies
|
| 6 |
+
# like torch, sentence-transformers, and pyarrow.
|
| 7 |
+
#
|
| 8 |
+
# Architecture:
|
| 9 |
+
# Stage 1 (builder): Installs Poetry and exports serve-only dependencies
|
| 10 |
+
# Stage 2 (runtime): Minimal image with only runtime dependencies
|
| 11 |
+
#
|
| 12 |
+
# Target image size: < 2GB
|
| 13 |
+
# Base image: python:3.11-slim (Debian-based, ~150MB)
|
| 14 |
+
# =============================================================================
|
| 15 |
+
|
| 16 |
+
# =============================================================================
|
| 17 |
+
# STAGE 1: BUILDER
|
| 18 |
+
# =============================================================================
|
| 19 |
+
# Purpose: Use Poetry to export a clean requirements.txt for serve dependencies
|
| 20 |
+
# This stage is discarded after build, so Poetry overhead doesn't affect runtime
|
| 21 |
+
# =============================================================================
|
| 22 |
+
FROM python:3.11-slim AS builder
|
| 23 |
+
|
| 24 |
+
# -----------------------------------------------------------------------------
|
| 25 |
+
# Install Poetry
|
| 26 |
+
# -----------------------------------------------------------------------------
|
| 27 |
+
# Poetry is used to manage dependencies and export requirements.txt
|
| 28 |
+
# We pin the version for reproducible builds
|
| 29 |
+
# Using pipx isolation avoids polluting the system Python
|
| 30 |
+
# -----------------------------------------------------------------------------
|
| 31 |
+
ENV POETRY_VERSION=1.8.2
|
| 32 |
+
ENV POETRY_HOME=/opt/poetry
|
| 33 |
+
ENV PATH="${POETRY_HOME}/bin:${PATH}"
|
| 34 |
+
|
| 35 |
+
# Install Poetry using the official installer script
|
| 36 |
+
# This method is recommended over pip install for isolation
|
| 37 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 38 |
+
curl \
|
| 39 |
+
&& curl -sSL https://install.python-poetry.org | python3 - \
|
| 40 |
+
&& apt-get purge -y curl \
|
| 41 |
+
&& apt-get autoremove -y \
|
| 42 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 43 |
+
|
| 44 |
+
# -----------------------------------------------------------------------------
|
| 45 |
+
# Set Working Directory
|
| 46 |
+
# -----------------------------------------------------------------------------
|
| 47 |
+
WORKDIR /build
|
| 48 |
+
|
| 49 |
+
# -----------------------------------------------------------------------------
|
| 50 |
+
# Copy Dependency Files
|
| 51 |
+
# -----------------------------------------------------------------------------
|
| 52 |
+
# Copy only the files needed for dependency resolution
|
| 53 |
+
# This layer is cached until pyproject.toml or poetry.lock changes
|
| 54 |
+
# -----------------------------------------------------------------------------
|
| 55 |
+
COPY pyproject.toml poetry.lock ./
|
| 56 |
+
|
| 57 |
+
# -----------------------------------------------------------------------------
|
| 58 |
+
# Export Requirements
|
| 59 |
+
# -----------------------------------------------------------------------------
|
| 60 |
+
# Export only the serve group dependencies to requirements.txt
|
| 61 |
+
# Flags explained:
|
| 62 |
+
# --only main,serve : Include core deps + serve group (FastAPI, uvicorn, SSE)
|
| 63 |
+
# --without-hashes : Omit hashes for compatibility with pip install
|
| 64 |
+
# -f requirements.txt: Output to requirements.txt file
|
| 65 |
+
#
|
| 66 |
+
# IMPORTANT: This excludes:
|
| 67 |
+
# - dense group (torch, sentence-transformers) - not needed at runtime
|
| 68 |
+
# - build group (pymupdf4llm, pyarrow, etc.) - offline pipeline only
|
| 69 |
+
# - dev group (mypy, ruff, pytest) - development tools only
|
| 70 |
+
# -----------------------------------------------------------------------------
|
| 71 |
+
RUN poetry export --only main,serve --without-hashes -f requirements.txt -o requirements.txt
|
| 72 |
+
|
| 73 |
+
# =============================================================================
|
| 74 |
+
# STAGE 2: RUNTIME
|
| 75 |
+
# =============================================================================
|
| 76 |
+
# Purpose: Minimal production image with only serve dependencies
|
| 77 |
+
# This is the final image that runs on HuggingFace Spaces
|
| 78 |
+
# =============================================================================
|
| 79 |
+
FROM python:3.11-slim AS runtime
|
| 80 |
+
|
| 81 |
+
# -----------------------------------------------------------------------------
|
| 82 |
+
# Environment Variables
|
| 83 |
+
# -----------------------------------------------------------------------------
|
| 84 |
+
# PYTHONDONTWRITEBYTECODE: Prevents Python from writing .pyc bytecode files
|
| 85 |
+
# - Reduces image size slightly
|
| 86 |
+
# - Avoids permission issues with read-only filesystems
|
| 87 |
+
#
|
| 88 |
+
# PYTHONUNBUFFERED: Forces stdout/stderr to be unbuffered
|
| 89 |
+
# - Ensures log messages appear immediately in container logs
|
| 90 |
+
# - Critical for debugging and monitoring in production
|
| 91 |
+
#
|
| 92 |
+
# PYTHONPATH: Adds src/ to Python's module search path
|
| 93 |
+
# - Allows importing rag_chatbot package without installation
|
| 94 |
+
# - Matches the src layout convention used in the project
|
| 95 |
+
# -----------------------------------------------------------------------------
|
| 96 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 97 |
+
ENV PYTHONUNBUFFERED=1
|
| 98 |
+
ENV PYTHONPATH=/app/src
|
| 99 |
+
|
| 100 |
+
# -----------------------------------------------------------------------------
|
| 101 |
+
# Create Non-Root User
|
| 102 |
+
# -----------------------------------------------------------------------------
|
| 103 |
+
# Security best practice: Run the application as a non-root user
|
| 104 |
+
# This limits the impact of potential security vulnerabilities
|
| 105 |
+
#
|
| 106 |
+
# - Create group 'appgroup' with GID 1000
|
| 107 |
+
# - Create user 'appuser' with UID 1000 in that group
|
| 108 |
+
# - No home directory needed (-M), no login shell (-s /bin/false)
|
| 109 |
+
# -----------------------------------------------------------------------------
|
| 110 |
+
RUN groupadd --gid 1000 appgroup \
|
| 111 |
+
&& useradd --uid 1000 --gid appgroup --shell /bin/false --no-create-home appuser
|
| 112 |
+
|
| 113 |
+
# -----------------------------------------------------------------------------
|
| 114 |
+
# Install System Dependencies
|
| 115 |
+
# -----------------------------------------------------------------------------
|
| 116 |
+
# Install curl for health checks and clean up apt cache to reduce image size
|
| 117 |
+
# The health check endpoint will be probed using curl
|
| 118 |
+
# -----------------------------------------------------------------------------
|
| 119 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 120 |
+
curl \
|
| 121 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 122 |
+
|
| 123 |
+
# -----------------------------------------------------------------------------
|
| 124 |
+
# Set Working Directory
|
| 125 |
+
# -----------------------------------------------------------------------------
|
| 126 |
+
WORKDIR /app
|
| 127 |
+
|
| 128 |
+
# -----------------------------------------------------------------------------
|
| 129 |
+
# Copy Requirements from Builder Stage
|
| 130 |
+
# -----------------------------------------------------------------------------
|
| 131 |
+
# The requirements.txt was generated by Poetry in the builder stage
|
| 132 |
+
# It contains only the serve dependencies (no torch, no build deps)
|
| 133 |
+
# -----------------------------------------------------------------------------
|
| 134 |
+
COPY --from=builder /build/requirements.txt .
|
| 135 |
+
|
| 136 |
+
# -----------------------------------------------------------------------------
|
| 137 |
+
# Install Python Dependencies
|
| 138 |
+
# -----------------------------------------------------------------------------
|
| 139 |
+
# Install all serve dependencies using pip
|
| 140 |
+
# Flags explained:
|
| 141 |
+
# --no-cache-dir : Don't cache pip packages (reduces image size)
|
| 142 |
+
# --no-compile : Don't compile .py to .pyc (PYTHONDONTWRITEBYTECODE=1 handles this)
|
| 143 |
+
# -r requirements.txt : Install from the exported requirements
|
| 144 |
+
#
|
| 145 |
+
# This installs:
|
| 146 |
+
# - Core deps: pydantic, numpy, httpx, tiktoken, rank-bm25, faiss-cpu, etc.
|
| 147 |
+
# - Serve deps: fastapi, uvicorn, sse-starlette
|
| 148 |
+
#
|
| 149 |
+
# This does NOT install:
|
| 150 |
+
# - torch, sentence-transformers (dense group)
|
| 151 |
+
# - pymupdf4llm, pyarrow, datasets (build group)
|
| 152 |
+
# - mypy, ruff, pytest (dev group)
|
| 153 |
+
# -----------------------------------------------------------------------------
|
| 154 |
+
RUN pip install --no-cache-dir --no-compile -r requirements.txt
|
| 155 |
+
|
| 156 |
+
# -----------------------------------------------------------------------------
|
| 157 |
+
# Copy Application Source Code
|
| 158 |
+
# -----------------------------------------------------------------------------
|
| 159 |
+
# Copy the source code from the host to the container
|
| 160 |
+
# The src/rag_chatbot/ directory contains the application package
|
| 161 |
+
# PYTHONPATH=/app/src allows Python to find the rag_chatbot module
|
| 162 |
+
# -----------------------------------------------------------------------------
|
| 163 |
+
COPY src/ /app/src/
|
| 164 |
+
|
| 165 |
+
# -----------------------------------------------------------------------------
|
| 166 |
+
# Set Ownership
|
| 167 |
+
# -----------------------------------------------------------------------------
|
| 168 |
+
# Change ownership of the application directory to the non-root user
|
| 169 |
+
# This ensures the application can read its own files when running as appuser
|
| 170 |
+
# -----------------------------------------------------------------------------
|
| 171 |
+
RUN chown -R appuser:appgroup /app
|
| 172 |
+
|
| 173 |
+
# -----------------------------------------------------------------------------
|
| 174 |
+
# Switch to Non-Root User
|
| 175 |
+
# -----------------------------------------------------------------------------
|
| 176 |
+
# From this point on, all commands run as appuser (UID 1000)
|
| 177 |
+
# This is the user that will run the application in production
|
| 178 |
+
# -----------------------------------------------------------------------------
|
| 179 |
+
USER appuser
|
| 180 |
+
|
| 181 |
+
# -----------------------------------------------------------------------------
|
| 182 |
+
# Expose Port
|
| 183 |
+
# -----------------------------------------------------------------------------
|
| 184 |
+
# HuggingFace Spaces expects the application to listen on port 7860
|
| 185 |
+
# This documents the port but doesn't actually publish it (done at runtime)
|
| 186 |
+
# -----------------------------------------------------------------------------
|
| 187 |
+
EXPOSE 7860
|
| 188 |
+
|
| 189 |
+
# -----------------------------------------------------------------------------
|
| 190 |
+
# Health Check
|
| 191 |
+
# -----------------------------------------------------------------------------
|
| 192 |
+
# Docker health check configuration for container orchestration
|
| 193 |
+
# This allows Docker/HF Spaces to know if the application is healthy
|
| 194 |
+
#
|
| 195 |
+
# --interval=30s : Check every 30 seconds
|
| 196 |
+
# --timeout=10s : Wait up to 10 seconds for response
|
| 197 |
+
# --start-period=40s : Grace period for startup (resources load lazily on first request)
|
| 198 |
+
# --retries=3 : Mark unhealthy after 3 consecutive failures
|
| 199 |
+
#
|
| 200 |
+
# Health endpoint paths:
|
| 201 |
+
# /health/ready - Simple readiness probe (200 OK when ready, 503 when loading)
|
| 202 |
+
# /health/health - Full health status with version and component details
|
| 203 |
+
#
|
| 204 |
+
# Note: Using /health/ready because it returns 200/503 which curl --fail can detect.
|
| 205 |
+
# The endpoint returns 503 while resources are loading, which is expected during
|
| 206 |
+
# the start period. Once resources load (on first request), it returns 200.
|
| 207 |
+
# -----------------------------------------------------------------------------
|
| 208 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
| 209 |
+
CMD curl --fail http://localhost:7860/health/ready || exit 1
|
| 210 |
+
|
| 211 |
+
# -----------------------------------------------------------------------------
|
| 212 |
+
# Entrypoint
|
| 213 |
+
# -----------------------------------------------------------------------------
|
| 214 |
+
# Start the FastAPI application using uvicorn ASGI server
|
| 215 |
+
# Command breakdown:
|
| 216 |
+
# uvicorn : ASGI server for async Python web apps
|
| 217 |
+
# rag_chatbot.api.main:create_app : Module path to the app factory function
|
| 218 |
+
# --factory : create_app is a factory function, not an app instance
|
| 219 |
+
# --host 0.0.0.0 : Listen on all network interfaces (required for containers)
|
| 220 |
+
# --port 7860 : HuggingFace Spaces default port
|
| 221 |
+
#
|
| 222 |
+
# Using CMD allows the command to be overridden for debugging if needed
|
| 223 |
+
# Using exec form (JSON array) ensures proper signal handling
|
| 224 |
+
# -----------------------------------------------------------------------------
|
| 225 |
+
CMD ["uvicorn", "rag_chatbot.api.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "7860"]
|
Dockerfile.frontend
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# RAG Chatbot Frontend - Production Dockerfile
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# This is a multi-stage Dockerfile optimized for production deployment of the
|
| 5 |
+
# Next.js frontend application on HuggingFace Spaces or any Docker-based hosting.
|
| 6 |
+
#
|
| 7 |
+
# Architecture (3 stages):
|
| 8 |
+
# Stage 1 (deps): Install all dependencies using npm ci
|
| 9 |
+
# Stage 2 (builder): Build the Next.js application with standalone output
|
| 10 |
+
# Stage 3 (runner): Minimal production image with only runtime files
|
| 11 |
+
#
|
| 12 |
+
# Key optimizations:
|
| 13 |
+
# - Alpine-based images for minimal size
|
| 14 |
+
# - Multi-stage build eliminates build-time dependencies from final image
|
| 15 |
+
# - Standalone output mode bundles only required node_modules
|
| 16 |
+
# - Gzip compression enabled via Node.js flags
|
| 17 |
+
# - Non-root user for security
|
| 18 |
+
#
|
| 19 |
+
# Target image size: < 500MB
|
| 20 |
+
# Base image: node:22-alpine (~50MB base)
|
| 21 |
+
#
|
| 22 |
+
# Build command:
|
| 23 |
+
# docker build -f Dockerfile.frontend -t frontend ./frontend
|
| 24 |
+
#
|
| 25 |
+
# Run command:
|
| 26 |
+
# docker run -p 3000:3000 frontend
|
| 27 |
+
#
|
| 28 |
+
# =============================================================================
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# =============================================================================
|
| 32 |
+
# STAGE 1: DEPENDENCIES
|
| 33 |
+
# =============================================================================
|
| 34 |
+
# Purpose: Install all npm dependencies in an isolated stage
|
| 35 |
+
# This stage installs both production and dev dependencies because we need
|
| 36 |
+
# devDependencies (TypeScript, Tailwind, etc.) to build the application.
|
| 37 |
+
# The final image will not include these - only the standalone output.
|
| 38 |
+
# =============================================================================
|
| 39 |
+
FROM node:22-alpine AS deps
|
| 40 |
+
|
| 41 |
+
# -----------------------------------------------------------------------------
|
| 42 |
+
# Install System Dependencies
|
| 43 |
+
# -----------------------------------------------------------------------------
|
| 44 |
+
# libc6-compat: Required for some native Node.js modules that expect glibc
|
| 45 |
+
# Alpine uses musl libc by default, and this compatibility layer helps with
|
| 46 |
+
# packages that have native bindings compiled against glibc.
|
| 47 |
+
# -----------------------------------------------------------------------------
|
| 48 |
+
RUN apk add --no-cache libc6-compat
|
| 49 |
+
|
| 50 |
+
# -----------------------------------------------------------------------------
|
| 51 |
+
# Set Working Directory
|
| 52 |
+
# -----------------------------------------------------------------------------
|
| 53 |
+
# Use /app as the standard working directory for the application
|
| 54 |
+
# -----------------------------------------------------------------------------
|
| 55 |
+
WORKDIR /app
|
| 56 |
+
|
| 57 |
+
# -----------------------------------------------------------------------------
|
| 58 |
+
# Copy Package Files
|
| 59 |
+
# -----------------------------------------------------------------------------
|
| 60 |
+
# Copy only package.json and package-lock.json first for better layer caching.
|
| 61 |
+
# Docker caches this layer until these files change, avoiding unnecessary
|
| 62 |
+
# reinstalls when only source code changes.
|
| 63 |
+
# -----------------------------------------------------------------------------
|
| 64 |
+
COPY package.json package-lock.json ./
|
| 65 |
+
|
| 66 |
+
# -----------------------------------------------------------------------------
|
| 67 |
+
# Install Dependencies
|
| 68 |
+
# -----------------------------------------------------------------------------
|
| 69 |
+
# npm ci (Clean Install) is used instead of npm install because:
|
| 70 |
+
# - It's faster: skips package resolution, uses exact versions from lock file
|
| 71 |
+
# - It's reproducible: guarantees exact same dependency tree
|
| 72 |
+
# - It's stricter: fails if lock file is out of sync with package.json
|
| 73 |
+
# - It clears node_modules before install: ensures clean state
|
| 74 |
+
#
|
| 75 |
+
# --prefer-offline: Use cached packages when available (faster in CI/CD)
|
| 76 |
+
#
|
| 77 |
+
# After install, clean npm cache to reduce layer size.
|
| 78 |
+
# -----------------------------------------------------------------------------
|
| 79 |
+
RUN npm ci --prefer-offline && npm cache clean --force
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# =============================================================================
|
| 83 |
+
# STAGE 2: BUILDER
|
| 84 |
+
# =============================================================================
|
| 85 |
+
# Purpose: Build the Next.js application for production
|
| 86 |
+
# This stage:
|
| 87 |
+
# 1. Copies dependencies from the deps stage
|
| 88 |
+
# 2. Copies source code
|
| 89 |
+
# 3. Runs the production build
|
| 90 |
+
# 4. Outputs standalone server + static assets
|
| 91 |
+
#
|
| 92 |
+
# The standalone output (enabled in next.config.ts) creates:
|
| 93 |
+
# - .next/standalone/ - Minimal Node.js server with bundled dependencies
|
| 94 |
+
# - .next/static/ - Static assets (JS, CSS chunks)
|
| 95 |
+
# - public/ - Public static files (images, fonts, etc.)
|
| 96 |
+
# =============================================================================
|
| 97 |
+
FROM node:22-alpine AS builder
|
| 98 |
+
|
| 99 |
+
# -----------------------------------------------------------------------------
|
| 100 |
+
# Set Working Directory
|
| 101 |
+
# -----------------------------------------------------------------------------
|
| 102 |
+
WORKDIR /app
|
| 103 |
+
|
| 104 |
+
# -----------------------------------------------------------------------------
|
| 105 |
+
# Copy Dependencies from deps Stage
|
| 106 |
+
# -----------------------------------------------------------------------------
|
| 107 |
+
# Copy the fully installed node_modules from the deps stage.
|
| 108 |
+
# This is faster than reinstalling and ensures consistent dependencies.
|
| 109 |
+
# -----------------------------------------------------------------------------
|
| 110 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 111 |
+
|
| 112 |
+
# -----------------------------------------------------------------------------
|
| 113 |
+
# Copy Source Code and Configuration
|
| 114 |
+
# -----------------------------------------------------------------------------
|
| 115 |
+
# Copy all source files needed for the build.
|
| 116 |
+
# Note: Files matching patterns in .dockerignore are excluded automatically.
|
| 117 |
+
#
|
| 118 |
+
# The key files needed:
|
| 119 |
+
# - src/ : Application source code (pages, components, etc.)
|
| 120 |
+
# - public/ : Static assets to be copied to output
|
| 121 |
+
# - package.json : Project metadata and scripts
|
| 122 |
+
# - next.config.ts : Next.js configuration (standalone output enabled)
|
| 123 |
+
# - tsconfig.json : TypeScript configuration
|
| 124 |
+
# - postcss.config.mjs, tailwind config : CSS processing
|
| 125 |
+
# -----------------------------------------------------------------------------
|
| 126 |
+
COPY . .
|
| 127 |
+
|
| 128 |
+
# -----------------------------------------------------------------------------
|
| 129 |
+
# Build-Time Environment Variables
|
| 130 |
+
# -----------------------------------------------------------------------------
|
| 131 |
+
# NEXT_PUBLIC_API_URL: The base URL for API requests
|
| 132 |
+
# - Set at build time because NEXT_PUBLIC_* vars are inlined into the bundle
|
| 133 |
+
# - Empty string = same-origin requests (frontend and backend on same domain)
|
| 134 |
+
# - Set to absolute URL if backend is on different domain
|
| 135 |
+
#
|
| 136 |
+
# NEXT_TELEMETRY_DISABLED: Disable Next.js anonymous telemetry
|
| 137 |
+
# - Prevents outbound network requests during build
|
| 138 |
+
# - Recommended for CI/CD and production environments
|
| 139 |
+
#
|
| 140 |
+
# NODE_ENV: Set to production for optimized build output
|
| 141 |
+
# - Enables production optimizations (minification, dead code elimination)
|
| 142 |
+
# - Disables development-only features and warnings
|
| 143 |
+
# -----------------------------------------------------------------------------
|
| 144 |
+
ENV NEXT_PUBLIC_API_URL=""
|
| 145 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 146 |
+
ENV NODE_ENV=production
|
| 147 |
+
|
| 148 |
+
# -----------------------------------------------------------------------------
|
| 149 |
+
# Build the Application
|
| 150 |
+
# -----------------------------------------------------------------------------
|
| 151 |
+
# Run the Next.js production build using webpack bundler.
|
| 152 |
+
#
|
| 153 |
+
# Note: Next.js 16+ uses Turbopack by default, but we use --webpack flag here
|
| 154 |
+
# because the next.config.ts contains a webpack configuration block.
|
| 155 |
+
# Using webpack ensures compatibility with existing configuration.
|
| 156 |
+
#
|
| 157 |
+
# This command will:
|
| 158 |
+
# 1. Compile TypeScript to JavaScript
|
| 159 |
+
# 2. Bundle and optimize client-side code using webpack
|
| 160 |
+
# 3. Pre-render static pages (if any)
|
| 161 |
+
# 4. Generate standalone output in .next/standalone/
|
| 162 |
+
# 5. Generate static assets in .next/static/
|
| 163 |
+
#
|
| 164 |
+
# The standalone output includes:
|
| 165 |
+
# - server.js: Minimal Node.js server
|
| 166 |
+
# - node_modules: Only production dependencies needed by the server
|
| 167 |
+
# - Required Next.js internal files
|
| 168 |
+
# -----------------------------------------------------------------------------
|
| 169 |
+
RUN npx next build --webpack
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# =============================================================================
|
| 173 |
+
# STAGE 3: RUNNER (Production)
|
| 174 |
+
# =============================================================================
|
| 175 |
+
# Purpose: Minimal production image containing only what's needed to run
|
| 176 |
+
# This is the final image that will be deployed.
|
| 177 |
+
#
|
| 178 |
+
# Size optimization:
|
| 179 |
+
# - Alpine base image (~50MB vs ~350MB for Debian)
|
| 180 |
+
# - Only standalone output copied (no full node_modules)
|
| 181 |
+
# - No build tools, devDependencies, or source TypeScript
|
| 182 |
+
#
|
| 183 |
+
# Security:
|
| 184 |
+
# - Runs as non-root user (nextjs)
|
| 185 |
+
# - Minimal attack surface
|
| 186 |
+
# - No unnecessary packages or tools
|
| 187 |
+
# =============================================================================
|
| 188 |
+
FROM node:22-alpine AS runner
|
| 189 |
+
|
| 190 |
+
# -----------------------------------------------------------------------------
|
| 191 |
+
# Set Working Directory
|
| 192 |
+
# -----------------------------------------------------------------------------
|
| 193 |
+
WORKDIR /app
|
| 194 |
+
|
| 195 |
+
# -----------------------------------------------------------------------------
|
| 196 |
+
# Environment Variables for Production
|
| 197 |
+
# -----------------------------------------------------------------------------
|
| 198 |
+
# NODE_ENV: production
|
| 199 |
+
# - Next.js uses this to enable production optimizations
|
| 200 |
+
# - Disables development-only features
|
| 201 |
+
#
|
| 202 |
+
# NEXT_TELEMETRY_DISABLED: Disable telemetry at runtime
|
| 203 |
+
# - No anonymous usage data sent to Vercel
|
| 204 |
+
#
|
| 205 |
+
# PORT: The port the server will listen on
|
| 206 |
+
# - Default 3000, can be overridden at runtime
|
| 207 |
+
# - HuggingFace Spaces may require specific ports (e.g., 7860)
|
| 208 |
+
#
|
| 209 |
+
# HOSTNAME: The hostname to bind to
|
| 210 |
+
# - 0.0.0.0 binds to all network interfaces
|
| 211 |
+
# - Required for Docker networking (localhost wouldn't be accessible)
|
| 212 |
+
#
|
| 213 |
+
# NODE_OPTIONS: Node.js runtime flags
|
| 214 |
+
# - --enable-source-maps: Better error stack traces in production
|
| 215 |
+
# - Note: Gzip compression is handled by Next.js automatically
|
| 216 |
+
# (via the compress option, which defaults to true)
|
| 217 |
+
# -----------------------------------------------------------------------------
|
| 218 |
+
ENV NODE_ENV=production
|
| 219 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 220 |
+
ENV PORT=3000
|
| 221 |
+
ENV HOSTNAME=0.0.0.0
|
| 222 |
+
ENV NODE_OPTIONS="--enable-source-maps"
|
| 223 |
+
|
| 224 |
+
# -----------------------------------------------------------------------------
|
| 225 |
+
# Create Non-Root User
|
| 226 |
+
# -----------------------------------------------------------------------------
|
| 227 |
+
# Security best practice: Run applications as non-root user.
|
| 228 |
+
# This limits the potential damage from security vulnerabilities.
|
| 229 |
+
#
|
| 230 |
+
# addgroup: Create a system group 'nodejs' with GID 1001
|
| 231 |
+
# adduser: Create a system user 'nextjs' with UID 1001
|
| 232 |
+
# - -S: Create a system user (no password, no home dir contents)
|
| 233 |
+
# - -G: Add to the nodejs group
|
| 234 |
+
# - -u: Set specific UID for consistency across environments
|
| 235 |
+
#
|
| 236 |
+
# Using UID/GID 1001 to avoid conflicts with existing system users.
|
| 237 |
+
# -----------------------------------------------------------------------------
|
| 238 |
+
RUN addgroup --system --gid 1001 nodejs && \
|
| 239 |
+
adduser --system --uid 1001 --ingroup nodejs nextjs
|
| 240 |
+
|
| 241 |
+
# -----------------------------------------------------------------------------
|
| 242 |
+
# Copy Public Directory
|
| 243 |
+
# -----------------------------------------------------------------------------
|
| 244 |
+
# Copy static files from the public directory.
|
| 245 |
+
# These files are served directly by Next.js without processing.
|
| 246 |
+
# Common contents: favicon.ico, robots.txt, static images, fonts
|
| 247 |
+
#
|
| 248 |
+
# --chown: Set ownership to nextjs user for proper permissions
|
| 249 |
+
# -----------------------------------------------------------------------------
|
| 250 |
+
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
| 251 |
+
|
| 252 |
+
# -----------------------------------------------------------------------------
|
| 253 |
+
# Create .next Directory
|
| 254 |
+
# -----------------------------------------------------------------------------
|
| 255 |
+
# Create the .next directory with proper ownership.
|
| 256 |
+
# Next.js writes cache and runtime files here.
|
| 257 |
+
# Pre-creating with correct ownership prevents permission errors.
|
| 258 |
+
# -----------------------------------------------------------------------------
|
| 259 |
+
RUN mkdir -p .next && chown nextjs:nodejs .next
|
| 260 |
+
|
| 261 |
+
# -----------------------------------------------------------------------------
|
| 262 |
+
# Copy Standalone Server
|
| 263 |
+
# -----------------------------------------------------------------------------
|
| 264 |
+
# Copy the standalone output from the builder stage.
|
| 265 |
+
# This is the minimal Node.js server with bundled dependencies.
|
| 266 |
+
#
|
| 267 |
+
# Structure of .next/standalone/:
|
| 268 |
+
# - server.js : The entry point for the production server
|
| 269 |
+
# - node_modules/ : Only the dependencies required by server.js
|
| 270 |
+
# - .next/ : Compiled server components and routes
|
| 271 |
+
# - package.json : Minimal package info
|
| 272 |
+
#
|
| 273 |
+
# The standalone output is significantly smaller than full node_modules
|
| 274 |
+
# because it only includes the specific files needed for production.
|
| 275 |
+
# -----------------------------------------------------------------------------
|
| 276 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 277 |
+
|
| 278 |
+
# -----------------------------------------------------------------------------
|
| 279 |
+
# Copy Static Assets
|
| 280 |
+
# -----------------------------------------------------------------------------
|
| 281 |
+
# Copy the static assets directory to the standalone output.
|
| 282 |
+
# These are the compiled JavaScript/CSS chunks and other static files.
|
| 283 |
+
#
|
| 284 |
+
# IMPORTANT: The standalone output does NOT automatically include .next/static
|
| 285 |
+
# because these files should typically be served by a CDN. However, for
|
| 286 |
+
# HuggingFace Spaces and simple deployments, we serve them from the same server.
|
| 287 |
+
#
|
| 288 |
+
# The static directory must be placed inside .next/ for Next.js to find it.
|
| 289 |
+
# -----------------------------------------------------------------------------
|
| 290 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 291 |
+
|
| 292 |
+
# -----------------------------------------------------------------------------
|
| 293 |
+
# Switch to Non-Root User
|
| 294 |
+
# -----------------------------------------------------------------------------
|
| 295 |
+
# All subsequent commands and the container runtime will use this user.
|
| 296 |
+
# This is the final security measure before running the application.
|
| 297 |
+
# -----------------------------------------------------------------------------
|
| 298 |
+
USER nextjs
|
| 299 |
+
|
| 300 |
+
# -----------------------------------------------------------------------------
|
| 301 |
+
# Expose Port
|
| 302 |
+
# -----------------------------------------------------------------------------
|
| 303 |
+
# Document the port that the application listens on.
|
| 304 |
+
# This is informational; actual port mapping is done at runtime with -p flag.
|
| 305 |
+
# The PORT environment variable (default 3000) controls the actual binding.
|
| 306 |
+
# -----------------------------------------------------------------------------
|
| 307 |
+
EXPOSE 3000
|
| 308 |
+
|
| 309 |
+
# -----------------------------------------------------------------------------
|
| 310 |
+
# Health Check
|
| 311 |
+
# -----------------------------------------------------------------------------
|
| 312 |
+
# Docker health check to verify the container is running properly.
|
| 313 |
+
# Used by orchestrators (Docker Compose, Kubernetes, HF Spaces) for:
|
| 314 |
+
# - Container lifecycle management
|
| 315 |
+
# - Load balancer health checks
|
| 316 |
+
# - Automatic container restarts on failure
|
| 317 |
+
#
|
| 318 |
+
# Options:
|
| 319 |
+
# --interval=30s : Check every 30 seconds
|
| 320 |
+
# --timeout=10s : Wait up to 10 seconds for response
|
| 321 |
+
# --start-period=30s: Grace period for application startup
|
| 322 |
+
# --retries=3 : Mark unhealthy after 3 consecutive failures
|
| 323 |
+
#
|
| 324 |
+
# Command:
|
| 325 |
+
# wget: Use wget instead of curl (curl not installed in alpine by default)
|
| 326 |
+
# --no-verbose: Reduce output noise
|
| 327 |
+
# --tries=1: Single attempt per check
|
| 328 |
+
# --spider: Don't download, just check availability
|
| 329 |
+
# http://localhost:3000/: The root page (or use a dedicated health endpoint)
|
| 330 |
+
# -----------------------------------------------------------------------------
|
| 331 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 332 |
+
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
|
| 333 |
+
|
| 334 |
+
# -----------------------------------------------------------------------------
|
| 335 |
+
# Start Command
|
| 336 |
+
# -----------------------------------------------------------------------------
|
| 337 |
+
# Start the Next.js production server using the standalone server.js file.
|
| 338 |
+
#
|
| 339 |
+
# The standalone server.js is a minimal Node.js server that:
|
| 340 |
+
# - Handles all Next.js routing (pages, API routes, etc.)
|
| 341 |
+
# - Serves static files from .next/static and public
|
| 342 |
+
# - Includes production optimizations (compression, caching headers)
|
| 343 |
+
# - Uses the HOSTNAME and PORT environment variables
|
| 344 |
+
#
|
| 345 |
+
# Using exec form (JSON array) ensures:
|
| 346 |
+
# - Proper signal handling (SIGTERM reaches Node.js directly)
|
| 347 |
+
# - No shell process overhead
|
| 348 |
+
# - Correct argument parsing
|
| 349 |
+
#
|
| 350 |
+
# Note: The server automatically enables gzip compression via Next.js
|
| 351 |
+
# (compress: true is the default in production mode).
|
| 352 |
+
# -----------------------------------------------------------------------------
|
| 353 |
+
CMD ["node", "server.js"]
|
PROJECT_README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RAG Chatbot for pythermalcomfort
|
| 2 |
+
|
| 3 |
+
> **Note**: For HuggingFace Space configuration and user documentation, see [README.md](README.md).
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+

|
| 7 |
+
|
| 8 |
+
A Retrieval-Augmented Generation (RAG) chatbot for the pythermalcomfort library documentation. The chatbot uses hybrid retrieval (dense embeddings + BM25) to find relevant documentation chunks and streams responses via LLM providers.
|
| 9 |
+
|
| 10 |
+
## Features
|
| 11 |
+
|
| 12 |
+
- Hybrid retrieval combining dense embeddings (BGE) with BM25 keyword search
|
| 13 |
+
- Multi-provider LLM support with automatic fallback (Gemini -> Groq -> DeepSeek)
|
| 14 |
+
- Server-Sent Events (SSE) streaming for real-time responses
|
| 15 |
+
- Structure-aware PDF chunking with heading inheritance
|
| 16 |
+
- Async query logging to HuggingFace datasets
|
| 17 |
+
|
| 18 |
+
## Installation
|
| 19 |
+
|
| 20 |
+
### Prerequisites
|
| 21 |
+
|
| 22 |
+
- Python 3.11 or higher
|
| 23 |
+
- [Poetry](https://python-poetry.org/docs/#installation) for dependency management
|
| 24 |
+
|
| 25 |
+
### Environment Setup
|
| 26 |
+
|
| 27 |
+
1. Clone the repository:
|
| 28 |
+
```bash
|
| 29 |
+
git clone https://github.com/sadickam/pythermalcomfort-chat.git
|
| 30 |
+
cd pythermalcomfort-chat
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
2. Create and activate a virtual environment:
|
| 34 |
+
```bash
|
| 35 |
+
python -m venv therm_venv
|
| 36 |
+
source therm_venv/bin/activate # Linux/macOS
|
| 37 |
+
# or
|
| 38 |
+
therm_venv\Scripts\activate # Windows
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
3. Install dependencies using one of the install modes below.
|
| 42 |
+
|
| 43 |
+
### Install Modes
|
| 44 |
+
|
| 45 |
+
The project uses Poetry dependency groups to support different deployment scenarios:
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# Server only (BM25 retrieval) - lightweight, no GPU required
|
| 49 |
+
poetry install --with serve
|
| 50 |
+
|
| 51 |
+
# Server with dense retrieval - requires torch, optional GPU
|
| 52 |
+
poetry install --with serve,dense
|
| 53 |
+
|
| 54 |
+
# Full build pipeline (local) - for rebuilding embeddings and indexes
|
| 55 |
+
poetry install --with build,dev
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### Dependency Comparison
|
| 59 |
+
|
| 60 |
+
| Dependency | Core | Serve | Dense | Build |
|
| 61 |
+
|------------------------|:----:|:-----:|:-----:|:-----:|
|
| 62 |
+
| pydantic | x | | | |
|
| 63 |
+
| pydantic-settings | x | | | |
|
| 64 |
+
| numpy | x | | | |
|
| 65 |
+
| httpx | x | | | |
|
| 66 |
+
| tiktoken | x | | | |
|
| 67 |
+
| rank-bm25 | x | | | |
|
| 68 |
+
| faiss-cpu | x | | | |
|
| 69 |
+
| fastapi | | x | | |
|
| 70 |
+
| uvicorn | | x | | |
|
| 71 |
+
| sse-starlette | | x | | |
|
| 72 |
+
| sentence-transformers | | | x | x |
|
| 73 |
+
| torch | | | x | x |
|
| 74 |
+
| pymupdf4llm | | | | x |
|
| 75 |
+
| pymupdf | | | | x |
|
| 76 |
+
| pyarrow | | | | x |
|
| 77 |
+
| datasets | | | | x |
|
| 78 |
+
|
| 79 |
+
**Install mode explanations:**
|
| 80 |
+
|
| 81 |
+
- **Core**: Always installed. Provides base functionality for retrieval and LLM communication.
|
| 82 |
+
- **Serve** (`--with serve`): Adds FastAPI server for hosting the chatbot API. Suitable for CPU-only deployments using precomputed embeddings.
|
| 83 |
+
- **Dense** (`--with dense`): Adds sentence-transformers and PyTorch for runtime embedding generation. Use when you need to embed queries with dense vectors.
|
| 84 |
+
- **Build** (`--with build`): Full offline pipeline for processing PDFs, generating embeddings, and publishing indexes to HuggingFace.
|
| 85 |
+
|
| 86 |
+
## Quick Start
|
| 87 |
+
|
| 88 |
+
1. Set up environment variables:
|
| 89 |
+
```bash
|
| 90 |
+
cp .env.example .env
|
| 91 |
+
# Edit .env with your API keys
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
2. Start the server:
|
| 95 |
+
```bash
|
| 96 |
+
poetry run uvicorn src.rag_chatbot.api.main:app --reload
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
3. The API will be available at `http://localhost:8000`
|
| 100 |
+
|
| 101 |
+
## Project Structure
|
| 102 |
+
|
| 103 |
+
```
|
| 104 |
+
src/rag_chatbot/
|
| 105 |
+
├── api/ # FastAPI endpoints and middleware
|
| 106 |
+
├── chunking/ # Structure-aware document chunking
|
| 107 |
+
├── config/ # Settings and configuration
|
| 108 |
+
├── embeddings/ # BGE encoder and storage
|
| 109 |
+
├── extraction/ # PDF to Markdown conversion
|
| 110 |
+
├── llm/ # LLM provider registry (Gemini, Groq, DeepSeek)
|
| 111 |
+
├── qlog/ # Async query logging to HuggingFace
|
| 112 |
+
└── retrieval/ # Hybrid retriever (FAISS + BM25 with RRF fusion)
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
## Build Pipeline
|
| 116 |
+
|
| 117 |
+
For rebuilding embeddings and indexes from source PDFs:
|
| 118 |
+
|
| 119 |
+
```bash
|
| 120 |
+
# Full rebuild: extract -> chunk -> embed -> index -> publish
|
| 121 |
+
poetry run python scripts/rebuild.py data/raw/
|
| 122 |
+
|
| 123 |
+
# Individual steps
|
| 124 |
+
poetry run python scripts/extract.py data/raw/ data/processed/
|
| 125 |
+
poetry run python scripts/chunk.py data/processed/ data/chunks/chunks.jsonl
|
| 126 |
+
poetry run python scripts/embed.py data/chunks/chunks.jsonl data/embeddings/ --publish
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
## Development
|
| 130 |
+
|
| 131 |
+
### Running Tests
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
# Run all tests
|
| 135 |
+
poetry run pytest
|
| 136 |
+
|
| 137 |
+
# Run unit tests only
|
| 138 |
+
poetry run pytest tests/unit/
|
| 139 |
+
|
| 140 |
+
# Run a single test file
|
| 141 |
+
poetry run pytest tests/unit/test_foo.py
|
| 142 |
+
|
| 143 |
+
# Run a single test by name
|
| 144 |
+
poetry run pytest -k "test_name"
|
| 145 |
+
|
| 146 |
+
# Generate coverage report
|
| 147 |
+
poetry run pytest --cov=src --cov-report=html
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### Code Quality
|
| 151 |
+
|
| 152 |
+
```bash
|
| 153 |
+
# Type checking (strict mode)
|
| 154 |
+
poetry run mypy src/
|
| 155 |
+
|
| 156 |
+
# Linting
|
| 157 |
+
poetry run ruff check src/
|
| 158 |
+
|
| 159 |
+
# Formatting
|
| 160 |
+
poetry run ruff format src/
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
### Pre-commit Hooks
|
| 164 |
+
|
| 165 |
+
Install pre-commit hooks to run checks automatically before each commit:
|
| 166 |
+
|
| 167 |
+
```bash
|
| 168 |
+
poetry run pre-commit install
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
## Environment Variables
|
| 172 |
+
|
| 173 |
+
Required secrets for deployment:
|
| 174 |
+
|
| 175 |
+
| Variable | Description |
|
| 176 |
+
|--------------------|--------------------------------|
|
| 177 |
+
| `GEMINI_API_KEY` | Google Gemini API key |
|
| 178 |
+
| `DEEPSEEK_API_KEY` | DeepSeek API key |
|
| 179 |
+
| `GROQ_API_KEY` | Groq API key |
|
| 180 |
+
| `HF_TOKEN` | HuggingFace authentication |
|
| 181 |
+
|
| 182 |
+
Configuration options:
|
| 183 |
+
|
| 184 |
+
| Variable | Default | Description |
|
| 185 |
+
|-----------------------|---------|--------------------------------------|
|
| 186 |
+
| `USE_HYBRID` | `true` | Enable BM25 + dense retrieval |
|
| 187 |
+
| `USE_RERANKER` | `false` | Enable cross-encoder reranking |
|
| 188 |
+
| `TOP_K` | `6` | Number of chunks to retrieve |
|
| 189 |
+
| `PROVIDER_TIMEOUT_MS` | `30000` | Timeout before provider fallback |
|
| 190 |
+
|
| 191 |
+
## Retrieval Configuration
|
| 192 |
+
|
| 193 |
+
The retrieval system can be tuned via environment variables to balance latency and answer quality.
|
| 194 |
+
|
| 195 |
+
### Available Settings
|
| 196 |
+
|
| 197 |
+
| Setting | Default | Range | Description |
|
| 198 |
+
|---------|---------|-------|-------------|
|
| 199 |
+
| `TOP_K` | `6` | 1-20 | Number of chunks to retrieve. Higher values provide more context but increase latency. |
|
| 200 |
+
| `USE_HYBRID` | `true` | boolean | Enable hybrid retrieval combining dense (FAISS) and sparse (BM25) search with RRF fusion. |
|
| 201 |
+
| `USE_RERANKER` | `false` | boolean | Enable cross-encoder reranking for improved relevance. Adds ~200-500ms latency. |
|
| 202 |
+
|
| 203 |
+
### Retriever Modes
|
| 204 |
+
|
| 205 |
+
**Hybrid Mode (default):** Combines dense semantic search (FAISS with BGE embeddings) and sparse keyword search (BM25) using Reciprocal Rank Fusion. Best for mixed queries containing both technical terms and natural language.
|
| 206 |
+
|
| 207 |
+
**Dense-Only Mode:** Uses only FAISS semantic search. Faster than hybrid mode and works well for purely semantic queries where exact keyword matching is less important.
|
| 208 |
+
|
| 209 |
+
### Performance Tradeoffs
|
| 210 |
+
|
| 211 |
+
| Setting | Default | Latency Impact | Quality Impact |
|
| 212 |
+
|---------|---------|----------------|----------------|
|
| 213 |
+
| `TOP_K` | 6 | +~5ms per chunk | More context = better answers |
|
| 214 |
+
| `USE_HYBRID` | true | +~20-50ms | Better for mixed queries |
|
| 215 |
+
| `USE_RERANKER` | false | +~200-500ms | Significantly improved relevance |
|
| 216 |
+
|
| 217 |
+
### Recommended Configurations
|
| 218 |
+
|
| 219 |
+
**Low-latency production (default):**
|
| 220 |
+
```bash
|
| 221 |
+
USE_HYBRID=true
|
| 222 |
+
USE_RERANKER=false
|
| 223 |
+
TOP_K=6
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
**High-precision answers:**
|
| 227 |
+
```bash
|
| 228 |
+
USE_HYBRID=true
|
| 229 |
+
USE_RERANKER=true
|
| 230 |
+
TOP_K=10
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
**Fastest responses:**
|
| 234 |
+
```bash
|
| 235 |
+
USE_HYBRID=false
|
| 236 |
+
USE_RERANKER=false
|
| 237 |
+
TOP_K=3
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
## HuggingFace Repositories
|
| 241 |
+
|
| 242 |
+
| Repository | Purpose |
|
| 243 |
+
|----------------------------------|--------------------------------------|
|
| 244 |
+
| `sadickam/pytherm_index` | Chunks, embeddings, FAISS/BM25 indexes |
|
| 245 |
+
| `sadickam/Pytherm_Qlog` | Query/answer logs (no PII) |
|
| 246 |
+
| `sadickam/Pythermalcomfort-Chat` | Deployment Space (Docker SDK) |
|
| 247 |
+
|
| 248 |
+
## License
|
| 249 |
+
|
| 250 |
+
MIT License - see [LICENSE](LICENSE) for details.
|
README.md
CHANGED
|
@@ -1,198 +1,249 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
| 7 |
|
| 8 |
-
##
|
| 9 |
|
| 10 |
-
|
| 11 |
-
- Multi-provider LLM support with automatic fallback (Gemini -> Groq -> DeepSeek)
|
| 12 |
-
- Server-Sent Events (SSE) streaming for real-time responses
|
| 13 |
-
- Structure-aware PDF chunking with heading inheritance
|
| 14 |
-
- Async query logging to HuggingFace datasets
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
```bash
|
| 33 |
-
python -m venv therm_venv
|
| 34 |
-
source therm_venv/bin/activate # Linux/macOS
|
| 35 |
-
# or
|
| 36 |
-
therm_venv\Scripts\activate # Windows
|
| 37 |
-
```
|
| 38 |
|
| 39 |
-
|
|
|
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
|
| 45 |
-
|
| 46 |
-
# Server only (BM25 retrieval) - lightweight, no GPU required
|
| 47 |
-
poetry install --with serve
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
| pydantic-settings | x | | | |
|
| 62 |
-
| numpy | x | | | |
|
| 63 |
-
| httpx | x | | | |
|
| 64 |
-
| tiktoken | x | | | |
|
| 65 |
-
| rank-bm25 | x | | | |
|
| 66 |
-
| faiss-cpu | x | | | |
|
| 67 |
-
| fastapi | | x | | |
|
| 68 |
-
| uvicorn | | x | | |
|
| 69 |
-
| sse-starlette | | x | | |
|
| 70 |
-
| sentence-transformers | | | x | x |
|
| 71 |
-
| torch | | | x | x |
|
| 72 |
-
| pymupdf4llm | | | | x |
|
| 73 |
-
| pymupdf | | | | x |
|
| 74 |
-
| pyarrow | | | | x |
|
| 75 |
-
| datasets | | | | x |
|
| 76 |
-
|
| 77 |
-
**Install mode explanations:**
|
| 78 |
-
|
| 79 |
-
- **Core**: Always installed. Provides base functionality for retrieval and LLM communication.
|
| 80 |
-
- **Serve** (`--with serve`): Adds FastAPI server for hosting the chatbot API. Suitable for CPU-only deployments using precomputed embeddings.
|
| 81 |
-
- **Dense** (`--with dense`): Adds sentence-transformers and PyTorch for runtime embedding generation. Use when you need to embed queries with dense vectors.
|
| 82 |
-
- **Build** (`--with build`): Full offline pipeline for processing PDFs, generating embeddings, and publishing indexes to HuggingFace.
|
| 83 |
-
|
| 84 |
-
## Quick Start
|
| 85 |
-
|
| 86 |
-
1. Set up environment variables:
|
| 87 |
-
```bash
|
| 88 |
-
cp .env.example .env
|
| 89 |
-
# Edit .env with your API keys
|
| 90 |
-
```
|
| 91 |
-
|
| 92 |
-
2. Start the server:
|
| 93 |
-
```bash
|
| 94 |
-
poetry run uvicorn src.rag_chatbot.api.main:app --reload
|
| 95 |
-
```
|
| 96 |
-
|
| 97 |
-
3. The API will be available at `http://localhost:8000`
|
| 98 |
-
|
| 99 |
-
## Project Structure
|
| 100 |
|
| 101 |
```
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
├── config/ # Settings and configuration
|
| 106 |
-
├── embeddings/ # BGE encoder and storage
|
| 107 |
-
├── extraction/ # PDF to Markdown conversion
|
| 108 |
-
├── llm/ # LLM provider registry (Gemini, Groq, DeepSeek)
|
| 109 |
-
├── qlog/ # Async query logging to HuggingFace
|
| 110 |
-
└── retrieval/ # Hybrid retriever (FAISS + BM25 with RRF fusion)
|
| 111 |
```
|
| 112 |
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
-
|
| 116 |
|
| 117 |
-
|
| 118 |
-
# Full rebuild: extract -> chunk -> embed -> index -> publish
|
| 119 |
-
poetry run python scripts/rebuild.py data/raw/
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
poetry run python scripts/chunk.py data/processed/ data/chunks/chunks.jsonl
|
| 124 |
-
poetry run python scripts/embed.py data/chunks/chunks.jsonl data/embeddings/ --publish
|
| 125 |
-
```
|
| 126 |
|
| 127 |
-
|
| 128 |
|
| 129 |
-
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
| 136 |
-
poetry run pytest tests/unit/
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
```
|
| 147 |
|
| 148 |
-
|
| 149 |
|
| 150 |
-
|
| 151 |
-
# Type checking (strict mode)
|
| 152 |
-
poetry run mypy src/
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
```
|
| 160 |
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
```bash
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
```
|
| 168 |
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
-
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
| `GEMINI_API_KEY` | Google Gemini API key |
|
| 176 |
-
| `DEEPSEEK_API_KEY` | DeepSeek API key |
|
| 177 |
-
| `GROQ_API_KEY` | Groq API key |
|
| 178 |
-
| `HF_TOKEN` | HuggingFace authentication |
|
| 179 |
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|-----------------------|---------|--------------------------------------|
|
| 184 |
-
| `USE_HYBRID` | `true` | Enable BM25 + dense retrieval |
|
| 185 |
-
| `TOP_K` | `6` | Number of chunks to retrieve |
|
| 186 |
-
| `PROVIDER_TIMEOUT_MS` | `30000` | Timeout before provider fallback |
|
| 187 |
|
| 188 |
-
|
|
|
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|----------------------------------|--------------------------------------|
|
| 192 |
-
| `sadickam/pytherm_index` | Chunks, embeddings, FAISS/BM25 indexes |
|
| 193 |
-
| `sadickam/Pytherm_Qlog` | Query/answer logs (no PII) |
|
| 194 |
-
| `sadickam/Pythermalcomfort-Chat` | Deployment Space (Docker SDK) |
|
| 195 |
|
| 196 |
-
|
| 197 |
|
| 198 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Pythermalcomfort Chat
|
| 3 |
+
emoji: 🌖
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
|
| 11 |
+
<!-- Overview Section: Introduces the chatbot and its purpose -->
|
| 12 |
+
# 🌡️ Pythermalcomfort RAG Chatbot
|
| 13 |
|
| 14 |
+
Welcome to the **Pythermalcomfort Chat** — an AI-powered assistant designed to help you understand and use the [pythermalcomfort](https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort) Python library for thermal comfort calculations.
|
| 15 |
|
| 16 |
+
## What is This?
|
| 17 |
|
| 18 |
+
This chatbot uses **Retrieval-Augmented Generation (RAG)** to provide accurate, documentation-grounded answers about thermal comfort science and the pythermalcomfort library. Instead of relying solely on general AI knowledge, every response is backed by relevant excerpts from the official documentation, ensuring you get precise and reliable information.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
**Why use this chatbot?**
|
| 21 |
+
- 📚 Get instant answers about thermal comfort standards (ASHRAE 55, ISO 7730, EN 16798)
|
| 22 |
+
- 🔧 Learn how to use pythermalcomfort functions with practical examples
|
| 23 |
+
- 🎯 Understand complex concepts like PMV/PPD, adaptive comfort, and more
|
| 24 |
+
- 📖 Receive responses with source citations so you can dive deeper
|
| 25 |
|
| 26 |
+
---
|
| 27 |
|
| 28 |
+
<!-- Example Questions Section: Shows users what they can ask -->
|
| 29 |
+
## 💬 What You Can Ask
|
| 30 |
|
| 31 |
+
Here are some example questions to get you started:
|
| 32 |
|
| 33 |
+
| Category | Example Questions |
|
| 34 |
+
|----------|-------------------|
|
| 35 |
+
| **Concepts** | "What is PMV and how is it calculated?" |
|
| 36 |
+
| | "What is the difference between PMV and PPD?" |
|
| 37 |
+
| | "How does adaptive thermal comfort work?" |
|
| 38 |
+
| **Standards** | "What are the ASHRAE Standard 55 requirements?" |
|
| 39 |
+
| | "What thermal comfort categories does ISO 7730 define?" |
|
| 40 |
+
| | "What is the EN 16798 standard?" |
|
| 41 |
+
| **Library Usage** | "What inputs does the pmv_ppd function need?" |
|
| 42 |
+
| | "How do I calculate thermal comfort for an office?" |
|
| 43 |
+
| | "How do I use the adaptive comfort model?" |
|
| 44 |
+
| **Parameters** | "What is metabolic rate and how do I estimate it?" |
|
| 45 |
+
| | "How do I measure clothing insulation?" |
|
| 46 |
+
| | "What is operative temperature?" |
|
| 47 |
|
| 48 |
+
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
<!-- Technical Features Section: Details the chatbot's capabilities -->
|
| 51 |
+
## ⚙️ Technical Features
|
| 52 |
|
| 53 |
+
| Feature | Description |
|
| 54 |
+
|---------|-------------|
|
| 55 |
+
| **RAG-Powered Responses** | Every answer includes source citations from pythermalcomfort documentation |
|
| 56 |
+
| **Hybrid Retrieval** | Combines dense embeddings (FAISS) + sparse retrieval (BM25) with Reciprocal Rank Fusion for accurate document search |
|
| 57 |
+
| **Multi-Provider LLM** | Automatic fallback chain: Gemini → DeepSeek → Anthropic → Groq ensures high availability |
|
| 58 |
+
| **Real-Time Streaming** | Responses stream via Server-Sent Events (SSE) for a responsive chat experience |
|
| 59 |
+
| **Query Logging** | Anonymous query logging enables continuous improvement of retrieval quality |
|
| 60 |
|
| 61 |
+
---
|
| 62 |
|
| 63 |
+
## 🤖 Available LLM Models
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
<!--
|
| 66 |
+
The chatbot uses multiple LLM providers with automatic fallback.
|
| 67 |
+
When one provider is rate-limited or unavailable, the system
|
| 68 |
+
automatically switches to the next available provider.
|
| 69 |
+
-->
|
| 70 |
|
| 71 |
+
The chatbot leverages multiple LLM providers with intelligent fallback to ensure high availability:
|
| 72 |
+
|
| 73 |
+
### Google Gemini (Primary Provider)
|
| 74 |
+
|
| 75 |
+
| Model | Rate Limits (Free Tier) | Description |
|
| 76 |
+
|-------|-------------------------|-------------|
|
| 77 |
+
| `gemini-2.5-flash-lite` | 10 RPM, 250K TPM | Primary model - fastest response times |
|
| 78 |
+
| `gemini-2.5-flash` | 5 RPM, 250K TPM | Secondary - balanced speed and quality |
|
| 79 |
+
| `gemini-3-flash` | 5 RPM, 250K TPM | Tertiary - latest Gemini capabilities |
|
| 80 |
+
| `gemma-3-27b-it` | 30 RPM, 15K TPM | Final fallback - open-weights model |
|
| 81 |
+
|
| 82 |
+
### Groq (High-Speed Provider)
|
| 83 |
+
|
| 84 |
+
| Model | Rate Limits (Free Tier) | Description |
|
| 85 |
+
|-------|-------------------------|-------------|
|
| 86 |
+
| `openai/gpt-oss-120b` | 30 RPM, 8K TPM | Primary - large model via Groq |
|
| 87 |
+
| `llama-3.3-70b-versatile` | 30 RPM, 12K TPM | Secondary - Meta's Llama 3.3 |
|
| 88 |
+
|
| 89 |
+
### DeepSeek (Fallback Provider)
|
| 90 |
|
| 91 |
+
| Model | Description |
|
| 92 |
+
|-------|-------------|
|
| 93 |
+
| `deepseek-chat` | Cost-effective alternative with strong reasoning |
|
| 94 |
+
|
| 95 |
+
### Provider Fallback Chain
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
```
|
| 98 |
+
Gemini → Groq → DeepSeek → Anthropic
|
| 99 |
+
↓ ↓ ↓ ↓
|
| 100 |
+
Primary Fast Budget Premium
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
```
|
| 102 |
|
| 103 |
+
When a provider hits rate limits or encounters errors:
|
| 104 |
+
1. The system automatically tries the next provider in the chain
|
| 105 |
+
2. Rate limit cooldowns are tracked per-model
|
| 106 |
+
3. Responses indicate which provider was used
|
| 107 |
|
| 108 |
+
> **Note**: The fallback chain ensures maximum availability while staying within free tier limits.
|
| 109 |
|
| 110 |
+
---
|
|
|
|
|
|
|
| 111 |
|
| 112 |
+
<!-- About pythermalcomfort Section: Background on the library -->
|
| 113 |
+
## 📖 About pythermalcomfort
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
**Thermal comfort** refers to the condition of mind that expresses satisfaction with the thermal environment. It's influenced by factors like air temperature, humidity, air velocity, radiant temperature, clothing, and metabolic rate.
|
| 116 |
|
| 117 |
+
The **pythermalcomfort** library is an open-source Python package that implements thermal comfort models and indices according to international standards, including:
|
| 118 |
|
| 119 |
+
- 🏛️ **ASHRAE Standard 55** — Thermal Environmental Conditions for Human Occupancy
|
| 120 |
+
- 🌍 **ISO 7730** — Ergonomics of the thermal environment
|
| 121 |
+
- 🇪🇺 **EN 16798** — Energy performance of buildings
|
| 122 |
+
|
| 123 |
+
### Key Capabilities
|
| 124 |
+
|
| 125 |
+
- Calculate **PMV (Predicted Mean Vote)** and **PPD (Predicted Percentage Dissatisfied)**
|
| 126 |
+
- Evaluate **adaptive thermal comfort** for naturally ventilated buildings
|
| 127 |
+
- Compute various thermal comfort indices (SET, UTCI, Cooling Effect, etc.)
|
| 128 |
+
- Support for both SI and IP unit systems
|
| 129 |
+
|
| 130 |
+
### Resources
|
| 131 |
+
|
| 132 |
+
- 📦 **GitHub Repository**: [CenterForTheBuiltEnvironment/pythermalcomfort](https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort)
|
| 133 |
+
- 📚 **Documentation**: [pythermalcomfort.readthedocs.io](https://pythermalcomfort.readthedocs.io/)
|
| 134 |
+
- 🐍 **PyPI**: [pythermalcomfort on PyPI](https://pypi.org/project/pythermalcomfort/)
|
| 135 |
|
| 136 |
+
---
|
|
|
|
| 137 |
|
| 138 |
+
<!-- API Endpoints Section: Technical reference for developers -->
|
| 139 |
+
## 🔌 API Endpoints
|
| 140 |
|
| 141 |
+
| Endpoint | Method | Description |
|
| 142 |
+
|----------|--------|-------------|
|
| 143 |
+
| `/api/query` | POST | Submit a question and receive a streamed response |
|
| 144 |
+
| `/api/providers` | GET | Check availability status of LLM providers |
|
| 145 |
+
| `/health` | GET | Basic health check |
|
| 146 |
+
| `/health/ready` | GET | Readiness probe (checks if indexes are loaded) |
|
| 147 |
|
| 148 |
+
### Query Request Format
|
| 149 |
+
|
| 150 |
+
```json
|
| 151 |
+
{
|
| 152 |
+
"question": "What is PMV?",
|
| 153 |
+
"provider": "gemini"
|
| 154 |
+
}
|
| 155 |
```
|
| 156 |
|
| 157 |
+
The `provider` field is optional. If omitted, the system automatically selects the best available provider.
|
| 158 |
|
| 159 |
+
---
|
|
|
|
|
|
|
| 160 |
|
| 161 |
+
<!-- Architecture Section: High-level system overview -->
|
| 162 |
+
## 🏗️ Architecture
|
| 163 |
|
| 164 |
+
This Space uses a multi-container architecture optimized for HuggingFace Spaces:
|
| 165 |
+
|
| 166 |
+
```
|
| 167 |
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
| 168 |
+
│ Nginx │────▶│ Next.js │ │ FastAPI │
|
| 169 |
+
│ (Proxy) │────▶│ Frontend │────▶│ Backend │
|
| 170 |
+
└─────────────┘ └─────────────┘ └─────────────┘
|
| 171 |
+
│
|
| 172 |
+
▼
|
| 173 |
+
┌─────────────────┐
|
| 174 |
+
│ FAISS + BM25 │
|
| 175 |
+
│ Indexes │
|
| 176 |
+
└─────────────────┘
|
| 177 |
```
|
| 178 |
|
| 179 |
+
- **Nginx** — Reverse proxy handling routing between frontend and backend
|
| 180 |
+
- **Next.js** — React frontend with responsive chat interface
|
| 181 |
+
- **FastAPI** — Python backend with RAG pipeline and LLM orchestration
|
| 182 |
+
|
| 183 |
+
The backend downloads prebuilt FAISS indexes and BM25 vocabularies from the [`sadickam/pytherm_index`](https://huggingface.co/datasets/sadickam/pytherm_index) HuggingFace dataset at startup, enabling efficient hybrid retrieval without requiring GPU compute.
|
| 184 |
+
|
| 185 |
+
---
|
| 186 |
+
|
| 187 |
+
<!-- For Developers Section: Points to development resources -->
|
| 188 |
+
## 👩💻 For Developers
|
| 189 |
+
|
| 190 |
+
Interested in running this locally or contributing? See the **[PROJECT_README.md](PROJECT_README.md)** for detailed development setup instructions, including:
|
| 191 |
|
| 192 |
+
- Local development without Docker
|
| 193 |
+
- Building the retrieval indexes from scratch
|
| 194 |
+
- Running tests and type checking
|
| 195 |
+
- Contributing guidelines
|
| 196 |
+
|
| 197 |
+
### Quick Start (Development)
|
| 198 |
|
| 199 |
```bash
|
| 200 |
+
# Backend
|
| 201 |
+
poetry install --with serve,dense
|
| 202 |
+
poetry run uvicorn src.rag_chatbot.api.main:app --reload --port 7860
|
| 203 |
+
|
| 204 |
+
# Frontend (in separate terminal)
|
| 205 |
+
cd frontend
|
| 206 |
+
npm install
|
| 207 |
+
npm run dev
|
| 208 |
```
|
| 209 |
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
<!-- Environment Variables Section: Deployment configuration -->
|
| 213 |
+
## 🔐 Environment Variables (Deployment)
|
| 214 |
+
|
| 215 |
+
The following secrets must be configured in HuggingFace Space settings for deployment:
|
| 216 |
+
|
| 217 |
+
| Variable | Required | Description |
|
| 218 |
+
|----------|----------|-------------|
|
| 219 |
+
| `GEMINI_API_KEY` | Yes | Google Gemini API key (primary provider) |
|
| 220 |
+
| `DEEPSEEK_API_KEY` | No | DeepSeek API key (fallback provider) |
|
| 221 |
+
| `ANTHROPIC_API_KEY` | No | Anthropic API key (fallback provider) |
|
| 222 |
+
| `GROQ_API_KEY` | No | Groq API key (fallback provider) |
|
| 223 |
+
| `HF_TOKEN` | Yes | HuggingFace token for accessing datasets and query logging |
|
| 224 |
+
|
| 225 |
+
**Note:** At least one LLM provider key is required for the chatbot to function.
|
| 226 |
|
| 227 |
+
---
|
| 228 |
|
| 229 |
+
<!-- Related Repositories Section: Links to associated resources -->
|
| 230 |
+
## 🔗 Related Repositories
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
+
| Repository | Description |
|
| 233 |
+
|------------|-------------|
|
| 234 |
+
| [pythermalcomfort](https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort) | The Python library this chatbot documents |
|
| 235 |
+
| [sadickam/pytherm_index](https://huggingface.co/datasets/sadickam/pytherm_index) | Prebuilt retrieval indexes (FAISS + BM25) |
|
| 236 |
+
| [sadickam/Pytherm_Qlog](https://huggingface.co/datasets/sadickam/Pytherm_Qlog) | Anonymous query logs for improvement |
|
| 237 |
|
| 238 |
+
---
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
+
<!-- License Section -->
|
| 241 |
+
## 📄 License
|
| 242 |
|
| 243 |
+
This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
+
---
|
| 246 |
|
| 247 |
+
<p align="center">
|
| 248 |
+
Made with ❤️ for the thermal comfort research community
|
| 249 |
+
</p>
|
commands
CHANGED
|
@@ -41,6 +41,10 @@ Verbose mode - Basic rebuild with detailed output showing each file being proces
|
|
| 41 |
clear all artifacts, generate new artifacts and publish to HF
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
## CREATE AASBS2 and SDG Targets Database with Embeddings
|
| 45 |
|
| 46 |
|
|
@@ -85,9 +89,8 @@ After your solution, rate your confidence (0-1) on:
|
|
| 85 |
If any score < 0.9, refine your answer.
|
| 86 |
[TASK]
|
| 87 |
- Read the **## Overview** sections of **IMPLEMENTATION_PLAN.md** to understand the system, and context.
|
| 88 |
-
- Review **Step
|
| 89 |
-
- Spawn specilaised agents and orchestrate them to succesfully complete all tasks for **Step
|
| 90 |
-
- Ensure normalisation includes correcting jumbled words and sentences, removing extra spaces, and ensuring proper capitalization.
|
| 91 |
- Spawn at most 2 specialised agents at a time
|
| 92 |
- Ensure clean hands off between agents to avoid file write conflicts occurring between agents and potential data loss
|
| 93 |
- It is critical to identify and fix potentially missing items that can affect production-readiness.
|
|
|
|
| 41 |
clear all artifacts, generate new artifacts and publish to HF
|
| 42 |
|
| 43 |
|
| 44 |
+
## Commit to HF Space
|
| 45 |
+
- git push space master --force ( first time)
|
| 46 |
+
- git push space master (subsequent commits)
|
| 47 |
+
|
| 48 |
## CREATE AASBS2 and SDG Targets Database with Embeddings
|
| 49 |
|
| 50 |
|
|
|
|
| 89 |
If any score < 0.9, refine your answer.
|
| 90 |
[TASK]
|
| 91 |
- Read the **## Overview** sections of **IMPLEMENTATION_PLAN.md** to understand the system, and context.
|
| 92 |
+
- Review **Step 9.5: Add Dataset Freshness Check on Startup** step by step to understand all of its requirements and objectives.
|
| 93 |
+
- Spawn specilaised agents and orchestrate them to succesfully complete all tasks for **Step 9.5: Add Dataset Freshness Check on Startup** based on defined details in the **@IMPLEMENTATION_PLAN.md**
|
|
|
|
| 94 |
- Spawn at most 2 specialised agents at a time
|
| 95 |
- Ensure clean hands off between agents to avoid file write conflicts occurring between agents and potential data loss
|
| 96 |
- It is critical to identify and fix potentially missing items that can affect production-readiness.
|
docs/HF_SPACE_CONFIG.md
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingFace Space Configuration Guide
|
| 2 |
+
|
| 3 |
+
<!--
|
| 4 |
+
This documentation covers the manual configuration steps required to deploy
|
| 5 |
+
the pythermalcomfort RAG chatbot on HuggingFace Spaces. The Space uses the
|
| 6 |
+
Docker SDK for deployment, which means it builds and runs a Docker container
|
| 7 |
+
from the repository's Dockerfile.
|
| 8 |
+
-->
|
| 9 |
+
|
| 10 |
+
## Overview
|
| 11 |
+
|
| 12 |
+
This guide explains how to configure the HuggingFace Space at **[sadickam/Pythermalcomfort-Chat](https://huggingface.co/spaces/sadickam/Pythermalcomfort-Chat)** for deployment.
|
| 13 |
+
|
| 14 |
+
The Space uses the **Docker SDK**, which means:
|
| 15 |
+
- HuggingFace builds a Docker image from the repository's `Dockerfile`
|
| 16 |
+
- The container runs on HuggingFace's infrastructure
|
| 17 |
+
- Environment variables and secrets are injected at runtime
|
| 18 |
+
- The application serves both the Next.js frontend and FastAPI backend
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## README File Structure
|
| 23 |
+
|
| 24 |
+
<!--
|
| 25 |
+
The project maintains two separate README files for different purposes:
|
| 26 |
+
- README.md: HuggingFace Space configuration and user documentation (with YAML frontmatter)
|
| 27 |
+
- PROJECT_README.md: Main project documentation for developers
|
| 28 |
+
-->
|
| 29 |
+
|
| 30 |
+
The HuggingFace Space requires a `README.md` file with specific YAML frontmatter for configuration. This repository is structured so that `README.md` serves as the Space configuration file directly, eliminating the need for file renaming during deployment.
|
| 31 |
+
|
| 32 |
+
### File Structure
|
| 33 |
+
|
| 34 |
+
| File | Purpose |
|
| 35 |
+
|------|---------|
|
| 36 |
+
| `README.md` | HuggingFace Space configuration with YAML frontmatter, user-facing documentation |
|
| 37 |
+
| `PROJECT_README.md` | Developer documentation, installation instructions, contribution guidelines |
|
| 38 |
+
|
| 39 |
+
### YAML Frontmatter Requirements
|
| 40 |
+
|
| 41 |
+
The `README.md` starts with this required frontmatter:
|
| 42 |
+
|
| 43 |
+
```yaml
|
| 44 |
+
---
|
| 45 |
+
title: Pythermalcomfort Chat
|
| 46 |
+
emoji: 🌖
|
| 47 |
+
colorFrom: yellow
|
| 48 |
+
colorTo: red
|
| 49 |
+
sdk: docker
|
| 50 |
+
pinned: false
|
| 51 |
+
license: mit
|
| 52 |
+
---
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
This tells HuggingFace how to build and display the Space.
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## Required Secrets Configuration
|
| 60 |
+
|
| 61 |
+
<!--
|
| 62 |
+
Secrets are sensitive credentials that should never be committed to the repository.
|
| 63 |
+
HuggingFace Spaces provides a secure way to inject these as environment variables
|
| 64 |
+
at runtime. The application uses these keys for LLM API calls and HuggingFace
|
| 65 |
+
dataset access.
|
| 66 |
+
-->
|
| 67 |
+
|
| 68 |
+
### Secret Reference Table
|
| 69 |
+
|
| 70 |
+
| Secret Name | Required | Description |
|
| 71 |
+
|-------------|----------|-------------|
|
| 72 |
+
| `GEMINI_API_KEY` | **Required** | Google Gemini API key - Primary LLM provider for generating responses |
|
| 73 |
+
| `HF_TOKEN` | **Required** | HuggingFace token for accessing the index dataset and query logging |
|
| 74 |
+
| `DEEPSEEK_API_KEY` | Optional | DeepSeek API key - First fallback provider if Gemini fails |
|
| 75 |
+
| `ANTHROPIC_API_KEY` | Optional | Anthropic Claude API key - Second fallback provider |
|
| 76 |
+
| `GROQ_API_KEY` | Optional | Groq API key - Third fallback provider for fast inference |
|
| 77 |
+
|
| 78 |
+
### Step-by-Step Configuration
|
| 79 |
+
|
| 80 |
+
1. **Navigate to the Space Settings**
|
| 81 |
+
```
|
| 82 |
+
https://huggingface.co/spaces/sadickam/Pythermalcomfort-Chat/settings
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
2. **Scroll to the "Repository secrets" section**
|
| 86 |
+
- This section is located under "Variables and secrets" in the Settings page
|
| 87 |
+
|
| 88 |
+
3. **Add each secret**
|
| 89 |
+
- Click "New secret"
|
| 90 |
+
- Enter the secret name exactly as shown (e.g., `GEMINI_API_KEY`)
|
| 91 |
+
- Paste the API key value
|
| 92 |
+
- Click "Add"
|
| 93 |
+
- Repeat for each secret
|
| 94 |
+
|
| 95 |
+
4. **Required secrets setup**
|
| 96 |
+
|
| 97 |
+
**GEMINI_API_KEY** (Required):
|
| 98 |
+
```
|
| 99 |
+
# Obtain from: https://makersuite.google.com/app/apikey
|
| 100 |
+
# This is the primary LLM provider - the chatbot will not function without it
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
**HF_TOKEN** (Required):
|
| 104 |
+
```
|
| 105 |
+
# Obtain from: https://huggingface.co/settings/tokens
|
| 106 |
+
# Required permissions:
|
| 107 |
+
# - Read access to sadickam/pytherm_index (prebuilt indexes)
|
| 108 |
+
# - Write access to sadickam/Pytherm_Qlog (query logging)
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
5. **Optional fallback provider secrets**
|
| 112 |
+
|
| 113 |
+
<!--
|
| 114 |
+
The LLM provider registry implements automatic fallback. If the primary
|
| 115 |
+
provider (Gemini) fails or hits rate limits, it will try the next available
|
| 116 |
+
provider in order: DeepSeek -> Anthropic -> Groq
|
| 117 |
+
-->
|
| 118 |
+
|
| 119 |
+
**DEEPSEEK_API_KEY** (Optional):
|
| 120 |
+
```
|
| 121 |
+
# Obtain from: https://platform.deepseek.com/
|
| 122 |
+
# First fallback if Gemini is unavailable
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
**ANTHROPIC_API_KEY** (Optional):
|
| 126 |
+
```
|
| 127 |
+
# Obtain from: https://console.anthropic.com/
|
| 128 |
+
# Second fallback provider
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
**GROQ_API_KEY** (Optional):
|
| 132 |
+
```
|
| 133 |
+
# Obtain from: https://console.groq.com/
|
| 134 |
+
# Third fallback - offers fast inference times
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
6. **Restart the Space**
|
| 138 |
+
- After adding all secrets, click "Restart" in the Space header
|
| 139 |
+
- The Space will rebuild and inject the new secrets
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## Hardware Settings
|
| 144 |
+
|
| 145 |
+
<!--
|
| 146 |
+
The application is optimized for CPU-only inference. All heavy computation
|
| 147 |
+
(embedding, indexing) is done offline during the build pipeline. At runtime,
|
| 148 |
+
the Space only performs:
|
| 149 |
+
- Vector similarity search (FAISS with prebuilt index)
|
| 150 |
+
- BM25 keyword search (prebuilt index)
|
| 151 |
+
- LLM API calls (external providers)
|
| 152 |
+
-->
|
| 153 |
+
|
| 154 |
+
### Recommended Configuration
|
| 155 |
+
|
| 156 |
+
| Setting | Value | Reason |
|
| 157 |
+
|---------|-------|--------|
|
| 158 |
+
| **Hardware** | CPU Basic (Free) | No GPU required for inference |
|
| 159 |
+
| **Sleep timeout** | Default (48 hours) | Adjust based on usage patterns |
|
| 160 |
+
|
| 161 |
+
### Why CPU is Sufficient
|
| 162 |
+
|
| 163 |
+
1. **Prebuilt Indexes**: The FAISS and BM25 indexes are built offline with GPU acceleration and published to `sadickam/pytherm_index`. At startup, the Space downloads these prebuilt artifacts.
|
| 164 |
+
|
| 165 |
+
2. **No Local Embedding**: Query embedding uses the same BGE model but runs efficiently on CPU for single queries.
|
| 166 |
+
|
| 167 |
+
3. **External LLM Providers**: Response generation is handled by external API providers (Gemini, DeepSeek, etc.), not local models.
|
| 168 |
+
|
| 169 |
+
4. **Cost Optimization**: The free CPU tier is sufficient for the expected load and keeps operational costs at zero.
|
| 170 |
+
|
| 171 |
+
### Configuring Hardware
|
| 172 |
+
|
| 173 |
+
1. Navigate to: `https://huggingface.co/spaces/sadickam/Pythermalcomfort-Chat/settings`
|
| 174 |
+
2. Find the "Space hardware" section
|
| 175 |
+
3. Select "CPU basic" from the dropdown
|
| 176 |
+
4. Click "Save" (the Space will restart automatically)
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## Deployment Verification Checklist
|
| 181 |
+
|
| 182 |
+
<!--
|
| 183 |
+
After configuring secrets and deploying, verify the Space is functioning
|
| 184 |
+
correctly by checking each component in order. The health endpoints provide
|
| 185 |
+
detailed status information about the application state.
|
| 186 |
+
-->
|
| 187 |
+
|
| 188 |
+
### Pre-flight Checks
|
| 189 |
+
|
| 190 |
+
- [ ] All required secrets are configured (`GEMINI_API_KEY`, `HF_TOKEN`)
|
| 191 |
+
- [ ] Hardware is set to "CPU Basic"
|
| 192 |
+
- [ ] Space is set to "Public" or "Private" as needed
|
| 193 |
+
|
| 194 |
+
### Build Verification
|
| 195 |
+
|
| 196 |
+
- [ ] **Space builds successfully**
|
| 197 |
+
- Check the "Logs" tab for build output
|
| 198 |
+
- Build should complete without errors in 5-10 minutes
|
| 199 |
+
- Look for "Application startup complete" in logs
|
| 200 |
+
|
| 201 |
+
### Health Endpoint Checks
|
| 202 |
+
|
| 203 |
+
<!--
|
| 204 |
+
The application exposes multiple health endpoints for monitoring.
|
| 205 |
+
These should be checked in order as each depends on the previous.
|
| 206 |
+
-->
|
| 207 |
+
|
| 208 |
+
1. **Basic Health Check**
|
| 209 |
+
```bash
|
| 210 |
+
curl https://sadickam-pythermalcomfort-chat.hf.space/health
|
| 211 |
+
```
|
| 212 |
+
Expected response:
|
| 213 |
+
```json
|
| 214 |
+
{"status": "healthy"}
|
| 215 |
+
```
|
| 216 |
+
- [ ] Returns HTTP 200
|
| 217 |
+
|
| 218 |
+
2. **Readiness Check**
|
| 219 |
+
```bash
|
| 220 |
+
curl https://sadickam-pythermalcomfort-chat.hf.space/health/ready
|
| 221 |
+
```
|
| 222 |
+
Expected response:
|
| 223 |
+
```json
|
| 224 |
+
{
|
| 225 |
+
"status": "ready",
|
| 226 |
+
"indexes_loaded": true,
|
| 227 |
+
"chunks_count": <number>,
|
| 228 |
+
"faiss_index_size": <number>
|
| 229 |
+
}
|
| 230 |
+
```
|
| 231 |
+
- [ ] Returns HTTP 200
|
| 232 |
+
- [ ] `indexes_loaded` is `true`
|
| 233 |
+
- [ ] `chunks_count` is greater than 0
|
| 234 |
+
|
| 235 |
+
3. **Provider Availability**
|
| 236 |
+
```bash
|
| 237 |
+
curl https://sadickam-pythermalcomfort-chat.hf.space/api/providers
|
| 238 |
+
```
|
| 239 |
+
Expected response:
|
| 240 |
+
```json
|
| 241 |
+
{
|
| 242 |
+
"available": ["gemini", ...],
|
| 243 |
+
"primary": "gemini"
|
| 244 |
+
}
|
| 245 |
+
```
|
| 246 |
+
- [ ] Returns HTTP 200
|
| 247 |
+
- [ ] At least one provider in `available` list
|
| 248 |
+
- [ ] `primary` is set (typically "gemini")
|
| 249 |
+
|
| 250 |
+
### Functional Test
|
| 251 |
+
|
| 252 |
+
- [ ] **Test a sample query through the UI**
|
| 253 |
+
1. Open `https://sadickam-pythermalcomfort-chat.hf.space`
|
| 254 |
+
2. Wait for the interface to load
|
| 255 |
+
3. Enter a test question: "What is PMV?"
|
| 256 |
+
4. Verify:
|
| 257 |
+
- Response streams in real-time
|
| 258 |
+
- Response includes relevant information about PMV (Predicted Mean Vote)
|
| 259 |
+
- Source citations are included
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
## Troubleshooting
|
| 264 |
+
|
| 265 |
+
<!--
|
| 266 |
+
Common issues encountered during deployment and their solutions.
|
| 267 |
+
Issues are organized by symptom for easy diagnosis.
|
| 268 |
+
-->
|
| 269 |
+
|
| 270 |
+
### Space Stuck in "Building"
|
| 271 |
+
|
| 272 |
+
**Symptoms:**
|
| 273 |
+
- Build process runs for more than 15 minutes
|
| 274 |
+
- Build log shows no progress or loops
|
| 275 |
+
|
| 276 |
+
**Solutions:**
|
| 277 |
+
1. **Check Dockerfile syntax**
|
| 278 |
+
```bash
|
| 279 |
+
# Validate locally before pushing
|
| 280 |
+
docker build -t test-build .
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
2. **Review build logs**
|
| 284 |
+
- Click "Logs" tab in the Space
|
| 285 |
+
- Look for error messages or failed commands
|
| 286 |
+
- Common issues: missing files, dependency conflicts
|
| 287 |
+
|
| 288 |
+
3. **Clear build cache**
|
| 289 |
+
- Go to Settings > "Factory reboot"
|
| 290 |
+
- This clears cached layers and rebuilds from scratch
|
| 291 |
+
|
| 292 |
+
### Health Check Failing
|
| 293 |
+
|
| 294 |
+
**Symptoms:**
|
| 295 |
+
- `/health` returns 500 or connection refused
|
| 296 |
+
- Space shows as "Running" but endpoints don't respond
|
| 297 |
+
|
| 298 |
+
**Solutions:**
|
| 299 |
+
1. **Verify secrets are configured**
|
| 300 |
+
- Go to Settings > "Repository secrets"
|
| 301 |
+
- Confirm `GEMINI_API_KEY` and `HF_TOKEN` are present
|
| 302 |
+
- Note: You cannot see secret values, only that they exist
|
| 303 |
+
|
| 304 |
+
2. **Check application logs**
|
| 305 |
+
```
|
| 306 |
+
# Look for startup errors in the Logs tab
|
| 307 |
+
# Common messages:
|
| 308 |
+
# - "Missing required environment variable"
|
| 309 |
+
# - "Failed to initialize provider"
|
| 310 |
+
```
|
| 311 |
+
|
| 312 |
+
3. **Restart the Space**
|
| 313 |
+
- Click the three-dot menu > "Restart"
|
| 314 |
+
- Wait 2-3 minutes for full startup
|
| 315 |
+
|
| 316 |
+
### No Providers Available
|
| 317 |
+
|
| 318 |
+
**Symptoms:**
|
| 319 |
+
- `/api/providers` returns `{"available": [], "primary": null}`
|
| 320 |
+
- Chat interface shows "No providers available" error
|
| 321 |
+
|
| 322 |
+
**Solutions:**
|
| 323 |
+
1. **Verify API keys are correct**
|
| 324 |
+
- Regenerate the API key from the provider's console
|
| 325 |
+
- Update the secret in HuggingFace Space settings
|
| 326 |
+
- Restart the Space
|
| 327 |
+
|
| 328 |
+
2. **Check provider status**
|
| 329 |
+
- Verify the provider's API is operational
|
| 330 |
+
- Check for rate limiting or account issues
|
| 331 |
+
|
| 332 |
+
3. **Review provider logs**
|
| 333 |
+
```
|
| 334 |
+
# Look for these patterns in logs:
|
| 335 |
+
# - "API key invalid"
|
| 336 |
+
# - "Rate limit exceeded"
|
| 337 |
+
# - "Provider initialization failed"
|
| 338 |
+
```
|
| 339 |
+
|
| 340 |
+
### Index Loading Failures
|
| 341 |
+
|
| 342 |
+
**Symptoms:**
|
| 343 |
+
- `/health/ready` returns `{"indexes_loaded": false}`
|
| 344 |
+
- Logs show "Failed to download artifacts"
|
| 345 |
+
|
| 346 |
+
**Solutions:**
|
| 347 |
+
1. **Verify HF_TOKEN permissions**
|
| 348 |
+
- Go to https://huggingface.co/settings/tokens
|
| 349 |
+
- Ensure the token has "Read" access to `sadickam/pytherm_index`
|
| 350 |
+
- If using a fine-grained token, add explicit repo access
|
| 351 |
+
|
| 352 |
+
2. **Check dataset availability**
|
| 353 |
+
- Visit https://huggingface.co/datasets/sadickam/pytherm_index
|
| 354 |
+
- Verify the dataset exists and is accessible
|
| 355 |
+
- Check if the dataset is private and token has access
|
| 356 |
+
|
| 357 |
+
3. **Manual verification**
|
| 358 |
+
```bash
|
| 359 |
+
# Test token access locally
|
| 360 |
+
curl -H "Authorization: Bearer $HF_TOKEN" \
|
| 361 |
+
https://huggingface.co/api/datasets/sadickam/pytherm_index
|
| 362 |
+
```
|
| 363 |
+
|
| 364 |
+
4. **Check disk space**
|
| 365 |
+
- The index files require ~500MB of storage
|
| 366 |
+
- HuggingFace Spaces have limited ephemeral storage
|
| 367 |
+
- Consider reducing index size if this is an issue
|
| 368 |
+
|
| 369 |
+
### Slow Response Times
|
| 370 |
+
|
| 371 |
+
**Symptoms:**
|
| 372 |
+
- Queries take more than 30 seconds
|
| 373 |
+
- Responses time out frequently
|
| 374 |
+
|
| 375 |
+
**Solutions:**
|
| 376 |
+
1. **Check provider latency**
|
| 377 |
+
- The primary provider (Gemini) may be experiencing high load
|
| 378 |
+
- Fallback providers will be tried automatically
|
| 379 |
+
|
| 380 |
+
2. **Verify hybrid retrieval settings**
|
| 381 |
+
```
|
| 382 |
+
# In environment or settings:
|
| 383 |
+
USE_HYBRID=true # Enable both FAISS and BM25
|
| 384 |
+
TOP_K=6 # Reduce if responses are slow
|
| 385 |
+
```
|
| 386 |
+
|
| 387 |
+
3. **Monitor Space resources**
|
| 388 |
+
- Check the "Metrics" tab for CPU/memory usage
|
| 389 |
+
- Consider upgrading hardware if consistently maxed out
|
| 390 |
+
|
| 391 |
+
---
|
| 392 |
+
|
| 393 |
+
## Environment Variables Reference
|
| 394 |
+
|
| 395 |
+
<!--
|
| 396 |
+
Complete reference of all environment variables used by the application.
|
| 397 |
+
Secrets should be configured in HuggingFace Space settings, while non-sensitive
|
| 398 |
+
configuration can be set in the Dockerfile or as Space variables.
|
| 399 |
+
-->
|
| 400 |
+
|
| 401 |
+
### Secrets (Configure in Space Settings)
|
| 402 |
+
|
| 403 |
+
| Variable | Required | Description |
|
| 404 |
+
|----------|----------|-------------|
|
| 405 |
+
| `GEMINI_API_KEY` | Yes | Google Gemini API key |
|
| 406 |
+
| `HF_TOKEN` | Yes | HuggingFace access token |
|
| 407 |
+
| `DEEPSEEK_API_KEY` | No | DeepSeek API key |
|
| 408 |
+
| `ANTHROPIC_API_KEY` | No | Anthropic API key |
|
| 409 |
+
| `GROQ_API_KEY` | No | Groq API key |
|
| 410 |
+
|
| 411 |
+
### Configuration (Can be set in Dockerfile)
|
| 412 |
+
|
| 413 |
+
| Variable | Default | Description |
|
| 414 |
+
|----------|---------|-------------|
|
| 415 |
+
| `USE_HYBRID` | `true` | Enable hybrid retrieval (FAISS + BM25) |
|
| 416 |
+
| `TOP_K` | `6` | Number of chunks to retrieve |
|
| 417 |
+
| `PROVIDER_TIMEOUT_MS` | `30000` | Timeout before trying fallback provider |
|
| 418 |
+
| `LOG_LEVEL` | `INFO` | Application log level |
|
| 419 |
+
|
| 420 |
+
---
|
| 421 |
+
|
| 422 |
+
## Additional Resources
|
| 423 |
+
|
| 424 |
+
- [HuggingFace Spaces Documentation](https://huggingface.co/docs/hub/spaces)
|
| 425 |
+
- [Docker SDK Reference](https://huggingface.co/docs/hub/spaces-sdks-docker)
|
| 426 |
+
- [Space Secrets Documentation](https://huggingface.co/docs/hub/spaces-overview#managing-secrets)
|
| 427 |
+
- [pythermalcomfort Library](https://pythermalcomfort.readthedocs.io/)
|
frontend/.dockerignore
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Docker Ignore File for Next.js Frontend
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# This file specifies patterns for files and directories that should be
|
| 5 |
+
# excluded from the Docker build context. Proper exclusions:
|
| 6 |
+
# - Reduce build context size (faster docker build)
|
| 7 |
+
# - Prevent unnecessary cache invalidation
|
| 8 |
+
# - Avoid including sensitive or development-only files
|
| 9 |
+
# - Keep the final image smaller and more secure
|
| 10 |
+
# =============================================================================
|
| 11 |
+
|
| 12 |
+
# -----------------------------------------------------------------------------
|
| 13 |
+
# Dependencies
|
| 14 |
+
# -----------------------------------------------------------------------------
|
| 15 |
+
# Exclude node_modules as dependencies are installed fresh during build.
|
| 16 |
+
# This ensures consistent, reproducible builds and prevents platform-specific
|
| 17 |
+
# native modules from causing issues (e.g., macOS binaries on Linux).
|
| 18 |
+
node_modules/
|
| 19 |
+
.pnp/
|
| 20 |
+
.pnp.js
|
| 21 |
+
|
| 22 |
+
# -----------------------------------------------------------------------------
|
| 23 |
+
# Build Outputs
|
| 24 |
+
# -----------------------------------------------------------------------------
|
| 25 |
+
# Exclude local build artifacts. The Docker build process will generate
|
| 26 |
+
# fresh build outputs. Including these would:
|
| 27 |
+
# - Bloat the build context unnecessarily
|
| 28 |
+
# - Potentially cause cache conflicts
|
| 29 |
+
# - Include development build artifacts in production
|
| 30 |
+
.next/
|
| 31 |
+
out/
|
| 32 |
+
build/
|
| 33 |
+
dist/
|
| 34 |
+
|
| 35 |
+
# -----------------------------------------------------------------------------
|
| 36 |
+
# Version Control
|
| 37 |
+
# -----------------------------------------------------------------------------
|
| 38 |
+
# Git history and metadata are not needed in the container.
|
| 39 |
+
# Excluding .git significantly reduces build context size.
|
| 40 |
+
.git/
|
| 41 |
+
.gitignore
|
| 42 |
+
.gitattributes
|
| 43 |
+
|
| 44 |
+
# -----------------------------------------------------------------------------
|
| 45 |
+
# Test Files and Coverage
|
| 46 |
+
# -----------------------------------------------------------------------------
|
| 47 |
+
# Testing infrastructure is not needed in production images.
|
| 48 |
+
# Exclude test files, coverage reports, and testing configurations.
|
| 49 |
+
coverage/
|
| 50 |
+
.nyc_output/
|
| 51 |
+
*.test.ts
|
| 52 |
+
*.test.tsx
|
| 53 |
+
*.test.js
|
| 54 |
+
*.test.jsx
|
| 55 |
+
*.spec.ts
|
| 56 |
+
*.spec.tsx
|
| 57 |
+
*.spec.js
|
| 58 |
+
*.spec.jsx
|
| 59 |
+
__tests__/
|
| 60 |
+
__mocks__/
|
| 61 |
+
jest.config.*
|
| 62 |
+
vitest.config.*
|
| 63 |
+
playwright.config.*
|
| 64 |
+
cypress/
|
| 65 |
+
cypress.config.*
|
| 66 |
+
|
| 67 |
+
# -----------------------------------------------------------------------------
|
| 68 |
+
# Documentation
|
| 69 |
+
# -----------------------------------------------------------------------------
|
| 70 |
+
# Documentation files are not needed for runtime.
|
| 71 |
+
# Keep the image focused on application code only.
|
| 72 |
+
*.md
|
| 73 |
+
!README.md
|
| 74 |
+
docs/
|
| 75 |
+
CHANGELOG*
|
| 76 |
+
LICENSE*
|
| 77 |
+
CONTRIBUTING*
|
| 78 |
+
|
| 79 |
+
# -----------------------------------------------------------------------------
|
| 80 |
+
# IDE and Editor Configurations
|
| 81 |
+
# -----------------------------------------------------------------------------
|
| 82 |
+
# Editor-specific files and directories should not be in the image.
|
| 83 |
+
# These are developer-specific and vary between team members.
|
| 84 |
+
.idea/
|
| 85 |
+
.vscode/
|
| 86 |
+
*.swp
|
| 87 |
+
*.swo
|
| 88 |
+
*~
|
| 89 |
+
.project
|
| 90 |
+
.classpath
|
| 91 |
+
.settings/
|
| 92 |
+
*.sublime-*
|
| 93 |
+
|
| 94 |
+
# -----------------------------------------------------------------------------
|
| 95 |
+
# OS-Generated Files
|
| 96 |
+
# -----------------------------------------------------------------------------
|
| 97 |
+
# Operating system metadata files are never needed in containers.
|
| 98 |
+
.DS_Store
|
| 99 |
+
.DS_Store?
|
| 100 |
+
._*
|
| 101 |
+
.Spotlight-V100
|
| 102 |
+
.Trashes
|
| 103 |
+
ehthumbs.db
|
| 104 |
+
Thumbs.db
|
| 105 |
+
desktop.ini
|
| 106 |
+
|
| 107 |
+
# -----------------------------------------------------------------------------
|
| 108 |
+
# Debug and Log Files
|
| 109 |
+
# -----------------------------------------------------------------------------
|
| 110 |
+
# Debug logs from package managers and build tools.
|
| 111 |
+
# These can contain sensitive information and are not needed in production.
|
| 112 |
+
npm-debug.log*
|
| 113 |
+
yarn-debug.log*
|
| 114 |
+
yarn-error.log*
|
| 115 |
+
pnpm-debug.log*
|
| 116 |
+
lerna-debug.log*
|
| 117 |
+
.pnpm-debug.log*
|
| 118 |
+
|
| 119 |
+
# -----------------------------------------------------------------------------
|
| 120 |
+
# Environment Files
|
| 121 |
+
# -----------------------------------------------------------------------------
|
| 122 |
+
# Local environment files often contain secrets.
|
| 123 |
+
# Production secrets should be injected via Docker secrets or env vars.
|
| 124 |
+
# IMPORTANT: Never include .env files with real credentials in images.
|
| 125 |
+
.env
|
| 126 |
+
.env.local
|
| 127 |
+
.env.development
|
| 128 |
+
.env.development.local
|
| 129 |
+
.env.test
|
| 130 |
+
.env.test.local
|
| 131 |
+
.env.production.local
|
| 132 |
+
|
| 133 |
+
# Note: .env.production is intentionally NOT excluded as it may contain
|
| 134 |
+
# non-sensitive build-time configuration. Review before building.
|
| 135 |
+
|
| 136 |
+
# -----------------------------------------------------------------------------
|
| 137 |
+
# TypeScript Build Info
|
| 138 |
+
# -----------------------------------------------------------------------------
|
| 139 |
+
# TypeScript incremental compilation cache.
|
| 140 |
+
# Not needed in the container; TypeScript compiles fresh during build.
|
| 141 |
+
*.tsbuildinfo
|
| 142 |
+
tsconfig.tsbuildinfo
|
| 143 |
+
|
| 144 |
+
# -----------------------------------------------------------------------------
|
| 145 |
+
# Package Manager Lock Files (Alternative)
|
| 146 |
+
# -----------------------------------------------------------------------------
|
| 147 |
+
# If using npm, exclude yarn/pnpm lock files and vice versa.
|
| 148 |
+
# Uncomment the appropriate lines based on your package manager.
|
| 149 |
+
# yarn.lock
|
| 150 |
+
# pnpm-lock.yaml
|
| 151 |
+
# package-lock.json
|
| 152 |
+
|
| 153 |
+
# -----------------------------------------------------------------------------
|
| 154 |
+
# Storybook
|
| 155 |
+
# -----------------------------------------------------------------------------
|
| 156 |
+
# Storybook is a development tool for UI component documentation.
|
| 157 |
+
# Not needed in production runtime.
|
| 158 |
+
.storybook/
|
| 159 |
+
storybook-static/
|
| 160 |
+
|
| 161 |
+
# -----------------------------------------------------------------------------
|
| 162 |
+
# Docker Files
|
| 163 |
+
# -----------------------------------------------------------------------------
|
| 164 |
+
# Dockerfiles themselves don't need to be in the build context
|
| 165 |
+
# (Docker reads them separately). Including them can leak build strategy info.
|
| 166 |
+
Dockerfile*
|
| 167 |
+
docker-compose*
|
| 168 |
+
.dockerignore
|
| 169 |
+
|
| 170 |
+
# -----------------------------------------------------------------------------
|
| 171 |
+
# CI/CD Configuration
|
| 172 |
+
# -----------------------------------------------------------------------------
|
| 173 |
+
# Continuous integration configs are not needed in the runtime image.
|
| 174 |
+
.github/
|
| 175 |
+
.gitlab-ci.yml
|
| 176 |
+
.travis.yml
|
| 177 |
+
.circleci/
|
| 178 |
+
azure-pipelines.yml
|
| 179 |
+
Jenkinsfile
|
| 180 |
+
.buildkite/
|
| 181 |
+
|
| 182 |
+
# -----------------------------------------------------------------------------
|
| 183 |
+
# Linting and Formatting Configs
|
| 184 |
+
# -----------------------------------------------------------------------------
|
| 185 |
+
# These are development tools; linting happens before build, not at runtime.
|
| 186 |
+
.eslintrc*
|
| 187 |
+
.eslintignore
|
| 188 |
+
eslint.config.*
|
| 189 |
+
.prettierrc*
|
| 190 |
+
.prettierignore
|
| 191 |
+
prettier.config.*
|
| 192 |
+
.stylelintrc*
|
| 193 |
+
.editorconfig
|
| 194 |
+
|
| 195 |
+
# -----------------------------------------------------------------------------
|
| 196 |
+
# Husky and Git Hooks
|
| 197 |
+
# -----------------------------------------------------------------------------
|
| 198 |
+
# Git hooks are for development workflow, not needed in containers.
|
| 199 |
+
.husky/
|
| 200 |
+
.git-hooks/
|
| 201 |
+
|
| 202 |
+
# -----------------------------------------------------------------------------
|
| 203 |
+
# Temporary Files
|
| 204 |
+
# -----------------------------------------------------------------------------
|
| 205 |
+
# Temporary files from various tools and processes.
|
| 206 |
+
tmp/
|
| 207 |
+
temp/
|
| 208 |
+
*.tmp
|
| 209 |
+
*.temp
|
| 210 |
+
*.bak
|
| 211 |
+
*.backup
|
| 212 |
+
|
| 213 |
+
# -----------------------------------------------------------------------------
|
| 214 |
+
# Miscellaneous
|
| 215 |
+
# -----------------------------------------------------------------------------
|
| 216 |
+
# Other files that don't belong in production images.
|
| 217 |
+
Makefile
|
| 218 |
+
*.log
|
| 219 |
+
*.pid
|
| 220 |
+
*.seed
|
| 221 |
+
*.pid.lock
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
frontend/.gitkeep
DELETED
|
File without changes
|
frontend/.prettierignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules
|
| 3 |
+
|
| 4 |
+
# Build output
|
| 5 |
+
.next
|
| 6 |
+
out
|
| 7 |
+
|
| 8 |
+
# Coverage
|
| 9 |
+
coverage
|
| 10 |
+
|
| 11 |
+
# Cache
|
| 12 |
+
.cache
|
| 13 |
+
|
| 14 |
+
# Lock files
|
| 15 |
+
package-lock.json
|
| 16 |
+
yarn.lock
|
| 17 |
+
pnpm-lock.yaml
|
| 18 |
+
|
| 19 |
+
# Generated files
|
| 20 |
+
*.min.js
|
| 21 |
+
*.min.css
|
frontend/README.md
CHANGED
|
@@ -1 +1,36 @@
|
|
| 1 |
-
Next.js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
frontend/eslint.config.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ESLint Configuration for Next.js Frontend
|
| 3 |
+
*
|
| 4 |
+
* This configuration enforces strict TypeScript and React rules for
|
| 5 |
+
* production-quality code in the RAG Chatbot frontend.
|
| 6 |
+
*
|
| 7 |
+
* @see https://eslint.org/docs/latest/use/configure/configuration-files
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { defineConfig, globalIgnores } from 'eslint/config';
|
| 11 |
+
import nextVitals from 'eslint-config-next/core-web-vitals';
|
| 12 |
+
import nextTs from 'eslint-config-next/typescript';
|
| 13 |
+
import eslintConfigPrettier from 'eslint-config-prettier';
|
| 14 |
+
|
| 15 |
+
const eslintConfig = defineConfig([
|
| 16 |
+
// Extend Next.js recommended configs
|
| 17 |
+
...nextVitals,
|
| 18 |
+
...nextTs,
|
| 19 |
+
|
| 20 |
+
// Prettier config to disable formatting rules that conflict
|
| 21 |
+
eslintConfigPrettier,
|
| 22 |
+
|
| 23 |
+
// Global configuration with strict rules for source files
|
| 24 |
+
{
|
| 25 |
+
files: ['src/**/*.{ts,tsx}'],
|
| 26 |
+
rules: {
|
| 27 |
+
// =============================================
|
| 28 |
+
// TypeScript Strict Rules
|
| 29 |
+
// =============================================
|
| 30 |
+
|
| 31 |
+
// Enforce explicit return types on functions
|
| 32 |
+
'@typescript-eslint/explicit-function-return-type': [
|
| 33 |
+
'warn',
|
| 34 |
+
{
|
| 35 |
+
allowExpressions: true,
|
| 36 |
+
allowTypedFunctionExpressions: true,
|
| 37 |
+
},
|
| 38 |
+
],
|
| 39 |
+
|
| 40 |
+
// Require explicit accessibility modifiers
|
| 41 |
+
'@typescript-eslint/explicit-member-accessibility': [
|
| 42 |
+
'error',
|
| 43 |
+
{
|
| 44 |
+
accessibility: 'explicit',
|
| 45 |
+
overrides: {
|
| 46 |
+
constructors: 'no-public',
|
| 47 |
+
},
|
| 48 |
+
},
|
| 49 |
+
],
|
| 50 |
+
|
| 51 |
+
// Disallow 'any' type usage
|
| 52 |
+
'@typescript-eslint/no-explicit-any': 'error',
|
| 53 |
+
|
| 54 |
+
// Require consistent type assertions
|
| 55 |
+
'@typescript-eslint/consistent-type-assertions': [
|
| 56 |
+
'error',
|
| 57 |
+
{
|
| 58 |
+
assertionStyle: 'as',
|
| 59 |
+
objectLiteralTypeAssertions: 'never',
|
| 60 |
+
},
|
| 61 |
+
],
|
| 62 |
+
|
| 63 |
+
// No unused variables (with underscore exception)
|
| 64 |
+
'@typescript-eslint/no-unused-vars': [
|
| 65 |
+
'error',
|
| 66 |
+
{
|
| 67 |
+
argsIgnorePattern: '^_',
|
| 68 |
+
varsIgnorePattern: '^_',
|
| 69 |
+
},
|
| 70 |
+
],
|
| 71 |
+
|
| 72 |
+
// =============================================
|
| 73 |
+
// React & Next.js Rules
|
| 74 |
+
// =============================================
|
| 75 |
+
|
| 76 |
+
// Enforce hooks rules strictly
|
| 77 |
+
'react-hooks/rules-of-hooks': 'error',
|
| 78 |
+
'react-hooks/exhaustive-deps': 'warn',
|
| 79 |
+
|
| 80 |
+
// Prevent missing key props in iterators
|
| 81 |
+
'react/jsx-key': ['error', { checkFragmentShorthand: true }],
|
| 82 |
+
|
| 83 |
+
// No array index as key
|
| 84 |
+
'react/no-array-index-key': 'warn',
|
| 85 |
+
|
| 86 |
+
// =============================================
|
| 87 |
+
// General Best Practices
|
| 88 |
+
// =============================================
|
| 89 |
+
|
| 90 |
+
// No console statements in production
|
| 91 |
+
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
| 92 |
+
|
| 93 |
+
// Enforce strict equality
|
| 94 |
+
eqeqeq: ['error', 'always'],
|
| 95 |
+
|
| 96 |
+
// No debugger statements
|
| 97 |
+
'no-debugger': 'error',
|
| 98 |
+
|
| 99 |
+
// Prefer const over let when possible
|
| 100 |
+
'prefer-const': 'error',
|
| 101 |
+
|
| 102 |
+
// No var declarations
|
| 103 |
+
'no-var': 'error',
|
| 104 |
+
},
|
| 105 |
+
},
|
| 106 |
+
|
| 107 |
+
// Override default ignores of eslint-config-next
|
| 108 |
+
globalIgnores([
|
| 109 |
+
// Default ignores of eslint-config-next:
|
| 110 |
+
'.next/**',
|
| 111 |
+
'out/**',
|
| 112 |
+
'build/**',
|
| 113 |
+
'next-env.d.ts',
|
| 114 |
+
// Additional ignores
|
| 115 |
+
'node_modules/**',
|
| 116 |
+
'coverage/**',
|
| 117 |
+
]),
|
| 118 |
+
]);
|
| 119 |
+
|
| 120 |
+
export default eslintConfig;
|
frontend/next.config.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Next.js Configuration for Production Docker Deployment
|
| 5 |
+
*
|
| 6 |
+
* This configuration is optimized for containerized deployments on platforms
|
| 7 |
+
* like HuggingFace Spaces, Vercel, or any Docker-based hosting.
|
| 8 |
+
*
|
| 9 |
+
* Key features:
|
| 10 |
+
* - Standalone output for minimal Docker image size
|
| 11 |
+
* - Security hardening (no x-powered-by header)
|
| 12 |
+
* - Optimized image handling with modern formats
|
| 13 |
+
* - Environment variable support for dynamic API configuration
|
| 14 |
+
*/
|
| 15 |
+
const nextConfig: NextConfig = {
|
| 16 |
+
/**
|
| 17 |
+
* Output Mode: Standalone
|
| 18 |
+
*
|
| 19 |
+
* Generates a standalone folder with only the necessary files for production.
|
| 20 |
+
* This dramatically reduces Docker image size by:
|
| 21 |
+
* - Including only required node_modules (not devDependencies)
|
| 22 |
+
* - Creating a minimal server.js that doesn't require the full Next.js installation
|
| 23 |
+
* - Copying only the files needed for production
|
| 24 |
+
*
|
| 25 |
+
* The standalone output is located at `.next/standalone/` after build.
|
| 26 |
+
* To run: `node .next/standalone/server.js`
|
| 27 |
+
*
|
| 28 |
+
* @see https://nextjs.org/docs/app/api-reference/config/next-config-js/output
|
| 29 |
+
*/
|
| 30 |
+
output: "standalone",
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* React Strict Mode
|
| 34 |
+
*
|
| 35 |
+
* Enables React's Strict Mode for the entire application.
|
| 36 |
+
* Benefits:
|
| 37 |
+
* - Identifies components with unsafe lifecycles
|
| 38 |
+
* - Warns about deprecated API usage
|
| 39 |
+
* - Detects unexpected side effects by double-invoking certain functions
|
| 40 |
+
* - Ensures reusable state (important for React 18+ concurrent features)
|
| 41 |
+
*
|
| 42 |
+
* Note: This only affects development mode; production builds are not impacted.
|
| 43 |
+
*
|
| 44 |
+
* @see https://react.dev/reference/react/StrictMode
|
| 45 |
+
*/
|
| 46 |
+
reactStrictMode: true,
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Security: Disable x-powered-by Header
|
| 50 |
+
*
|
| 51 |
+
* Removes the "X-Powered-By: Next.js" HTTP response header.
|
| 52 |
+
* Security benefits:
|
| 53 |
+
* - Reduces information disclosure about the tech stack
|
| 54 |
+
* - Makes it slightly harder for attackers to target Next.js-specific vulnerabilities
|
| 55 |
+
* - Follows security best practice of minimizing fingerprinting
|
| 56 |
+
*
|
| 57 |
+
* @see https://nextjs.org/docs/app/api-reference/config/next-config-js/poweredByHeader
|
| 58 |
+
*/
|
| 59 |
+
poweredByHeader: false,
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Image Optimization Configuration
|
| 63 |
+
*
|
| 64 |
+
* Configures Next.js Image component optimization settings.
|
| 65 |
+
* This affects how images are processed, cached, and served.
|
| 66 |
+
*/
|
| 67 |
+
images: {
|
| 68 |
+
/**
|
| 69 |
+
* Image Formats
|
| 70 |
+
*
|
| 71 |
+
* Specifies the output formats for optimized images, in order of preference.
|
| 72 |
+
* - AVIF: Best compression, ~50% smaller than JPEG, but slower to encode
|
| 73 |
+
* - WebP: Good compression, ~30% smaller than JPEG, widely supported
|
| 74 |
+
*
|
| 75 |
+
* The browser's Accept header determines which format is served.
|
| 76 |
+
* Modern browsers get AVIF/WebP; older browsers fall back to original format.
|
| 77 |
+
*
|
| 78 |
+
* Note: AVIF encoding is CPU-intensive; consider removing if build times are critical.
|
| 79 |
+
*/
|
| 80 |
+
formats: ["image/avif", "image/webp"],
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Remote Patterns
|
| 84 |
+
*
|
| 85 |
+
* Defines allowed external image sources for security.
|
| 86 |
+
* Only images from these patterns can be optimized by Next.js.
|
| 87 |
+
* Add patterns here if you need to serve images from external CDNs or APIs.
|
| 88 |
+
*
|
| 89 |
+
* Example:
|
| 90 |
+
* remotePatterns: [
|
| 91 |
+
* { protocol: 'https', hostname: 'cdn.example.com', pathname: '/images/**' }
|
| 92 |
+
* ]
|
| 93 |
+
*/
|
| 94 |
+
remotePatterns: [],
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Unoptimized Mode
|
| 98 |
+
*
|
| 99 |
+
* When true, disables image optimization entirely.
|
| 100 |
+
* Set to true if:
|
| 101 |
+
* - Deploying to a platform without image optimization support
|
| 102 |
+
* - Using an external image CDN (Cloudinary, imgix, etc.)
|
| 103 |
+
* - Debugging image-related issues
|
| 104 |
+
*
|
| 105 |
+
* Default: false (optimization enabled)
|
| 106 |
+
*/
|
| 107 |
+
unoptimized: false,
|
| 108 |
+
},
|
| 109 |
+
|
| 110 |
+
/**
|
| 111 |
+
* Environment Variables Configuration
|
| 112 |
+
*
|
| 113 |
+
* Exposes environment variables to the browser (client-side).
|
| 114 |
+
* Variables listed here are inlined at build time.
|
| 115 |
+
*
|
| 116 |
+
* IMPORTANT: Only expose non-sensitive values here.
|
| 117 |
+
* Never expose API keys, secrets, or credentials.
|
| 118 |
+
*
|
| 119 |
+
* For runtime configuration (not inlined at build), use:
|
| 120 |
+
* - NEXT_PUBLIC_* prefix for client-side runtime vars
|
| 121 |
+
* - Server-side API routes for sensitive operations
|
| 122 |
+
*/
|
| 123 |
+
env: {
|
| 124 |
+
/**
|
| 125 |
+
* API Base URL
|
| 126 |
+
*
|
| 127 |
+
* The base URL for the backend API server.
|
| 128 |
+
* - Development: Usually http://localhost:8000
|
| 129 |
+
* - Production: The deployed backend URL or relative path
|
| 130 |
+
*
|
| 131 |
+
* Falls back to empty string for same-origin API calls (relative URLs).
|
| 132 |
+
* This allows the frontend to work with both:
|
| 133 |
+
* - Separate backend deployment (absolute URL)
|
| 134 |
+
* - Same-origin deployment (relative URL like /api)
|
| 135 |
+
*/
|
| 136 |
+
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "",
|
| 137 |
+
},
|
| 138 |
+
|
| 139 |
+
/**
|
| 140 |
+
* Experimental Features
|
| 141 |
+
*
|
| 142 |
+
* Enable experimental Next.js features.
|
| 143 |
+
* Use with caution in production as these may change between versions.
|
| 144 |
+
*/
|
| 145 |
+
experimental: {
|
| 146 |
+
/**
|
| 147 |
+
* Optimize Package Imports
|
| 148 |
+
*
|
| 149 |
+
* Enables automatic tree-shaking for specific packages.
|
| 150 |
+
* Reduces bundle size by only importing used exports.
|
| 151 |
+
* Particularly useful for large UI libraries.
|
| 152 |
+
*
|
| 153 |
+
* @see https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports
|
| 154 |
+
*/
|
| 155 |
+
optimizePackageImports: ["lucide-react"],
|
| 156 |
+
},
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Webpack Configuration Override
|
| 160 |
+
*
|
| 161 |
+
* Custom webpack configuration for advanced build customization.
|
| 162 |
+
* Use sparingly as it can complicate upgrades and debugging.
|
| 163 |
+
*
|
| 164 |
+
* @param config - The existing webpack configuration
|
| 165 |
+
* @param context - Build context including isServer, dev, etc.
|
| 166 |
+
* @returns Modified webpack configuration
|
| 167 |
+
*/
|
| 168 |
+
webpack: (config, { isServer }) => {
|
| 169 |
+
/**
|
| 170 |
+
* Suppress Critical Dependency Warnings
|
| 171 |
+
*
|
| 172 |
+
* Some packages (especially those using dynamic requires) trigger
|
| 173 |
+
* webpack warnings that are not actionable. This filter suppresses
|
| 174 |
+
* known false-positive warnings to keep build output clean.
|
| 175 |
+
*/
|
| 176 |
+
if (!isServer) {
|
| 177 |
+
// Client-side specific webpack modifications can go here
|
| 178 |
+
// Example: config.resolve.fallback = { fs: false, path: false };
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
return config;
|
| 182 |
+
},
|
| 183 |
+
|
| 184 |
+
/**
|
| 185 |
+
* HTTP Headers Configuration
|
| 186 |
+
*
|
| 187 |
+
* Custom HTTP headers for all routes.
|
| 188 |
+
* Used for security headers, caching policies, and CORS.
|
| 189 |
+
*
|
| 190 |
+
* @returns Array of header configurations
|
| 191 |
+
*/
|
| 192 |
+
async headers() {
|
| 193 |
+
return [
|
| 194 |
+
{
|
| 195 |
+
// Apply to all routes
|
| 196 |
+
source: "/:path*",
|
| 197 |
+
headers: [
|
| 198 |
+
/**
|
| 199 |
+
* Content Security Policy
|
| 200 |
+
*
|
| 201 |
+
* Restricts resource loading to prevent XSS attacks.
|
| 202 |
+
* Customize based on your application's needs.
|
| 203 |
+
* Note: This is a basic policy; adjust for production.
|
| 204 |
+
*/
|
| 205 |
+
// Uncomment and customize for production:
|
| 206 |
+
// {
|
| 207 |
+
// key: 'Content-Security-Policy',
|
| 208 |
+
// value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
|
| 209 |
+
// },
|
| 210 |
+
|
| 211 |
+
/**
|
| 212 |
+
* X-Content-Type-Options
|
| 213 |
+
*
|
| 214 |
+
* Prevents MIME type sniffing attacks.
|
| 215 |
+
* Forces browser to respect declared Content-Type.
|
| 216 |
+
*/
|
| 217 |
+
{
|
| 218 |
+
key: "X-Content-Type-Options",
|
| 219 |
+
value: "nosniff",
|
| 220 |
+
},
|
| 221 |
+
|
| 222 |
+
/**
|
| 223 |
+
* X-Frame-Options
|
| 224 |
+
*
|
| 225 |
+
* Prevents clickjacking by controlling iframe embedding.
|
| 226 |
+
* DENY: Cannot be embedded in any iframe
|
| 227 |
+
* SAMEORIGIN: Can only be embedded by same-origin pages
|
| 228 |
+
*/
|
| 229 |
+
{
|
| 230 |
+
key: "X-Frame-Options",
|
| 231 |
+
value: "SAMEORIGIN",
|
| 232 |
+
},
|
| 233 |
+
|
| 234 |
+
/**
|
| 235 |
+
* X-XSS-Protection
|
| 236 |
+
*
|
| 237 |
+
* Enables browser's built-in XSS filtering.
|
| 238 |
+
* Note: Modern browsers rely more on CSP, but this provides
|
| 239 |
+
* additional protection for older browsers.
|
| 240 |
+
*/
|
| 241 |
+
{
|
| 242 |
+
key: "X-XSS-Protection",
|
| 243 |
+
value: "1; mode=block",
|
| 244 |
+
},
|
| 245 |
+
|
| 246 |
+
/**
|
| 247 |
+
* Referrer-Policy
|
| 248 |
+
*
|
| 249 |
+
* Controls how much referrer information is sent with requests.
|
| 250 |
+
* strict-origin-when-cross-origin: Full path for same-origin,
|
| 251 |
+
* only origin for cross-origin, nothing for downgrade to HTTP.
|
| 252 |
+
*/
|
| 253 |
+
{
|
| 254 |
+
key: "Referrer-Policy",
|
| 255 |
+
value: "strict-origin-when-cross-origin",
|
| 256 |
+
},
|
| 257 |
+
],
|
| 258 |
+
},
|
| 259 |
+
];
|
| 260 |
+
},
|
| 261 |
+
};
|
| 262 |
+
|
| 263 |
+
export default nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "next dev",
|
| 8 |
+
"build": "next build",
|
| 9 |
+
"start": "next start",
|
| 10 |
+
"lint": "eslint",
|
| 11 |
+
"lint:fix": "next lint --fix",
|
| 12 |
+
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
| 13 |
+
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
| 14 |
+
"typecheck": "tsc --noEmit",
|
| 15 |
+
"test": "vitest",
|
| 16 |
+
"test:coverage": "vitest --coverage"
|
| 17 |
+
},
|
| 18 |
+
"dependencies": {
|
| 19 |
+
"@tailwindcss/typography": "^0.5.19",
|
| 20 |
+
"class-variance-authority": "^0.7.1",
|
| 21 |
+
"clsx": "^2.1.1",
|
| 22 |
+
"framer-motion": "^12.26.2",
|
| 23 |
+
"lucide-react": "^0.562.0",
|
| 24 |
+
"next": "16.1.2",
|
| 25 |
+
"react": "19.2.3",
|
| 26 |
+
"react-dom": "19.2.3",
|
| 27 |
+
"react-markdown": "^10.1.0",
|
| 28 |
+
"rehype-highlight": "^7.0.2",
|
| 29 |
+
"remark-gfm": "^4.0.1",
|
| 30 |
+
"tailwind-merge": "^3.4.0"
|
| 31 |
+
},
|
| 32 |
+
"devDependencies": {
|
| 33 |
+
"@eslint/eslintrc": "^3.3.3",
|
| 34 |
+
"@tailwindcss/postcss": "^4",
|
| 35 |
+
"@testing-library/jest-dom": "^6.6.3",
|
| 36 |
+
"@testing-library/react": "^16.3.0",
|
| 37 |
+
"@testing-library/user-event": "^14.6.1",
|
| 38 |
+
"@types/node": "^20",
|
| 39 |
+
"@types/react": "^19",
|
| 40 |
+
"@types/react-dom": "^19",
|
| 41 |
+
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
| 42 |
+
"@typescript-eslint/parser": "^8.53.0",
|
| 43 |
+
"@vitejs/plugin-react": "^4.5.2",
|
| 44 |
+
"@vitest/coverage-v8": "^3.2.4",
|
| 45 |
+
"eslint": "^9",
|
| 46 |
+
"eslint-config-next": "16.1.2",
|
| 47 |
+
"eslint-config-prettier": "^10.1.8",
|
| 48 |
+
"eslint-plugin-prettier": "^5.5.5",
|
| 49 |
+
"jsdom": "^26.1.0",
|
| 50 |
+
"prettier": "^3.8.0",
|
| 51 |
+
"prettier-plugin-tailwindcss": "^0.7.2",
|
| 52 |
+
"tailwindcss": "^4",
|
| 53 |
+
"typescript": "^5",
|
| 54 |
+
"vitest": "^3.2.4"
|
| 55 |
+
}
|
| 56 |
+
}
|
frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
frontend/prettier.config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Prettier Configuration for Next.js Frontend
|
| 3 |
+
*
|
| 4 |
+
* This configuration ensures consistent code formatting across the
|
| 5 |
+
* RAG Chatbot frontend codebase.
|
| 6 |
+
*
|
| 7 |
+
* @see https://prettier.io/docs/en/configuration.html
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
/** @type {import('prettier').Config} */
|
| 11 |
+
const config = {
|
| 12 |
+
// =============================================
|
| 13 |
+
// Basic Formatting Rules
|
| 14 |
+
// =============================================
|
| 15 |
+
|
| 16 |
+
// Use single quotes for strings
|
| 17 |
+
singleQuote: true,
|
| 18 |
+
|
| 19 |
+
// Add semicolons at the end of statements
|
| 20 |
+
semi: true,
|
| 21 |
+
|
| 22 |
+
// Use 2 spaces for indentation
|
| 23 |
+
tabWidth: 2,
|
| 24 |
+
|
| 25 |
+
// Don't use tabs, use spaces
|
| 26 |
+
useTabs: false,
|
| 27 |
+
|
| 28 |
+
// Maximum line length before wrapping
|
| 29 |
+
printWidth: 80,
|
| 30 |
+
|
| 31 |
+
// =============================================
|
| 32 |
+
// Trailing Commas & Brackets
|
| 33 |
+
// =============================================
|
| 34 |
+
|
| 35 |
+
// Add trailing commas where valid in ES5 (objects, arrays, etc.)
|
| 36 |
+
trailingComma: 'es5',
|
| 37 |
+
|
| 38 |
+
// Put the > of a multi-line element at the end of the last line
|
| 39 |
+
bracketSameLine: false,
|
| 40 |
+
|
| 41 |
+
// Include parentheses around a sole arrow function parameter
|
| 42 |
+
arrowParens: 'always',
|
| 43 |
+
|
| 44 |
+
// =============================================
|
| 45 |
+
// JSX Formatting
|
| 46 |
+
// =============================================
|
| 47 |
+
|
| 48 |
+
// Use single quotes in JSX
|
| 49 |
+
jsxSingleQuote: false,
|
| 50 |
+
|
| 51 |
+
// =============================================
|
| 52 |
+
// Plugins
|
| 53 |
+
// =============================================
|
| 54 |
+
|
| 55 |
+
// Tailwind CSS class sorting plugin
|
| 56 |
+
plugins: ['prettier-plugin-tailwindcss'],
|
| 57 |
+
|
| 58 |
+
// =============================================
|
| 59 |
+
// File-specific Overrides
|
| 60 |
+
// =============================================
|
| 61 |
+
|
| 62 |
+
overrides: [
|
| 63 |
+
{
|
| 64 |
+
// JSON files should use 2-space indentation
|
| 65 |
+
files: '*.json',
|
| 66 |
+
options: {
|
| 67 |
+
tabWidth: 2,
|
| 68 |
+
},
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
// Markdown files have wider print width
|
| 72 |
+
files: '*.md',
|
| 73 |
+
options: {
|
| 74 |
+
printWidth: 100,
|
| 75 |
+
proseWrap: 'always',
|
| 76 |
+
},
|
| 77 |
+
},
|
| 78 |
+
],
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
export default config;
|
frontend/public/file.svg
ADDED
|
|
frontend/public/globe.svg
ADDED
|
|
frontend/public/next.svg
ADDED
|
|
frontend/public/vercel.svg
ADDED
|
|
frontend/public/window.svg
ADDED
|
|
frontend/src/app/favicon.ico
ADDED
|
|
frontend/src/app/globals.css
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Global Styles for RAG Chatbot Frontend
|
| 3 |
+
*
|
| 4 |
+
* This file contains global CSS variables, base styles, and
|
| 5 |
+
* utility classes that complement Tailwind CSS.
|
| 6 |
+
*
|
| 7 |
+
* Color Theme: Modern Purple AI-Assistant Aesthetic
|
| 8 |
+
* ================================================
|
| 9 |
+
* The purple color palette was chosen to convey intelligence, creativity,
|
| 10 |
+
* and technological sophistication - qualities often associated with AI assistants.
|
| 11 |
+
* Purple combines the stability of blue with the energy of red, creating a
|
| 12 |
+
* balanced, professional appearance suitable for a chatbot interface.
|
| 13 |
+
*
|
| 14 |
+
* @see https://tailwindcss.com/docs/adding-custom-styles
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
@import 'tailwindcss';
|
| 18 |
+
@plugin "@tailwindcss/typography";
|
| 19 |
+
@import "highlight.js/styles/atom-one-dark.css";
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* CSS Custom Properties (Design Tokens)
|
| 23 |
+
*
|
| 24 |
+
* These variables define the core design tokens for the application,
|
| 25 |
+
* enabling easy theming and dark mode support.
|
| 26 |
+
*
|
| 27 |
+
* Theme Selection Logic:
|
| 28 |
+
* - When `.dark` class is present on html -> dark mode (explicit override)
|
| 29 |
+
* - When `.light` class is present on html -> light mode (explicit override)
|
| 30 |
+
* - When neither class is present -> follow system preference
|
| 31 |
+
*/
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Light Mode Theme (default or explicit via .light class)
|
| 35 |
+
*
|
| 36 |
+
* Applied by default and when the user explicitly selects light mode.
|
| 37 |
+
*
|
| 38 |
+
* Purple Palette Accessibility Notes:
|
| 39 |
+
* -----------------------------------
|
| 40 |
+
* - Primary 500 (#a855f7): Main brand purple, vibrant and modern
|
| 41 |
+
* - Primary 700 (#7c3aed): Used for text on white - achieves 5.0:1 contrast (WCAG AA)
|
| 42 |
+
* - Button text uses white (#ffffff) on purple-500 for 4.58:1 contrast (WCAG AA for large text)
|
| 43 |
+
* - For critical small text on purple backgrounds, use darker purple shades
|
| 44 |
+
*/
|
| 45 |
+
:root,
|
| 46 |
+
:root.light {
|
| 47 |
+
/**
|
| 48 |
+
* Primary Brand Colors - Modern Purple Palette
|
| 49 |
+
*
|
| 50 |
+
* This gradient from light lavender (50) to deep purple (900) provides
|
| 51 |
+
* versatility for backgrounds, borders, text, and interactive states.
|
| 52 |
+
* The purple family evokes creativity, wisdom, and technological innovation.
|
| 53 |
+
*/
|
| 54 |
+
--color-primary-50: #faf5ff; /* Very light lavender - subtle backgrounds, hover states */
|
| 55 |
+
--color-primary-100: #f3e8ff; /* Light lavender - secondary backgrounds */
|
| 56 |
+
--color-primary-200: #e9d5ff; /* Soft purple - selection backgrounds in light mode */
|
| 57 |
+
--color-primary-300: #d8b4fe; /* Medium-light purple - decorative elements */
|
| 58 |
+
--color-primary-400: #c084fc; /* Medium purple - icons, secondary elements */
|
| 59 |
+
--color-primary-500: #a855f7; /* Main purple - primary buttons, key accents */
|
| 60 |
+
--color-primary-600: #9333ea; /* Rich purple - active states */
|
| 61 |
+
--color-primary-700: #7c3aed; /* Dark purple - hover states, accessible text */
|
| 62 |
+
--color-primary-800: #6b21a8; /* Deep purple - pressed states */
|
| 63 |
+
--color-primary-900: #581c87; /* Darkest purple - text on light backgrounds */
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* Accessible Primary Text Color
|
| 67 |
+
*
|
| 68 |
+
* #7c3aed (purple-700) provides 5.0:1 contrast ratio on white (#ffffff),
|
| 69 |
+
* exceeding the WCAG AA requirement of 4.5:1 for normal text.
|
| 70 |
+
* This ensures readability for links, labels, and highlighted text
|
| 71 |
+
* while maintaining the purple brand identity.
|
| 72 |
+
*
|
| 73 |
+
* Contrast verification: https://webaim.org/resources/contrastchecker/
|
| 74 |
+
* - #7c3aed on #ffffff = 5.0:1 (passes WCAG AA for normal text)
|
| 75 |
+
* - #7c3aed on #f8fafc = 4.85:1 (passes WCAG AA for large text, AAA for UI components)
|
| 76 |
+
*/
|
| 77 |
+
--color-primary-text: #7c3aed;
|
| 78 |
+
/* WCAG AA: 5.0:1 on white */
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* Primary Button Text Color
|
| 82 |
+
*
|
| 83 |
+
* White (#ffffff) text on purple-500 (#a855f7) background provides
|
| 84 |
+
* 4.58:1 contrast ratio. This passes WCAG AA for large text (18pt+)
|
| 85 |
+
* and UI components. For buttons with smaller text, consider using
|
| 86 |
+
* a darker purple background (600 or 700) to achieve higher contrast.
|
| 87 |
+
*
|
| 88 |
+
* Contrast verification:
|
| 89 |
+
* - #ffffff on #a855f7 = 4.58:1 (passes WCAG AA for large text/UI)
|
| 90 |
+
* - #ffffff on #9333ea = 5.69:1 (passes WCAG AA for all text sizes)
|
| 91 |
+
*/
|
| 92 |
+
--color-primary-button-text: #ffffff;
|
| 93 |
+
/* WCAG AA: 4.58:1 on primary-500 (large text/UI), 5.69:1 on primary-600 */
|
| 94 |
+
|
| 95 |
+
/* Background colors */
|
| 96 |
+
--background: #ffffff;
|
| 97 |
+
--background-secondary: #f8fafc;
|
| 98 |
+
--background-tertiary: #f1f5f9;
|
| 99 |
+
|
| 100 |
+
/* Text colors */
|
| 101 |
+
--foreground: #0f172a;
|
| 102 |
+
--foreground-secondary: #475569;
|
| 103 |
+
--foreground-muted: #64748b;
|
| 104 |
+
/* WCAG AA: 4.76:1 on white, 4.55:1 on bg-secondary */
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* Border Colors
|
| 108 |
+
*
|
| 109 |
+
* --border: Decorative borders for visual separation (cards, dividers)
|
| 110 |
+
* --border-ui: Essential UI component borders (inputs, selects) - higher contrast
|
| 111 |
+
* --border-focus: Focus ring color using purple-500 for brand consistency
|
| 112 |
+
*/
|
| 113 |
+
--border: #e2e8f0;
|
| 114 |
+
/* Decorative borders - use --border-ui for essential UI */
|
| 115 |
+
--border-ui: #767f8c;
|
| 116 |
+
/* WCAG AA: 4.05:1 on white for essential UI components */
|
| 117 |
+
--border-focus: #a855f7;
|
| 118 |
+
/* Purple-500: Visible focus indicator matching brand color */
|
| 119 |
+
|
| 120 |
+
/* Status colors */
|
| 121 |
+
--success: #22c55e;
|
| 122 |
+
--warning: #f59e0b;
|
| 123 |
+
--error: #ef4444;
|
| 124 |
+
|
| 125 |
+
/* Shadows */
|
| 126 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
| 127 |
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
| 128 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
| 129 |
+
|
| 130 |
+
/* Border radius */
|
| 131 |
+
--radius-sm: 0.375rem;
|
| 132 |
+
--radius-md: 0.5rem;
|
| 133 |
+
--radius-lg: 0.75rem;
|
| 134 |
+
--radius-full: 9999px;
|
| 135 |
+
|
| 136 |
+
/* Transitions */
|
| 137 |
+
--transition-fast: 150ms ease;
|
| 138 |
+
--transition-normal: 200ms ease;
|
| 139 |
+
--transition-slow: 300ms ease;
|
| 140 |
+
|
| 141 |
+
/* Easing functions */
|
| 142 |
+
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
| 143 |
+
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
| 144 |
+
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/**
|
| 148 |
+
* Dark Mode Theme via System Preference
|
| 149 |
+
*
|
| 150 |
+
* Automatically activated when the user's OS prefers dark color scheme
|
| 151 |
+
* AND no explicit theme class (.light or .dark) is set on the html element.
|
| 152 |
+
* This respects system preference while allowing manual override.
|
| 153 |
+
*
|
| 154 |
+
* Dark Mode Purple Adjustments:
|
| 155 |
+
* -----------------------------
|
| 156 |
+
* - Focus border uses purple-400 (#c084fc) for better visibility on dark backgrounds
|
| 157 |
+
* - Lighter purple shades become more prominent to maintain visual hierarchy
|
| 158 |
+
* - Shadows are intensified for depth perception on dark surfaces
|
| 159 |
+
*/
|
| 160 |
+
@media (prefers-color-scheme: dark) {
|
| 161 |
+
:root:not(.light):not(.dark) {
|
| 162 |
+
--background: #0f172a;
|
| 163 |
+
--background-secondary: #1e293b;
|
| 164 |
+
--background-tertiary: #334155;
|
| 165 |
+
|
| 166 |
+
--foreground: #f8fafc;
|
| 167 |
+
--foreground-secondary: #cbd5e1;
|
| 168 |
+
--foreground-muted: #a3afc0;
|
| 169 |
+
/* WCAG AA: 8.03:1 on bg, 6.58:1 on bg-secondary, 4.66:1 on bg-tertiary */
|
| 170 |
+
|
| 171 |
+
--border: #334155;
|
| 172 |
+
/* Decorative borders - use --border-ui for essential UI */
|
| 173 |
+
--border-ui: #64748b;
|
| 174 |
+
/* WCAG AA: 3.75:1 on dark bg for essential UI components */
|
| 175 |
+
--border-focus: #c084fc;
|
| 176 |
+
/* Purple-400: Brighter purple for visibility on dark backgrounds */
|
| 177 |
+
|
| 178 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
| 179 |
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
|
| 180 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5);
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/**
|
| 185 |
+
* Dark Mode Theme via Explicit Class
|
| 186 |
+
*
|
| 187 |
+
* Applied when the user explicitly selects dark mode via the theme toggle.
|
| 188 |
+
* This overrides the system preference, allowing users to choose dark mode
|
| 189 |
+
* even when their OS is set to light mode.
|
| 190 |
+
*
|
| 191 |
+
* Matches the system-preference dark mode settings for consistency.
|
| 192 |
+
*/
|
| 193 |
+
:root.dark {
|
| 194 |
+
--background: #0f172a;
|
| 195 |
+
--background-secondary: #1e293b;
|
| 196 |
+
--background-tertiary: #334155;
|
| 197 |
+
|
| 198 |
+
--foreground: #f8fafc;
|
| 199 |
+
--foreground-secondary: #cbd5e1;
|
| 200 |
+
--foreground-muted: #a3afc0;
|
| 201 |
+
/* WCAG AA: 8.03:1 on bg, 6.58:1 on bg-secondary, 4.66:1 on bg-tertiary */
|
| 202 |
+
|
| 203 |
+
--border: #334155;
|
| 204 |
+
/* Decorative borders - use --border-ui for essential UI */
|
| 205 |
+
--border-ui: #64748b;
|
| 206 |
+
/* WCAG AA: 3.75:1 on dark bg for essential UI components */
|
| 207 |
+
--border-focus: #c084fc;
|
| 208 |
+
/* Purple-400: Brighter purple for visibility on dark backgrounds */
|
| 209 |
+
|
| 210 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
| 211 |
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
|
| 212 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/**
|
| 216 |
+
* Base Element Styles
|
| 217 |
+
*
|
| 218 |
+
* Apply consistent base styles to HTML elements.
|
| 219 |
+
*/
|
| 220 |
+
html {
|
| 221 |
+
/* Smooth scrolling for anchor links */
|
| 222 |
+
scroll-behavior: smooth;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
body {
|
| 226 |
+
/* Apply design tokens to body */
|
| 227 |
+
background-color: var(--background);
|
| 228 |
+
color: var(--foreground);
|
| 229 |
+
|
| 230 |
+
/* Optimize text rendering */
|
| 231 |
+
-webkit-font-smoothing: antialiased;
|
| 232 |
+
-moz-osx-font-smoothing: grayscale;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/**
|
| 236 |
+
* Focus Styles
|
| 237 |
+
*
|
| 238 |
+
* Consistent focus ring for accessibility.
|
| 239 |
+
* Uses the purple brand color for cohesive visual identity
|
| 240 |
+
* while maintaining clear focus indication for keyboard navigation.
|
| 241 |
+
*/
|
| 242 |
+
:focus-visible {
|
| 243 |
+
outline: 2px solid var(--border-focus);
|
| 244 |
+
outline-offset: 2px;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/**
|
| 248 |
+
* Selection Styles
|
| 249 |
+
*
|
| 250 |
+
* Custom text selection colors using the purple palette.
|
| 251 |
+
*
|
| 252 |
+
* Light mode: Light purple background (#e9d5ff / primary-200) with dark purple text
|
| 253 |
+
* Dark mode: Dark purple background (#7c3aed / primary-700) with light lavender text
|
| 254 |
+
*
|
| 255 |
+
* These combinations ensure selected text remains readable while
|
| 256 |
+
* reinforcing the purple brand identity throughout the interface.
|
| 257 |
+
*/
|
| 258 |
+
::selection {
|
| 259 |
+
background-color: var(--color-primary-200);
|
| 260 |
+
/* #e9d5ff - soft purple selection background */
|
| 261 |
+
color: var(--color-primary-900);
|
| 262 |
+
/* #581c87 - dark purple text for contrast */
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/**
|
| 266 |
+
* Dark mode selection styles via system preference
|
| 267 |
+
* Applied when no explicit theme class is set and system prefers dark mode
|
| 268 |
+
*/
|
| 269 |
+
@media (prefers-color-scheme: dark) {
|
| 270 |
+
:root:not(.light):not(.dark) ::selection {
|
| 271 |
+
background-color: var(--color-primary-700);
|
| 272 |
+
/* #7c3aed - rich purple background */
|
| 273 |
+
color: var(--color-primary-50);
|
| 274 |
+
/* #faf5ff - light lavender text */
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
/**
|
| 279 |
+
* Dark mode selection styles via explicit class
|
| 280 |
+
* Applied when .dark class is set on html element
|
| 281 |
+
*/
|
| 282 |
+
:root.dark ::selection {
|
| 283 |
+
background-color: var(--color-primary-700);
|
| 284 |
+
/* #7c3aed - rich purple background */
|
| 285 |
+
color: var(--color-primary-50);
|
| 286 |
+
/* #faf5ff - light lavender text */
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
/**
|
| 290 |
+
* Scrollbar Styles (Webkit)
|
| 291 |
+
*
|
| 292 |
+
* Custom scrollbar appearance for webkit browsers.
|
| 293 |
+
*/
|
| 294 |
+
::-webkit-scrollbar {
|
| 295 |
+
width: 8px;
|
| 296 |
+
height: 8px;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
::-webkit-scrollbar-track {
|
| 300 |
+
background: var(--background-secondary);
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
::-webkit-scrollbar-thumb {
|
| 304 |
+
background: var(--foreground-muted);
|
| 305 |
+
border-radius: var(--radius-full);
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
::-webkit-scrollbar-thumb:hover {
|
| 309 |
+
background: var(--foreground-secondary);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/**
|
| 313 |
+
* Custom Animation Keyframes
|
| 314 |
+
*/
|
| 315 |
+
@keyframes fadeIn {
|
| 316 |
+
from {
|
| 317 |
+
opacity: 0;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
to {
|
| 321 |
+
opacity: 1;
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
@keyframes slideUp {
|
| 326 |
+
from {
|
| 327 |
+
opacity: 0;
|
| 328 |
+
transform: translateY(10px);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
to {
|
| 332 |
+
opacity: 1;
|
| 333 |
+
transform: translateY(0);
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
@keyframes pulse {
|
| 338 |
+
|
| 339 |
+
0%,
|
| 340 |
+
100% {
|
| 341 |
+
opacity: 1;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
50% {
|
| 345 |
+
opacity: 0.5;
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
/**
|
| 350 |
+
* Fade and slide in animation for staggered list items.
|
| 351 |
+
* Used by SourceCitations component for smooth entrance effects.
|
| 352 |
+
*/
|
| 353 |
+
@keyframes fadeSlideIn {
|
| 354 |
+
from {
|
| 355 |
+
opacity: 0;
|
| 356 |
+
transform: translateY(0.5rem);
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
to {
|
| 360 |
+
opacity: 1;
|
| 361 |
+
transform: translateY(0);
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
/**
|
| 366 |
+
* Utility Classes
|
| 367 |
+
*/
|
| 368 |
+
.animate-fade-in {
|
| 369 |
+
animation: fadeIn var(--transition-normal) ease-out;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.animate-slide-up {
|
| 373 |
+
animation: slideUp var(--transition-slow) ease-out;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.animate-pulse-custom {
|
| 377 |
+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/**
|
| 381 |
+
* Thermal Comfort Illustration Animations
|
| 382 |
+
*
|
| 383 |
+
* Custom keyframes for the ThermalComfortIllustration component.
|
| 384 |
+
* These provide subtle, professional animations that enhance the
|
| 385 |
+
* visual appeal without being distracting.
|
| 386 |
+
*/
|
| 387 |
+
|
| 388 |
+
/**
|
| 389 |
+
* Gentle floating animation for the main illustration container.
|
| 390 |
+
* Creates a subtle up-and-down motion suggesting thermal air currents.
|
| 391 |
+
*/
|
| 392 |
+
@keyframes thermalFloat {
|
| 393 |
+
|
| 394 |
+
0%,
|
| 395 |
+
100% {
|
| 396 |
+
transform: translateY(0);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
50% {
|
| 400 |
+
transform: translateY(-4px);
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/**
|
| 405 |
+
* Soft pulse animation for comfort zone elements.
|
| 406 |
+
* Provides a gentle "breathing" effect to indicate active thermal monitoring.
|
| 407 |
+
*/
|
| 408 |
+
@keyframes thermalPulse {
|
| 409 |
+
|
| 410 |
+
0%,
|
| 411 |
+
100% {
|
| 412 |
+
opacity: 1;
|
| 413 |
+
transform: scale(1);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
50% {
|
| 417 |
+
opacity: 0.7;
|
| 418 |
+
transform: scale(0.98);
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
/**
|
| 423 |
+
* Wave animation for air flow lines.
|
| 424 |
+
* Creates a gentle horizontal shift suggesting air movement.
|
| 425 |
+
*/
|
| 426 |
+
@keyframes thermalWave {
|
| 427 |
+
|
| 428 |
+
0%,
|
| 429 |
+
100% {
|
| 430 |
+
transform: translateX(0);
|
| 431 |
+
opacity: 0.6;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
50% {
|
| 435 |
+
transform: translateX(3px);
|
| 436 |
+
opacity: 0.9;
|
| 437 |
+
}
|
| 438 |
+
}
|
frontend/src/app/layout.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Root Layout Component
|
| 3 |
+
*
|
| 4 |
+
* This is the root layout for the Next.js App Router. It wraps all pages
|
| 5 |
+
* and provides the base HTML structure, fonts, and global styles.
|
| 6 |
+
*
|
| 7 |
+
* @see https://nextjs.org/docs/app/building-your-application/routing/layouts-and-templates
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import type { Metadata, Viewport } from 'next';
|
| 11 |
+
import { Inter, JetBrains_Mono } from 'next/font/google';
|
| 12 |
+
import './globals.css';
|
| 13 |
+
import { Providers } from './providers';
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Inter font configuration.
|
| 17 |
+
*
|
| 18 |
+
* Inter is used as the primary sans-serif font for its excellent
|
| 19 |
+
* readability and modern appearance.
|
| 20 |
+
*/
|
| 21 |
+
const inter = Inter({
|
| 22 |
+
variable: '--font-inter',
|
| 23 |
+
subsets: ['latin'],
|
| 24 |
+
display: 'swap',
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* JetBrains Mono font configuration.
|
| 29 |
+
*
|
| 30 |
+
* Used for code blocks and monospace text to ensure
|
| 31 |
+
* consistent character widths.
|
| 32 |
+
*/
|
| 33 |
+
const jetbrainsMono = JetBrains_Mono({
|
| 34 |
+
variable: '--font-mono',
|
| 35 |
+
subsets: ['latin'],
|
| 36 |
+
display: 'swap',
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Application metadata for SEO and social sharing.
|
| 41 |
+
*/
|
| 42 |
+
export const metadata: Metadata = {
|
| 43 |
+
title: {
|
| 44 |
+
default: 'pythermalcomfort Chat',
|
| 45 |
+
template: '%s | pythermalcomfort Chat',
|
| 46 |
+
},
|
| 47 |
+
description:
|
| 48 |
+
'Ask questions about thermal comfort standards and the pythermalcomfort Python library. Powered by RAG with multiple LLM providers.',
|
| 49 |
+
keywords: [
|
| 50 |
+
'thermal comfort',
|
| 51 |
+
'pythermalcomfort',
|
| 52 |
+
'ASHRAE',
|
| 53 |
+
'PMV',
|
| 54 |
+
'PPD',
|
| 55 |
+
'adaptive comfort',
|
| 56 |
+
'building science',
|
| 57 |
+
'HVAC',
|
| 58 |
+
],
|
| 59 |
+
authors: [{ name: 'pythermalcomfort Team' }],
|
| 60 |
+
creator: 'pythermalcomfort',
|
| 61 |
+
openGraph: {
|
| 62 |
+
type: 'website',
|
| 63 |
+
locale: 'en_US',
|
| 64 |
+
title: 'pythermalcomfort Chat',
|
| 65 |
+
description:
|
| 66 |
+
'AI-powered assistant for thermal comfort standards and pythermalcomfort library.',
|
| 67 |
+
siteName: 'pythermalcomfort Chat',
|
| 68 |
+
},
|
| 69 |
+
robots: {
|
| 70 |
+
index: true,
|
| 71 |
+
follow: true,
|
| 72 |
+
},
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Viewport configuration for responsive design.
|
| 77 |
+
*/
|
| 78 |
+
export const viewport: Viewport = {
|
| 79 |
+
width: 'device-width',
|
| 80 |
+
initialScale: 1,
|
| 81 |
+
themeColor: [
|
| 82 |
+
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
| 83 |
+
{ media: '(prefers-color-scheme: dark)', color: '#0f172a' },
|
| 84 |
+
],
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Root Layout Props
|
| 89 |
+
*/
|
| 90 |
+
interface RootLayoutProps {
|
| 91 |
+
/** Page content to render */
|
| 92 |
+
children: React.ReactNode;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* Root Layout Component
|
| 97 |
+
*
|
| 98 |
+
* Provides the base HTML structure for all pages in the application.
|
| 99 |
+
* Includes font loading, global styles, and common meta tags.
|
| 100 |
+
*
|
| 101 |
+
* @param props - Component props containing children to render
|
| 102 |
+
* @returns The root HTML structure wrapping all page content
|
| 103 |
+
*/
|
| 104 |
+
export default function RootLayout({
|
| 105 |
+
children,
|
| 106 |
+
}: RootLayoutProps): React.ReactElement {
|
| 107 |
+
return (
|
| 108 |
+
<html
|
| 109 |
+
lang="en"
|
| 110 |
+
className={`${inter.variable} ${jetbrainsMono.variable}`}
|
| 111 |
+
suppressHydrationWarning
|
| 112 |
+
>
|
| 113 |
+
<body className="min-h-screen bg-[var(--background)] font-sans text-[var(--foreground)] antialiased">
|
| 114 |
+
{/*
|
| 115 |
+
Main application content
|
| 116 |
+
The flex layout ensures footer stays at bottom
|
| 117 |
+
*/}
|
| 118 |
+
<Providers>
|
| 119 |
+
<div className="flex min-h-screen flex-col">{children}</div>
|
| 120 |
+
</Providers>
|
| 121 |
+
</body>
|
| 122 |
+
</html>
|
| 123 |
+
);
|
| 124 |
+
}
|
frontend/src/app/loading.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Loading Component
|
| 3 |
+
*
|
| 4 |
+
* This component is displayed while page content is loading.
|
| 5 |
+
* Next.js automatically shows this during navigation.
|
| 6 |
+
*
|
| 7 |
+
* @see https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Loading Skeleton
|
| 12 |
+
*
|
| 13 |
+
* Displays a loading animation while the page content loads.
|
| 14 |
+
* Uses a pulsing skeleton pattern for visual feedback.
|
| 15 |
+
*
|
| 16 |
+
* @returns Loading state UI
|
| 17 |
+
*/
|
| 18 |
+
export default function Loading(): React.ReactElement {
|
| 19 |
+
return (
|
| 20 |
+
<div className="flex min-h-screen items-center justify-center">
|
| 21 |
+
<div className="flex flex-col items-center gap-4">
|
| 22 |
+
{/* Spinning loader */}
|
| 23 |
+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[var(--foreground-muted)] border-t-[var(--color-primary-500)]" />
|
| 24 |
+
|
| 25 |
+
{/* Loading text */}
|
| 26 |
+
<p className="text-sm text-[var(--foreground-muted)]">Loading...</p>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
);
|
| 30 |
+
}
|
frontend/src/app/page.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Home Page Component
|
| 3 |
+
*
|
| 4 |
+
* The main landing page for the pythermalcomfort RAG Chatbot.
|
| 5 |
+
* Displays the full chat interface for interacting with the
|
| 6 |
+
* AI assistant about thermal comfort standards.
|
| 7 |
+
*
|
| 8 |
+
* @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
'use client';
|
| 12 |
+
|
| 13 |
+
import { MessageSquare } from 'lucide-react';
|
| 14 |
+
import { ChatContainer, Sidebar } from '@/components/chat';
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Home Page
|
| 18 |
+
*
|
| 19 |
+
* Renders the complete chat interface for the pythermalcomfort
|
| 20 |
+
* RAG chatbot. The page uses a full-height layout with the
|
| 21 |
+
* ChatContainer taking up the entire viewport.
|
| 22 |
+
*
|
| 23 |
+
* @remarks
|
| 24 |
+
* ## Layout Structure
|
| 25 |
+
*
|
| 26 |
+
* The page uses a Gemini-style layout with:
|
| 27 |
+
* - App title fixed in the top-left corner (like "Gemini" branding)
|
| 28 |
+
* - Collapsible sidebar showing current provider/model
|
| 29 |
+
* - Chat container centered in the remaining space
|
| 30 |
+
* - Full viewport height
|
| 31 |
+
*
|
| 32 |
+
* ## Responsive Design
|
| 33 |
+
*
|
| 34 |
+
* - On mobile: Sidebar hidden, chat fills screen
|
| 35 |
+
* - On tablet/desktop: Sidebar visible, collapsible
|
| 36 |
+
*
|
| 37 |
+
* ## Chat Configuration
|
| 38 |
+
*
|
| 39 |
+
* The ChatContainer is configured with:
|
| 40 |
+
* - No internal header (title is at page level)
|
| 41 |
+
* - Empty initial messages (fresh conversation)
|
| 42 |
+
* - No persistence callback (can be added for localStorage support)
|
| 43 |
+
*
|
| 44 |
+
* @returns The home page with chat interface
|
| 45 |
+
*/
|
| 46 |
+
export default function HomePage(): React.ReactElement {
|
| 47 |
+
return (
|
| 48 |
+
<main
|
| 49 |
+
className={
|
| 50 |
+
/* Full viewport height layout */
|
| 51 |
+
'flex min-h-screen flex-col ' +
|
| 52 |
+
/* Background color from design tokens */
|
| 53 |
+
'bg-[var(--background-secondary)]'
|
| 54 |
+
}
|
| 55 |
+
>
|
| 56 |
+
{/*
|
| 57 |
+
Page Header - Gemini-style branding in top-left
|
| 58 |
+
|
| 59 |
+
Fixed position at top-left of the page, outside the chat area.
|
| 60 |
+
This mimics how Gemini shows its logo/name.
|
| 61 |
+
*/}
|
| 62 |
+
<header className="shrink-0 px-4 py-3">
|
| 63 |
+
<div className="flex items-center gap-2">
|
| 64 |
+
<MessageSquare
|
| 65 |
+
className="h-5 w-5 text-[var(--color-primary-500)]"
|
| 66 |
+
strokeWidth={2}
|
| 67 |
+
aria-hidden="true"
|
| 68 |
+
/>
|
| 69 |
+
<span className="text-base font-medium text-[var(--foreground)]">
|
| 70 |
+
pythermalcomfort Chat
|
| 71 |
+
</span>
|
| 72 |
+
</div>
|
| 73 |
+
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5 ml-7">
|
| 74 |
+
AI-powered answers from scientific sources and official documentation
|
| 75 |
+
</p>
|
| 76 |
+
</header>
|
| 77 |
+
|
| 78 |
+
{/*
|
| 79 |
+
Main Content Area - Sidebar + Chat
|
| 80 |
+
|
| 81 |
+
Horizontal layout with collapsible sidebar on left
|
| 82 |
+
and chat container filling the remaining space.
|
| 83 |
+
*/}
|
| 84 |
+
<div className="flex flex-1 overflow-hidden">
|
| 85 |
+
{/*
|
| 86 |
+
Left Sidebar - Provider/Model Info
|
| 87 |
+
|
| 88 |
+
Shows current LLM provider and model name.
|
| 89 |
+
Collapsible to icon-only mode.
|
| 90 |
+
Hidden on mobile for better space utilization.
|
| 91 |
+
*/}
|
| 92 |
+
<Sidebar className="hidden sm:flex" />
|
| 93 |
+
|
| 94 |
+
{/*
|
| 95 |
+
ChatContainer - Main chat interface
|
| 96 |
+
|
| 97 |
+
Fills the remaining space after sidebar.
|
| 98 |
+
No internal header - the title is shown at page level above.
|
| 99 |
+
*/}
|
| 100 |
+
<ChatContainer
|
| 101 |
+
showHeader={false}
|
| 102 |
+
className="flex-1 px-2 sm:px-4 md:px-6 lg:px-8 pb-0.5 sm:pb-1"
|
| 103 |
+
/>
|
| 104 |
+
</div>
|
| 105 |
+
</main>
|
| 106 |
+
);
|
| 107 |
+
}
|
frontend/src/app/providers.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client-side Providers Wrapper
|
| 3 |
+
*
|
| 4 |
+
* Wraps children with all client-side context providers.
|
| 5 |
+
* This component is used by the root layout to provide
|
| 6 |
+
* shared state to all pages.
|
| 7 |
+
*
|
| 8 |
+
* @module app/providers
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
'use client';
|
| 12 |
+
|
| 13 |
+
import type { ReactNode } from 'react';
|
| 14 |
+
import { ProviderProvider } from '@/contexts/provider-context';
|
| 15 |
+
|
| 16 |
+
interface ProvidersProps {
|
| 17 |
+
children: ReactNode;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Providers Component
|
| 22 |
+
*
|
| 23 |
+
* Wraps the application with all necessary context providers.
|
| 24 |
+
* Add additional providers here as needed.
|
| 25 |
+
*/
|
| 26 |
+
export function Providers({ children }: ProvidersProps): React.ReactElement {
|
| 27 |
+
return <ProviderProvider>{children}</ProviderProvider>;
|
| 28 |
+
}
|
frontend/src/components/chat/__tests__/__snapshots__/empty-state.test.tsx.snap
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
| 2 |
+
|
| 3 |
+
exports[`EmptyState > snapshots > should render correctly (snapshot) 1`] = `
|
| 4 |
+
<div
|
| 5 |
+
class="mx-auto w-full max-w-2xl px-4 py-8 animate-slide-up"
|
| 6 |
+
>
|
| 7 |
+
<div
|
| 8 |
+
class="bg-[var(--background-secondary)]/80 backdrop-blur-sm border border-[var(--border)] rounded-2xl shadow-[var(--shadow-lg)] p-8"
|
| 9 |
+
>
|
| 10 |
+
<div
|
| 11 |
+
class="mb-6 flex justify-center"
|
| 12 |
+
>
|
| 13 |
+
<div
|
| 14 |
+
class="h-24 w-24 flex items-center justify-center"
|
| 15 |
+
>
|
| 16 |
+
<svg
|
| 17 |
+
aria-hidden="true"
|
| 18 |
+
class="h-20 w-20 motion-safe:animate-[thermalFloat_4s_ease-in-out_infinite]"
|
| 19 |
+
fill="none"
|
| 20 |
+
role="img"
|
| 21 |
+
viewBox="0 0 100 100"
|
| 22 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 23 |
+
>
|
| 24 |
+
<defs>
|
| 25 |
+
<lineargradient
|
| 26 |
+
id="buildingGradient"
|
| 27 |
+
x1="0%"
|
| 28 |
+
x2="100%"
|
| 29 |
+
y1="0%"
|
| 30 |
+
y2="100%"
|
| 31 |
+
>
|
| 32 |
+
<stop
|
| 33 |
+
offset="0%"
|
| 34 |
+
stop-color="var(--color-primary-400)"
|
| 35 |
+
/>
|
| 36 |
+
<stop
|
| 37 |
+
offset="100%"
|
| 38 |
+
stop-color="var(--color-primary-600)"
|
| 39 |
+
/>
|
| 40 |
+
</lineargradient>
|
| 41 |
+
<radialgradient
|
| 42 |
+
cx="50%"
|
| 43 |
+
cy="50%"
|
| 44 |
+
fx="30%"
|
| 45 |
+
fy="30%"
|
| 46 |
+
id="comfortZoneGradient"
|
| 47 |
+
r="50%"
|
| 48 |
+
>
|
| 49 |
+
<stop
|
| 50 |
+
offset="0%"
|
| 51 |
+
stop-color="var(--color-primary-200)"
|
| 52 |
+
stop-opacity="0.8"
|
| 53 |
+
/>
|
| 54 |
+
<stop
|
| 55 |
+
offset="70%"
|
| 56 |
+
stop-color="var(--color-primary-300)"
|
| 57 |
+
stop-opacity="0.4"
|
| 58 |
+
/>
|
| 59 |
+
<stop
|
| 60 |
+
offset="100%"
|
| 61 |
+
stop-color="var(--color-primary-400)"
|
| 62 |
+
stop-opacity="0.1"
|
| 63 |
+
/>
|
| 64 |
+
</radialgradient>
|
| 65 |
+
<lineargradient
|
| 66 |
+
id="thermoGradient"
|
| 67 |
+
x1="0%"
|
| 68 |
+
x2="0%"
|
| 69 |
+
y1="100%"
|
| 70 |
+
y2="0%"
|
| 71 |
+
>
|
| 72 |
+
<stop
|
| 73 |
+
offset="0%"
|
| 74 |
+
stop-color="var(--color-primary-300)"
|
| 75 |
+
/>
|
| 76 |
+
<stop
|
| 77 |
+
offset="50%"
|
| 78 |
+
stop-color="var(--color-primary-500)"
|
| 79 |
+
/>
|
| 80 |
+
<stop
|
| 81 |
+
offset="100%"
|
| 82 |
+
stop-color="var(--color-primary-600)"
|
| 83 |
+
/>
|
| 84 |
+
</lineargradient>
|
| 85 |
+
<lineargradient
|
| 86 |
+
id="waveGradient"
|
| 87 |
+
x1="0%"
|
| 88 |
+
x2="100%"
|
| 89 |
+
y1="0%"
|
| 90 |
+
y2="0%"
|
| 91 |
+
>
|
| 92 |
+
<stop
|
| 93 |
+
offset="0%"
|
| 94 |
+
stop-color="var(--color-primary-300)"
|
| 95 |
+
stop-opacity="0.2"
|
| 96 |
+
/>
|
| 97 |
+
<stop
|
| 98 |
+
offset="50%"
|
| 99 |
+
stop-color="var(--color-primary-400)"
|
| 100 |
+
stop-opacity="0.6"
|
| 101 |
+
/>
|
| 102 |
+
<stop
|
| 103 |
+
offset="100%"
|
| 104 |
+
stop-color="var(--color-primary-300)"
|
| 105 |
+
stop-opacity="0.2"
|
| 106 |
+
/>
|
| 107 |
+
</lineargradient>
|
| 108 |
+
</defs>
|
| 109 |
+
<circle
|
| 110 |
+
class="motion-safe:animate-[thermalPulse_3s_ease-in-out_infinite]"
|
| 111 |
+
cx="50"
|
| 112 |
+
cy="50"
|
| 113 |
+
fill="url(#comfortZoneGradient)"
|
| 114 |
+
r="42"
|
| 115 |
+
/>
|
| 116 |
+
<g
|
| 117 |
+
transform="translate(25, 28)"
|
| 118 |
+
>
|
| 119 |
+
<path
|
| 120 |
+
d="M5 45 L5 18 L25 8 L45 18 L45 45 Z"
|
| 121 |
+
fill="url(#buildingGradient)"
|
| 122 |
+
stroke="var(--color-primary-700)"
|
| 123 |
+
stroke-linejoin="round"
|
| 124 |
+
stroke-width="1"
|
| 125 |
+
/>
|
| 126 |
+
<path
|
| 127 |
+
d="M5 18 L25 8 L45 18"
|
| 128 |
+
fill="none"
|
| 129 |
+
stroke="var(--color-primary-700)"
|
| 130 |
+
stroke-linecap="round"
|
| 131 |
+
stroke-linejoin="round"
|
| 132 |
+
stroke-width="1.5"
|
| 133 |
+
/>
|
| 134 |
+
<rect
|
| 135 |
+
fill="var(--color-primary-100)"
|
| 136 |
+
height="6"
|
| 137 |
+
opacity="0.9"
|
| 138 |
+
rx="1"
|
| 139 |
+
width="6"
|
| 140 |
+
x="10"
|
| 141 |
+
y="22"
|
| 142 |
+
/>
|
| 143 |
+
<rect
|
| 144 |
+
fill="var(--color-primary-100)"
|
| 145 |
+
height="6"
|
| 146 |
+
opacity="0.9"
|
| 147 |
+
rx="1"
|
| 148 |
+
width="6"
|
| 149 |
+
x="10"
|
| 150 |
+
y="32"
|
| 151 |
+
/>
|
| 152 |
+
<rect
|
| 153 |
+
fill="var(--color-primary-100)"
|
| 154 |
+
height="6"
|
| 155 |
+
opacity="0.9"
|
| 156 |
+
rx="1"
|
| 157 |
+
width="6"
|
| 158 |
+
x="34"
|
| 159 |
+
y="22"
|
| 160 |
+
/>
|
| 161 |
+
<rect
|
| 162 |
+
fill="var(--color-primary-100)"
|
| 163 |
+
height="6"
|
| 164 |
+
opacity="0.9"
|
| 165 |
+
rx="1"
|
| 166 |
+
width="6"
|
| 167 |
+
x="34"
|
| 168 |
+
y="32"
|
| 169 |
+
/>
|
| 170 |
+
<rect
|
| 171 |
+
fill="var(--color-primary-200)"
|
| 172 |
+
height="13"
|
| 173 |
+
rx="1"
|
| 174 |
+
width="10"
|
| 175 |
+
x="20"
|
| 176 |
+
y="32"
|
| 177 |
+
/>
|
| 178 |
+
<circle
|
| 179 |
+
cx="27"
|
| 180 |
+
cy="39"
|
| 181 |
+
fill="var(--color-primary-600)"
|
| 182 |
+
r="1"
|
| 183 |
+
/>
|
| 184 |
+
</g>
|
| 185 |
+
<g
|
| 186 |
+
transform="translate(72, 25)"
|
| 187 |
+
>
|
| 188 |
+
<path
|
| 189 |
+
d="M6 0 C2.7 0 0 2.7 0 6 L0 35 C-2 37 -3 40 -3 43 C-3 49 2 54 8 54 C14 54 19 49 19 43 C19 40 18 37 16 35 L16 6 C16 2.7 13.3 0 10 0 Z"
|
| 190 |
+
fill="var(--color-primary-100)"
|
| 191 |
+
stroke="var(--color-primary-400)"
|
| 192 |
+
stroke-width="1.5"
|
| 193 |
+
/>
|
| 194 |
+
<path
|
| 195 |
+
class="motion-safe:animate-[thermalPulse_3s_ease-in-out_infinite_0.5s]"
|
| 196 |
+
d="M6 38 L6 10 C6 8 7 7 8 7 C9 7 10 8 10 10 L10 38 C12 39 14 41 14 43 C14 47 11 50 8 50 C5 50 2 47 2 43 C2 41 4 39 6 38 Z"
|
| 197 |
+
fill="url(#thermoGradient)"
|
| 198 |
+
/>
|
| 199 |
+
<line
|
| 200 |
+
stroke="var(--color-primary-400)"
|
| 201 |
+
stroke-width="1"
|
| 202 |
+
x1="12"
|
| 203 |
+
x2="14"
|
| 204 |
+
y1="15"
|
| 205 |
+
y2="15"
|
| 206 |
+
/>
|
| 207 |
+
<line
|
| 208 |
+
stroke="var(--color-primary-400)"
|
| 209 |
+
stroke-width="1"
|
| 210 |
+
x1="12"
|
| 211 |
+
x2="14"
|
| 212 |
+
y1="22"
|
| 213 |
+
y2="22"
|
| 214 |
+
/>
|
| 215 |
+
<line
|
| 216 |
+
stroke="var(--color-primary-400)"
|
| 217 |
+
stroke-width="1"
|
| 218 |
+
x1="12"
|
| 219 |
+
x2="14"
|
| 220 |
+
y1="29"
|
| 221 |
+
y2="29"
|
| 222 |
+
/>
|
| 223 |
+
</g>
|
| 224 |
+
<g
|
| 225 |
+
class="motion-safe:animate-[thermalWave_2s_ease-in-out_infinite]"
|
| 226 |
+
>
|
| 227 |
+
<path
|
| 228 |
+
d="M12 30 Q22 26 32 30 Q42 34 52 30"
|
| 229 |
+
fill="none"
|
| 230 |
+
opacity="0.7"
|
| 231 |
+
stroke="url(#waveGradient)"
|
| 232 |
+
stroke-linecap="round"
|
| 233 |
+
stroke-width="2"
|
| 234 |
+
/>
|
| 235 |
+
</g>
|
| 236 |
+
<g
|
| 237 |
+
class="motion-safe:animate-[thermalWave_2s_ease-in-out_infinite_0.3s]"
|
| 238 |
+
>
|
| 239 |
+
<path
|
| 240 |
+
d="M8 42 Q18 38 28 42 Q38 46 48 42"
|
| 241 |
+
fill="none"
|
| 242 |
+
opacity="0.5"
|
| 243 |
+
stroke="url(#waveGradient)"
|
| 244 |
+
stroke-linecap="round"
|
| 245 |
+
stroke-width="2"
|
| 246 |
+
/>
|
| 247 |
+
</g>
|
| 248 |
+
<g
|
| 249 |
+
class="motion-safe:animate-[thermalWave_2s_ease-in-out_infinite_0.6s]"
|
| 250 |
+
>
|
| 251 |
+
<path
|
| 252 |
+
d="M15 55 Q25 51 35 55 Q45 59 55 55"
|
| 253 |
+
fill="none"
|
| 254 |
+
opacity="0.6"
|
| 255 |
+
stroke="url(#waveGradient)"
|
| 256 |
+
stroke-linecap="round"
|
| 257 |
+
stroke-width="2"
|
| 258 |
+
/>
|
| 259 |
+
</g>
|
| 260 |
+
<circle
|
| 261 |
+
class="motion-safe:animate-[thermalPulse_2s_ease-in-out_infinite_0.2s]"
|
| 262 |
+
cx="18"
|
| 263 |
+
cy="22"
|
| 264 |
+
fill="var(--color-primary-400)"
|
| 265 |
+
opacity="0.6"
|
| 266 |
+
r="2"
|
| 267 |
+
/>
|
| 268 |
+
<circle
|
| 269 |
+
class="motion-safe:animate-[thermalPulse_2s_ease-in-out_infinite_0.8s]"
|
| 270 |
+
cx="82"
|
| 271 |
+
cy="72"
|
| 272 |
+
fill="var(--color-primary-300)"
|
| 273 |
+
opacity="0.5"
|
| 274 |
+
r="2.5"
|
| 275 |
+
/>
|
| 276 |
+
<circle
|
| 277 |
+
class="motion-safe:animate-[thermalPulse_2s_ease-in-out_infinite_1.2s]"
|
| 278 |
+
cx="15"
|
| 279 |
+
cy="75"
|
| 280 |
+
fill="var(--color-primary-500)"
|
| 281 |
+
opacity="0.4"
|
| 282 |
+
r="1.5"
|
| 283 |
+
/>
|
| 284 |
+
</svg>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
<div
|
| 288 |
+
class="mb-8 text-center"
|
| 289 |
+
>
|
| 290 |
+
<h2
|
| 291 |
+
class="text-2xl font-bold text-[var(--foreground)] mb-2"
|
| 292 |
+
>
|
| 293 |
+
Ask about thermal comfort and pythermalcomfort
|
| 294 |
+
</h2>
|
| 295 |
+
<p
|
| 296 |
+
class="text-[var(--foreground-secondary)] text-sm leading-relaxed mx-auto max-w-md"
|
| 297 |
+
>
|
| 298 |
+
Get answers about thermal comfort models, concepts, standards and the pythermalcomfort library. Your questions are answered using scientific sources and official documentations.
|
| 299 |
+
</p>
|
| 300 |
+
</div>
|
| 301 |
+
<div
|
| 302 |
+
class="space-y-3"
|
| 303 |
+
>
|
| 304 |
+
<p
|
| 305 |
+
class="text-xs font-medium tracking-wide uppercase text-[var(--foreground-muted)] mb-3 px-1"
|
| 306 |
+
>
|
| 307 |
+
Try asking
|
| 308 |
+
</p>
|
| 309 |
+
<div
|
| 310 |
+
class="grid gap-3 sm:grid-cols-2"
|
| 311 |
+
>
|
| 312 |
+
<button
|
| 313 |
+
class="w-full text-left px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)] text-sm text-[var(--foreground)] flex items-start gap-3 hover:border-[var(--color-primary-300)] hover:bg-[var(--background-secondary)] hover:shadow-[var(--shadow-md)] transition-all duration-[var(--transition-fast)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 cursor-pointer"
|
| 314 |
+
type="button"
|
| 315 |
+
>
|
| 316 |
+
<span
|
| 317 |
+
aria-hidden="true"
|
| 318 |
+
class="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-primary-500)]"
|
| 319 |
+
data-testid="message-square-icon"
|
| 320 |
+
/>
|
| 321 |
+
<span>
|
| 322 |
+
What is the PMV model and how do I calculate it?
|
| 323 |
+
</span>
|
| 324 |
+
</button>
|
| 325 |
+
<button
|
| 326 |
+
class="w-full text-left px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)] text-sm text-[var(--foreground)] flex items-start gap-3 hover:border-[var(--color-primary-300)] hover:bg-[var(--background-secondary)] hover:shadow-[var(--shadow-md)] transition-all duration-[var(--transition-fast)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 cursor-pointer"
|
| 327 |
+
type="button"
|
| 328 |
+
>
|
| 329 |
+
<span
|
| 330 |
+
aria-hidden="true"
|
| 331 |
+
class="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-primary-500)]"
|
| 332 |
+
data-testid="message-square-icon"
|
| 333 |
+
/>
|
| 334 |
+
<span>
|
| 335 |
+
How do I use the adaptive comfort model in pythermalcomfort?
|
| 336 |
+
</span>
|
| 337 |
+
</button>
|
| 338 |
+
<button
|
| 339 |
+
class="w-full text-left px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)] text-sm text-[var(--foreground)] flex items-start gap-3 hover:border-[var(--color-primary-300)] hover:bg-[var(--background-secondary)] hover:shadow-[var(--shadow-md)] transition-all duration-[var(--transition-fast)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 cursor-pointer"
|
| 340 |
+
type="button"
|
| 341 |
+
>
|
| 342 |
+
<span
|
| 343 |
+
aria-hidden="true"
|
| 344 |
+
class="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-primary-500)]"
|
| 345 |
+
data-testid="message-square-icon"
|
| 346 |
+
/>
|
| 347 |
+
<span>
|
| 348 |
+
What are the inputs for calculating thermal comfort indices?
|
| 349 |
+
</span>
|
| 350 |
+
</button>
|
| 351 |
+
<button
|
| 352 |
+
class="w-full text-left px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)] text-sm text-[var(--foreground)] flex items-start gap-3 hover:border-[var(--color-primary-300)] hover:bg-[var(--background-secondary)] hover:shadow-[var(--shadow-md)] transition-all duration-[var(--transition-fast)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 cursor-pointer"
|
| 353 |
+
type="button"
|
| 354 |
+
>
|
| 355 |
+
<span
|
| 356 |
+
aria-hidden="true"
|
| 357 |
+
class="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-primary-500)]"
|
| 358 |
+
data-testid="message-square-icon"
|
| 359 |
+
/>
|
| 360 |
+
<span>
|
| 361 |
+
Explain the difference between SET and PMV thermal comfort models.
|
| 362 |
+
</span>
|
| 363 |
+
</button>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
`;
|
frontend/src/components/chat/__tests__/__snapshots__/error-state.test.tsx.snap
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
| 2 |
+
|
| 3 |
+
exports[`ErrorState > snapshots > should render general type correctly (snapshot) 1`] = `
|
| 4 |
+
<div
|
| 5 |
+
aria-live="assertive"
|
| 6 |
+
class="mx-auto w-full max-w-lg px-4 py-6 animate-slide-up"
|
| 7 |
+
role="alert"
|
| 8 |
+
>
|
| 9 |
+
<div
|
| 10 |
+
class="bg-[var(--background-secondary)]/80 backdrop-blur-sm border border-red-300 rounded-2xl shadow-[var(--shadow-lg)] p-6 relative"
|
| 11 |
+
>
|
| 12 |
+
<button
|
| 13 |
+
aria-label="Dismiss error"
|
| 14 |
+
class="absolute top-3 right-3 h-8 w-8 rounded-full flex items-center justify-center text-[var(--foreground-muted)] hover:bg-[var(--background-tertiary)] hover:text-[var(--foreground-secondary)] transition-colors duration-[var(--transition-fast)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2"
|
| 15 |
+
type="button"
|
| 16 |
+
>
|
| 17 |
+
<span
|
| 18 |
+
aria-hidden="true"
|
| 19 |
+
class="h-4 w-4"
|
| 20 |
+
data-testid="x-icon"
|
| 21 |
+
/>
|
| 22 |
+
</button>
|
| 23 |
+
<div
|
| 24 |
+
class="mb-4 flex justify-center"
|
| 25 |
+
>
|
| 26 |
+
<div
|
| 27 |
+
class="h-14 w-14 rounded-full bg-gradient-to-br from-red-100 to-red-200 flex items-center justify-center shadow-[var(--shadow-md)]"
|
| 28 |
+
>
|
| 29 |
+
<span
|
| 30 |
+
aria-hidden="true"
|
| 31 |
+
class="h-7 w-7 text-red-600"
|
| 32 |
+
data-testid="alert-triangle-icon"
|
| 33 |
+
/>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
<div
|
| 37 |
+
class="mb-4 text-center"
|
| 38 |
+
>
|
| 39 |
+
<h3
|
| 40 |
+
class="text-lg font-semibold text-[var(--foreground)] mb-2"
|
| 41 |
+
>
|
| 42 |
+
Oops! Something Went Wrong
|
| 43 |
+
</h3>
|
| 44 |
+
<p
|
| 45 |
+
class="text-[var(--foreground-secondary)] text-sm leading-relaxed mx-auto max-w-sm"
|
| 46 |
+
>
|
| 47 |
+
A custom error message for the snapshot test.
|
| 48 |
+
</p>
|
| 49 |
+
</div>
|
| 50 |
+
<div
|
| 51 |
+
class="flex justify-center"
|
| 52 |
+
>
|
| 53 |
+
<button
|
| 54 |
+
class="px-5 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium inline-flex items-center gap-2 bg-red-500 text-white hover:bg-red-600 disabled:bg-red-200 disabled:text-red-400 shadow-[var(--shadow-sm)] transition-all duration-[var(--transition-fast)] hover:shadow-[var(--shadow-md)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:shadow-none"
|
| 55 |
+
type="button"
|
| 56 |
+
>
|
| 57 |
+
<span
|
| 58 |
+
aria-hidden="true"
|
| 59 |
+
class="h-4 w-4"
|
| 60 |
+
data-testid="refresh-icon"
|
| 61 |
+
/>
|
| 62 |
+
<span>
|
| 63 |
+
Try Again
|
| 64 |
+
</span>
|
| 65 |
+
</button>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
`;
|
| 70 |
+
|
| 71 |
+
exports[`ErrorState > snapshots > should render network type correctly (snapshot) 1`] = `
|
| 72 |
+
<div
|
| 73 |
+
aria-live="assertive"
|
| 74 |
+
class="mx-auto w-full max-w-lg px-4 py-6 animate-slide-up"
|
| 75 |
+
role="alert"
|
| 76 |
+
>
|
| 77 |
+
<div
|
| 78 |
+
class="bg-[var(--background-secondary)]/80 backdrop-blur-sm border border-blue-300 rounded-2xl shadow-[var(--shadow-lg)] p-6 relative"
|
| 79 |
+
>
|
| 80 |
+
<button
|
| 81 |
+
aria-label="Dismiss error"
|
| 82 |
+
class="absolute top-3 right-3 h-8 w-8 rounded-full flex items-center justify-center text-[var(--foreground-muted)] hover:bg-[var(--background-tertiary)] hover:text-[var(--foreground-secondary)] transition-colors duration-[var(--transition-fast)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2"
|
| 83 |
+
type="button"
|
| 84 |
+
>
|
| 85 |
+
<span
|
| 86 |
+
aria-hidden="true"
|
| 87 |
+
class="h-4 w-4"
|
| 88 |
+
data-testid="x-icon"
|
| 89 |
+
/>
|
| 90 |
+
</button>
|
| 91 |
+
<div
|
| 92 |
+
class="mb-4 flex justify-center"
|
| 93 |
+
>
|
| 94 |
+
<div
|
| 95 |
+
class="h-14 w-14 rounded-full bg-gradient-to-br from-blue-100 to-blue-200 flex items-center justify-center shadow-[var(--shadow-md)]"
|
| 96 |
+
>
|
| 97 |
+
<span
|
| 98 |
+
aria-hidden="true"
|
| 99 |
+
class="h-7 w-7 text-blue-600"
|
| 100 |
+
data-testid="wifi-off-icon"
|
| 101 |
+
/>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
<div
|
| 105 |
+
class="mb-4 text-center"
|
| 106 |
+
>
|
| 107 |
+
<h3
|
| 108 |
+
class="text-lg font-semibold text-[var(--foreground)] mb-2"
|
| 109 |
+
>
|
| 110 |
+
Connection Lost
|
| 111 |
+
</h3>
|
| 112 |
+
<p
|
| 113 |
+
class="text-[var(--foreground-secondary)] text-sm leading-relaxed mx-auto max-w-sm"
|
| 114 |
+
>
|
| 115 |
+
Unable to connect. Please check your internet connection.
|
| 116 |
+
</p>
|
| 117 |
+
</div>
|
| 118 |
+
<div
|
| 119 |
+
class="flex justify-center"
|
| 120 |
+
>
|
| 121 |
+
<button
|
| 122 |
+
class="px-5 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium inline-flex items-center gap-2 bg-blue-500 text-white hover:bg-blue-600 disabled:bg-blue-200 disabled:text-blue-400 shadow-[var(--shadow-sm)] transition-all duration-[var(--transition-fast)] hover:shadow-[var(--shadow-md)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:shadow-none"
|
| 123 |
+
type="button"
|
| 124 |
+
>
|
| 125 |
+
<span
|
| 126 |
+
aria-hidden="true"
|
| 127 |
+
class="h-4 w-4"
|
| 128 |
+
data-testid="refresh-icon"
|
| 129 |
+
/>
|
| 130 |
+
<span>
|
| 131 |
+
Try Again
|
| 132 |
+
</span>
|
| 133 |
+
</button>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
`;
|
| 138 |
+
|
| 139 |
+
exports[`ErrorState > snapshots > should render quota type correctly (snapshot) 1`] = `
|
| 140 |
+
<div
|
| 141 |
+
aria-live="assertive"
|
| 142 |
+
class="mx-auto w-full max-w-lg px-4 py-6 animate-slide-up"
|
| 143 |
+
role="alert"
|
| 144 |
+
>
|
| 145 |
+
<div
|
| 146 |
+
class="bg-[var(--background-secondary)]/80 backdrop-blur-sm border border-[var(--color-primary-300)] rounded-2xl shadow-[var(--shadow-lg)] p-6 relative"
|
| 147 |
+
>
|
| 148 |
+
<button
|
| 149 |
+
aria-label="Dismiss error"
|
| 150 |
+
class="absolute top-3 right-3 h-8 w-8 rounded-full flex items-center justify-center text-[var(--foreground-muted)] hover:bg-[var(--background-tertiary)] hover:text-[var(--foreground-secondary)] transition-colors duration-[var(--transition-fast)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2"
|
| 151 |
+
type="button"
|
| 152 |
+
>
|
| 153 |
+
<span
|
| 154 |
+
aria-hidden="true"
|
| 155 |
+
class="h-4 w-4"
|
| 156 |
+
data-testid="x-icon"
|
| 157 |
+
/>
|
| 158 |
+
</button>
|
| 159 |
+
<div
|
| 160 |
+
class="mb-4 flex justify-center"
|
| 161 |
+
>
|
| 162 |
+
<div
|
| 163 |
+
class="h-14 w-14 rounded-full bg-gradient-to-br from-[var(--color-primary-100)] to-[var(--color-primary-200)] flex items-center justify-center shadow-[var(--shadow-md)]"
|
| 164 |
+
>
|
| 165 |
+
<span
|
| 166 |
+
aria-hidden="true"
|
| 167 |
+
class="h-7 w-7 text-[var(--color-primary-600)]"
|
| 168 |
+
data-testid="clock-icon"
|
| 169 |
+
/>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
<div
|
| 173 |
+
class="mb-4 text-center"
|
| 174 |
+
>
|
| 175 |
+
<h3
|
| 176 |
+
class="text-lg font-semibold text-[var(--foreground)] mb-2"
|
| 177 |
+
>
|
| 178 |
+
Service Temporarily Unavailable
|
| 179 |
+
</h3>
|
| 180 |
+
<p
|
| 181 |
+
class="text-[var(--foreground-secondary)] text-sm leading-relaxed mx-auto max-w-sm"
|
| 182 |
+
>
|
| 183 |
+
Our service is currently at capacity. Please wait a moment.
|
| 184 |
+
</p>
|
| 185 |
+
</div>
|
| 186 |
+
<div
|
| 187 |
+
aria-atomic="true"
|
| 188 |
+
aria-live="polite"
|
| 189 |
+
class="mb-4 mx-auto max-w-xs px-4 py-2 bg-[var(--color-primary-50)]/50 border border-[var(--color-primary-200)] rounded-[var(--radius-lg)] flex items-center justify-center gap-2 animate-fade-in"
|
| 190 |
+
>
|
| 191 |
+
<span
|
| 192 |
+
aria-hidden="true"
|
| 193 |
+
class="h-4 w-4 text-[var(--color-primary-500)]"
|
| 194 |
+
data-testid="clock-icon"
|
| 195 |
+
/>
|
| 196 |
+
<span
|
| 197 |
+
class="text-sm font-medium text-[var(--color-primary-700)]"
|
| 198 |
+
>
|
| 199 |
+
Try again in 45s
|
| 200 |
+
</span>
|
| 201 |
+
</div>
|
| 202 |
+
<div
|
| 203 |
+
class="flex justify-center"
|
| 204 |
+
>
|
| 205 |
+
<button
|
| 206 |
+
class="px-5 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium inline-flex items-center gap-2 bg-[var(--color-primary-500)] text-[var(--color-primary-button-text)] hover:bg-[var(--color-primary-600)] disabled:bg-[var(--color-primary-200)] disabled:text-[var(--foreground-muted)] shadow-[var(--shadow-sm)] transition-all duration-[var(--transition-fast)] hover:shadow-[var(--shadow-md)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:shadow-none"
|
| 207 |
+
disabled=""
|
| 208 |
+
type="button"
|
| 209 |
+
>
|
| 210 |
+
<span
|
| 211 |
+
aria-hidden="true"
|
| 212 |
+
class="h-4 w-4 animate-pulse-custom"
|
| 213 |
+
data-testid="refresh-icon"
|
| 214 |
+
/>
|
| 215 |
+
<span>
|
| 216 |
+
Please wait...
|
| 217 |
+
</span>
|
| 218 |
+
</button>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
`;
|
frontend/src/components/chat/__tests__/empty-state.test.tsx
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Unit Tests for EmptyState Component
|
| 3 |
+
*
|
| 4 |
+
* Comprehensive test coverage for the empty state welcome component.
|
| 5 |
+
* Tests rendering, example question interactions, accessibility features,
|
| 6 |
+
* and edge cases.
|
| 7 |
+
*
|
| 8 |
+
* @module components/chat/__tests__/empty-state.test
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
| 12 |
+
import { render, screen, fireEvent } from '@testing-library/react';
|
| 13 |
+
import { EmptyState } from '../empty-state';
|
| 14 |
+
|
| 15 |
+
// ============================================================================
|
| 16 |
+
// Mocks
|
| 17 |
+
// ============================================================================
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Mock lucide-react icons for faster tests and to avoid lazy loading issues.
|
| 21 |
+
*/
|
| 22 |
+
vi.mock('lucide-react', () => ({
|
| 23 |
+
MessageSquare: ({
|
| 24 |
+
className,
|
| 25 |
+
'aria-hidden': ariaHidden,
|
| 26 |
+
}: {
|
| 27 |
+
className?: string;
|
| 28 |
+
'aria-hidden'?: boolean | 'true' | 'false';
|
| 29 |
+
}) => (
|
| 30 |
+
<span
|
| 31 |
+
data-testid="message-square-icon"
|
| 32 |
+
className={className}
|
| 33 |
+
aria-hidden={ariaHidden}
|
| 34 |
+
/>
|
| 35 |
+
),
|
| 36 |
+
}));
|
| 37 |
+
|
| 38 |
+
// ============================================================================
|
| 39 |
+
// Test Fixtures
|
| 40 |
+
// ============================================================================
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* Example questions that should be displayed in the component.
|
| 44 |
+
*/
|
| 45 |
+
const EXPECTED_QUESTIONS = [
|
| 46 |
+
'What is the PMV model and how do I calculate it?',
|
| 47 |
+
'How do I use the adaptive comfort model in pythermalcomfort?',
|
| 48 |
+
'What are the inputs for calculating thermal comfort indices?',
|
| 49 |
+
'Explain the difference between SET and PMV thermal comfort models.',
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
// ============================================================================
|
| 53 |
+
// Test Suite
|
| 54 |
+
// ============================================================================
|
| 55 |
+
|
| 56 |
+
describe('EmptyState', () => {
|
| 57 |
+
beforeEach(() => {
|
| 58 |
+
vi.resetAllMocks();
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
// ==========================================================================
|
| 62 |
+
// Rendering Tests
|
| 63 |
+
// ==========================================================================
|
| 64 |
+
|
| 65 |
+
describe('rendering', () => {
|
| 66 |
+
it('should render the main title', () => {
|
| 67 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 68 |
+
|
| 69 |
+
expect(
|
| 70 |
+
screen.getByText('Ask about thermal comfort and pythermalcomfort')
|
| 71 |
+
).toBeInTheDocument();
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
it('should render the subtitle description', () => {
|
| 75 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 76 |
+
|
| 77 |
+
expect(
|
| 78 |
+
screen.getByText(/get answers about thermal comfort standards/i)
|
| 79 |
+
).toBeInTheDocument();
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
it('should render "Try asking" label', () => {
|
| 83 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 84 |
+
|
| 85 |
+
expect(screen.getByText('Try asking')).toBeInTheDocument();
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
it('should render all example questions', () => {
|
| 89 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 90 |
+
|
| 91 |
+
EXPECTED_QUESTIONS.forEach((question) => {
|
| 92 |
+
expect(screen.getByText(question)).toBeInTheDocument();
|
| 93 |
+
});
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
it('should render MessageSquare icon for each question', () => {
|
| 97 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 98 |
+
|
| 99 |
+
const icons = screen.getAllByTestId('message-square-icon');
|
| 100 |
+
expect(icons).toHaveLength(EXPECTED_QUESTIONS.length);
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
it('should render thermal comfort illustration (SVG)', () => {
|
| 104 |
+
const { container } = render(<EmptyState onExampleClick={vi.fn()} />);
|
| 105 |
+
|
| 106 |
+
const svg = container.querySelector('svg');
|
| 107 |
+
expect(svg).toBeInTheDocument();
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it('should hide illustration from screen readers', () => {
|
| 111 |
+
const { container } = render(<EmptyState onExampleClick={vi.fn()} />);
|
| 112 |
+
|
| 113 |
+
const svg = container.querySelector('svg');
|
| 114 |
+
expect(svg).toHaveAttribute('aria-hidden', 'true');
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
it('should render example questions as buttons', () => {
|
| 118 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 119 |
+
|
| 120 |
+
const buttons = screen.getAllByRole('button');
|
| 121 |
+
expect(buttons).toHaveLength(EXPECTED_QUESTIONS.length);
|
| 122 |
+
});
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
// ==========================================================================
|
| 126 |
+
// Interaction Tests
|
| 127 |
+
// ==========================================================================
|
| 128 |
+
|
| 129 |
+
describe('interactions', () => {
|
| 130 |
+
it('should call onExampleClick when first question is clicked', () => {
|
| 131 |
+
const mockClick = vi.fn();
|
| 132 |
+
|
| 133 |
+
render(<EmptyState onExampleClick={mockClick} />);
|
| 134 |
+
|
| 135 |
+
fireEvent.click(screen.getByText(EXPECTED_QUESTIONS[0]));
|
| 136 |
+
|
| 137 |
+
expect(mockClick).toHaveBeenCalledTimes(1);
|
| 138 |
+
expect(mockClick).toHaveBeenCalledWith(EXPECTED_QUESTIONS[0]);
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
it('should call onExampleClick with correct question for each button', () => {
|
| 142 |
+
const mockClick = vi.fn();
|
| 143 |
+
|
| 144 |
+
render(<EmptyState onExampleClick={mockClick} />);
|
| 145 |
+
|
| 146 |
+
EXPECTED_QUESTIONS.forEach((question, index) => {
|
| 147 |
+
fireEvent.click(screen.getByText(question));
|
| 148 |
+
expect(mockClick).toHaveBeenNthCalledWith(index + 1, question);
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
expect(mockClick).toHaveBeenCalledTimes(EXPECTED_QUESTIONS.length);
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
it('should call onExampleClick multiple times when same question clicked repeatedly', () => {
|
| 155 |
+
const mockClick = vi.fn();
|
| 156 |
+
|
| 157 |
+
render(<EmptyState onExampleClick={mockClick} />);
|
| 158 |
+
|
| 159 |
+
const firstQuestion = screen.getByText(EXPECTED_QUESTIONS[0]);
|
| 160 |
+
|
| 161 |
+
fireEvent.click(firstQuestion);
|
| 162 |
+
fireEvent.click(firstQuestion);
|
| 163 |
+
fireEvent.click(firstQuestion);
|
| 164 |
+
|
| 165 |
+
expect(mockClick).toHaveBeenCalledTimes(3);
|
| 166 |
+
});
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
// ==========================================================================
|
| 170 |
+
// Accessibility Tests
|
| 171 |
+
// ==========================================================================
|
| 172 |
+
|
| 173 |
+
describe('accessibility', () => {
|
| 174 |
+
it('should have semantic heading hierarchy with h2', () => {
|
| 175 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 176 |
+
|
| 177 |
+
const heading = screen.getByRole('heading', { level: 2 });
|
| 178 |
+
expect(heading).toBeInTheDocument();
|
| 179 |
+
expect(heading).toHaveTextContent('Ask about thermal comfort and pythermalcomfort');
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
it('should have focusable question buttons', () => {
|
| 183 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 184 |
+
|
| 185 |
+
const buttons = screen.getAllByRole('button');
|
| 186 |
+
buttons.forEach((button) => {
|
| 187 |
+
button.focus();
|
| 188 |
+
expect(button).toHaveFocus();
|
| 189 |
+
});
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
it('should have visible focus styles on buttons', () => {
|
| 193 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 194 |
+
|
| 195 |
+
const buttons = screen.getAllByRole('button');
|
| 196 |
+
buttons.forEach((button) => {
|
| 197 |
+
expect(button.className).toMatch(/focus-visible:ring/);
|
| 198 |
+
});
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
it('should have icons with aria-hidden for decorative elements', () => {
|
| 202 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 203 |
+
|
| 204 |
+
const icons = screen.getAllByTestId('message-square-icon');
|
| 205 |
+
icons.forEach((icon) => {
|
| 206 |
+
expect(icon).toHaveAttribute('aria-hidden', 'true');
|
| 207 |
+
});
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
it('should support keyboard Enter to activate question buttons', () => {
|
| 211 |
+
const mockClick = vi.fn();
|
| 212 |
+
|
| 213 |
+
render(<EmptyState onExampleClick={mockClick} />);
|
| 214 |
+
|
| 215 |
+
const firstButton = screen.getAllByRole('button')[0];
|
| 216 |
+
firstButton.focus();
|
| 217 |
+
|
| 218 |
+
// Simulate Enter key (buttons natively handle this)
|
| 219 |
+
fireEvent.keyDown(firstButton, { key: 'Enter', code: 'Enter' });
|
| 220 |
+
fireEvent.click(firstButton);
|
| 221 |
+
|
| 222 |
+
expect(mockClick).toHaveBeenCalled();
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
it('should support keyboard Space to activate question buttons', () => {
|
| 226 |
+
const mockClick = vi.fn();
|
| 227 |
+
|
| 228 |
+
render(<EmptyState onExampleClick={mockClick} />);
|
| 229 |
+
|
| 230 |
+
const firstButton = screen.getAllByRole('button')[0];
|
| 231 |
+
firstButton.focus();
|
| 232 |
+
|
| 233 |
+
// Simulate Space key (buttons natively handle this)
|
| 234 |
+
fireEvent.keyDown(firstButton, { key: ' ', code: 'Space' });
|
| 235 |
+
fireEvent.click(firstButton);
|
| 236 |
+
|
| 237 |
+
expect(mockClick).toHaveBeenCalled();
|
| 238 |
+
});
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
// ==========================================================================
|
| 242 |
+
// Styling Tests
|
| 243 |
+
// ==========================================================================
|
| 244 |
+
|
| 245 |
+
describe('styling', () => {
|
| 246 |
+
it('should apply custom className to container', () => {
|
| 247 |
+
const { container } = render(
|
| 248 |
+
<EmptyState onExampleClick={vi.fn()} className="custom-class mt-8" />
|
| 249 |
+
);
|
| 250 |
+
|
| 251 |
+
const outerDiv = container.firstChild;
|
| 252 |
+
expect(outerDiv).toHaveClass('custom-class', 'mt-8');
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
it('should have animation class for entrance effect', () => {
|
| 256 |
+
const { container } = render(<EmptyState onExampleClick={vi.fn()} />);
|
| 257 |
+
|
| 258 |
+
const outerDiv = container.firstChild;
|
| 259 |
+
expect(outerDiv).toHaveClass('animate-slide-up');
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
it('should have glassmorphism styling on card', () => {
|
| 263 |
+
const { container } = render(<EmptyState onExampleClick={vi.fn()} />);
|
| 264 |
+
|
| 265 |
+
// Find the card container (child of outer div)
|
| 266 |
+
const card = container.querySelector('.backdrop-blur-sm');
|
| 267 |
+
expect(card).toBeInTheDocument();
|
| 268 |
+
});
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
// ==========================================================================
|
| 272 |
+
// Edge Cases Tests
|
| 273 |
+
// ==========================================================================
|
| 274 |
+
|
| 275 |
+
describe('edge cases', () => {
|
| 276 |
+
it('should render without crashing when onExampleClick is a no-op', () => {
|
| 277 |
+
render(<EmptyState onExampleClick={() => {}} />);
|
| 278 |
+
|
| 279 |
+
expect(
|
| 280 |
+
screen.getByText('Ask about thermal comfort and pythermalcomfort')
|
| 281 |
+
).toBeInTheDocument();
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
it('should forward additional HTML attributes', () => {
|
| 285 |
+
render(
|
| 286 |
+
<EmptyState
|
| 287 |
+
onExampleClick={vi.fn()}
|
| 288 |
+
data-testid="empty-state"
|
| 289 |
+
title="Welcome state"
|
| 290 |
+
/>
|
| 291 |
+
);
|
| 292 |
+
|
| 293 |
+
const container = screen.getByTestId('empty-state');
|
| 294 |
+
expect(container).toHaveAttribute('title', 'Welcome state');
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
it('should render questions in a grid layout', () => {
|
| 298 |
+
const { container } = render(<EmptyState onExampleClick={vi.fn()} />);
|
| 299 |
+
|
| 300 |
+
const grid = container.querySelector('.grid');
|
| 301 |
+
expect(grid).toBeInTheDocument();
|
| 302 |
+
expect(grid).toHaveClass('sm:grid-cols-2');
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
+
it('should maintain button type as "button"', () => {
|
| 306 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 307 |
+
|
| 308 |
+
const buttons = screen.getAllByRole('button');
|
| 309 |
+
buttons.forEach((button) => {
|
| 310 |
+
expect(button).toHaveAttribute('type', 'button');
|
| 311 |
+
});
|
| 312 |
+
});
|
| 313 |
+
});
|
| 314 |
+
|
| 315 |
+
// ==========================================================================
|
| 316 |
+
// Snapshot Tests
|
| 317 |
+
// ==========================================================================
|
| 318 |
+
|
| 319 |
+
describe('snapshots', () => {
|
| 320 |
+
it('should render correctly (snapshot)', () => {
|
| 321 |
+
const { container } = render(<EmptyState onExampleClick={vi.fn()} />);
|
| 322 |
+
|
| 323 |
+
expect(container.firstChild).toMatchSnapshot();
|
| 324 |
+
});
|
| 325 |
+
});
|
| 326 |
+
|
| 327 |
+
// ==========================================================================
|
| 328 |
+
// Integration Tests
|
| 329 |
+
// ==========================================================================
|
| 330 |
+
|
| 331 |
+
describe('integration', () => {
|
| 332 |
+
it('should render complete empty state with all elements', () => {
|
| 333 |
+
render(<EmptyState onExampleClick={vi.fn()} />);
|
| 334 |
+
|
| 335 |
+
// Title and subtitle
|
| 336 |
+
expect(
|
| 337 |
+
screen.getByText('Ask about thermal comfort and pythermalcomfort')
|
| 338 |
+
).toBeInTheDocument();
|
| 339 |
+
expect(
|
| 340 |
+
screen.getByText(/get answers about thermal comfort/i)
|
| 341 |
+
).toBeInTheDocument();
|
| 342 |
+
|
| 343 |
+
// "Try asking" label
|
| 344 |
+
expect(screen.getByText('Try asking')).toBeInTheDocument();
|
| 345 |
+
|
| 346 |
+
// All questions as buttons
|
| 347 |
+
const buttons = screen.getAllByRole('button');
|
| 348 |
+
expect(buttons).toHaveLength(4);
|
| 349 |
+
|
| 350 |
+
// All icons
|
| 351 |
+
const icons = screen.getAllByTestId('message-square-icon');
|
| 352 |
+
expect(icons).toHaveLength(4);
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
it('should work correctly through a complete user interaction flow', () => {
|
| 356 |
+
const mockClick = vi.fn();
|
| 357 |
+
|
| 358 |
+
render(<EmptyState onExampleClick={mockClick} />);
|
| 359 |
+
|
| 360 |
+
// Verify initial state
|
| 361 |
+
expect(mockClick).not.toHaveBeenCalled();
|
| 362 |
+
|
| 363 |
+
// Click first question
|
| 364 |
+
fireEvent.click(screen.getByText(EXPECTED_QUESTIONS[0]));
|
| 365 |
+
expect(mockClick).toHaveBeenLastCalledWith(EXPECTED_QUESTIONS[0]);
|
| 366 |
+
|
| 367 |
+
// Click last question
|
| 368 |
+
fireEvent.click(screen.getByText(EXPECTED_QUESTIONS[3]));
|
| 369 |
+
expect(mockClick).toHaveBeenLastCalledWith(EXPECTED_QUESTIONS[3]);
|
| 370 |
+
|
| 371 |
+
// Total clicks
|
| 372 |
+
expect(mockClick).toHaveBeenCalledTimes(2);
|
| 373 |
+
});
|
| 374 |
+
});
|
| 375 |
+
});
|
frontend/src/components/chat/__tests__/error-state.test.tsx
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Unit Tests for ErrorState Component
|
| 3 |
+
*
|
| 4 |
+
* Comprehensive test coverage for the error display component.
|
| 5 |
+
* Tests rendering states, countdown timer functionality, interactions,
|
| 6 |
+
* accessibility features, and visual/snapshot tests.
|
| 7 |
+
*
|
| 8 |
+
* @module components/chat/__tests__/error-state.test
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
| 12 |
+
import { render, screen, fireEvent, act } from '@testing-library/react';
|
| 13 |
+
import { ErrorState, type ErrorType } from '../error-state';
|
| 14 |
+
|
| 15 |
+
// ============================================================================
|
| 16 |
+
// Mocks
|
| 17 |
+
// ============================================================================
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Mock lucide-react icons for faster tests and to avoid lazy loading issues.
|
| 21 |
+
* Each icon is replaced with a simple span containing a test ID and class.
|
| 22 |
+
*/
|
| 23 |
+
vi.mock('lucide-react', () => ({
|
| 24 |
+
AlertTriangle: ({
|
| 25 |
+
className,
|
| 26 |
+
'aria-hidden': ariaHidden,
|
| 27 |
+
}: {
|
| 28 |
+
className?: string;
|
| 29 |
+
'aria-hidden'?: boolean | 'true' | 'false';
|
| 30 |
+
}) => (
|
| 31 |
+
<span
|
| 32 |
+
data-testid="alert-triangle-icon"
|
| 33 |
+
className={className}
|
| 34 |
+
aria-hidden={ariaHidden}
|
| 35 |
+
/>
|
| 36 |
+
),
|
| 37 |
+
WifiOff: ({
|
| 38 |
+
className,
|
| 39 |
+
'aria-hidden': ariaHidden,
|
| 40 |
+
}: {
|
| 41 |
+
className?: string;
|
| 42 |
+
'aria-hidden'?: boolean | 'true' | 'false';
|
| 43 |
+
}) => (
|
| 44 |
+
<span
|
| 45 |
+
data-testid="wifi-off-icon"
|
| 46 |
+
className={className}
|
| 47 |
+
aria-hidden={ariaHidden}
|
| 48 |
+
/>
|
| 49 |
+
),
|
| 50 |
+
Clock: ({
|
| 51 |
+
className,
|
| 52 |
+
'aria-hidden': ariaHidden,
|
| 53 |
+
}: {
|
| 54 |
+
className?: string;
|
| 55 |
+
'aria-hidden'?: boolean | 'true' | 'false';
|
| 56 |
+
}) => (
|
| 57 |
+
<span
|
| 58 |
+
data-testid="clock-icon"
|
| 59 |
+
className={className}
|
| 60 |
+
aria-hidden={ariaHidden}
|
| 61 |
+
/>
|
| 62 |
+
),
|
| 63 |
+
RefreshCw: ({
|
| 64 |
+
className,
|
| 65 |
+
'aria-hidden': ariaHidden,
|
| 66 |
+
}: {
|
| 67 |
+
className?: string;
|
| 68 |
+
'aria-hidden'?: boolean | 'true' | 'false';
|
| 69 |
+
}) => (
|
| 70 |
+
<span
|
| 71 |
+
data-testid="refresh-icon"
|
| 72 |
+
className={className}
|
| 73 |
+
aria-hidden={ariaHidden}
|
| 74 |
+
/>
|
| 75 |
+
),
|
| 76 |
+
X: ({
|
| 77 |
+
className,
|
| 78 |
+
'aria-hidden': ariaHidden,
|
| 79 |
+
}: {
|
| 80 |
+
className?: string;
|
| 81 |
+
'aria-hidden'?: boolean | 'true' | 'false';
|
| 82 |
+
}) => (
|
| 83 |
+
<span
|
| 84 |
+
data-testid="x-icon"
|
| 85 |
+
className={className}
|
| 86 |
+
aria-hidden={ariaHidden}
|
| 87 |
+
/>
|
| 88 |
+
),
|
| 89 |
+
}));
|
| 90 |
+
|
| 91 |
+
// ============================================================================
|
| 92 |
+
// Test Helpers
|
| 93 |
+
// ============================================================================
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* Default error messages for each error type.
|
| 97 |
+
*/
|
| 98 |
+
const DEFAULT_MESSAGES: Record<ErrorType, string> = {
|
| 99 |
+
quota: 'Our service is currently at capacity. Please wait a moment.',
|
| 100 |
+
network: 'Unable to connect. Please check your internet connection.',
|
| 101 |
+
general: 'Something went wrong. Please try again.',
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
/**
|
| 105 |
+
* Titles displayed for each error type.
|
| 106 |
+
*/
|
| 107 |
+
const ERROR_TITLES: Record<ErrorType, string> = {
|
| 108 |
+
quota: 'Service Temporarily Unavailable',
|
| 109 |
+
network: 'Connection Lost',
|
| 110 |
+
general: 'Oops! Something Went Wrong',
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
// ============================================================================
|
| 114 |
+
// Test Suite
|
| 115 |
+
// ============================================================================
|
| 116 |
+
|
| 117 |
+
describe('ErrorState', () => {
|
| 118 |
+
beforeEach(() => {
|
| 119 |
+
vi.resetAllMocks();
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
afterEach(() => {
|
| 123 |
+
vi.clearAllTimers();
|
| 124 |
+
vi.useRealTimers();
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
// ==========================================================================
|
| 128 |
+
// Rendering Tests
|
| 129 |
+
// ==========================================================================
|
| 130 |
+
|
| 131 |
+
describe('rendering', () => {
|
| 132 |
+
it('should render quota error type correctly with Clock icon', () => {
|
| 133 |
+
render(<ErrorState type="quota" />);
|
| 134 |
+
|
| 135 |
+
// Should show quota-specific title
|
| 136 |
+
expect(
|
| 137 |
+
screen.getByText('Service Temporarily Unavailable')
|
| 138 |
+
).toBeInTheDocument();
|
| 139 |
+
|
| 140 |
+
// Should show default quota message
|
| 141 |
+
expect(
|
| 142 |
+
screen.getByText(DEFAULT_MESSAGES.quota)
|
| 143 |
+
).toBeInTheDocument();
|
| 144 |
+
|
| 145 |
+
// Clock icon should be used for quota type (main icon)
|
| 146 |
+
const clockIcons = screen.getAllByTestId('clock-icon');
|
| 147 |
+
expect(clockIcons.length).toBeGreaterThan(0);
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
it('should render network error type correctly with WifiOff icon', () => {
|
| 151 |
+
render(<ErrorState type="network" />);
|
| 152 |
+
|
| 153 |
+
// Should show network-specific title
|
| 154 |
+
expect(screen.getByText('Connection Lost')).toBeInTheDocument();
|
| 155 |
+
|
| 156 |
+
// Should show default network message
|
| 157 |
+
expect(
|
| 158 |
+
screen.getByText(DEFAULT_MESSAGES.network)
|
| 159 |
+
).toBeInTheDocument();
|
| 160 |
+
|
| 161 |
+
// WifiOff icon should be present
|
| 162 |
+
expect(screen.getByTestId('wifi-off-icon')).toBeInTheDocument();
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
it('should render general error type correctly with AlertTriangle icon', () => {
|
| 166 |
+
render(<ErrorState type="general" />);
|
| 167 |
+
|
| 168 |
+
// Should show general-specific title
|
| 169 |
+
expect(
|
| 170 |
+
screen.getByText('Oops! Something Went Wrong')
|
| 171 |
+
).toBeInTheDocument();
|
| 172 |
+
|
| 173 |
+
// Should show default general message
|
| 174 |
+
expect(
|
| 175 |
+
screen.getByText(DEFAULT_MESSAGES.general)
|
| 176 |
+
).toBeInTheDocument();
|
| 177 |
+
|
| 178 |
+
// AlertTriangle icon should be present
|
| 179 |
+
expect(screen.getByTestId('alert-triangle-icon')).toBeInTheDocument();
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
it('should show default message when no custom message provided', () => {
|
| 183 |
+
render(<ErrorState type="quota" />);
|
| 184 |
+
|
| 185 |
+
expect(
|
| 186 |
+
screen.getByText(DEFAULT_MESSAGES.quota)
|
| 187 |
+
).toBeInTheDocument();
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
it('should show custom message when provided', () => {
|
| 191 |
+
const customMessage = 'A custom error message for testing purposes.';
|
| 192 |
+
|
| 193 |
+
render(<ErrorState type="quota" message={customMessage} />);
|
| 194 |
+
|
| 195 |
+
expect(screen.getByText(customMessage)).toBeInTheDocument();
|
| 196 |
+
// Default message should not be present
|
| 197 |
+
expect(
|
| 198 |
+
screen.queryByText(DEFAULT_MESSAGES.quota)
|
| 199 |
+
).not.toBeInTheDocument();
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
it('should show dismiss button when onDismiss callback provided', () => {
|
| 203 |
+
const mockDismiss = vi.fn();
|
| 204 |
+
|
| 205 |
+
render(<ErrorState type="general" onDismiss={mockDismiss} />);
|
| 206 |
+
|
| 207 |
+
const dismissButton = screen.getByLabelText('Dismiss error');
|
| 208 |
+
expect(dismissButton).toBeInTheDocument();
|
| 209 |
+
expect(screen.getByTestId('x-icon')).toBeInTheDocument();
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
it('should hide dismiss button when onDismiss not provided', () => {
|
| 213 |
+
render(<ErrorState type="general" />);
|
| 214 |
+
|
| 215 |
+
expect(screen.queryByLabelText('Dismiss error')).not.toBeInTheDocument();
|
| 216 |
+
expect(screen.queryByTestId('x-icon')).not.toBeInTheDocument();
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
it('should show retry button when onRetry callback provided', () => {
|
| 220 |
+
const mockRetry = vi.fn();
|
| 221 |
+
|
| 222 |
+
render(<ErrorState type="general" onRetry={mockRetry} />);
|
| 223 |
+
|
| 224 |
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
| 225 |
+
expect(retryButton).toBeInTheDocument();
|
| 226 |
+
expect(screen.getByTestId('refresh-icon')).toBeInTheDocument();
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
it('should hide retry button when onRetry not provided', () => {
|
| 230 |
+
render(<ErrorState type="general" />);
|
| 231 |
+
|
| 232 |
+
expect(
|
| 233 |
+
screen.queryByRole('button', { name: /try again/i })
|
| 234 |
+
).not.toBeInTheDocument();
|
| 235 |
+
expect(screen.queryByTestId('refresh-icon')).not.toBeInTheDocument();
|
| 236 |
+
});
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
// ==========================================================================
|
| 240 |
+
// Countdown Timer Tests
|
| 241 |
+
// ==========================================================================
|
| 242 |
+
|
| 243 |
+
describe('countdown timer', () => {
|
| 244 |
+
beforeEach(() => {
|
| 245 |
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
afterEach(() => {
|
| 249 |
+
vi.useRealTimers();
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
it('should display countdown timer for quota errors with retryAfter', () => {
|
| 253 |
+
render(<ErrorState type="quota" retryAfter={60} onRetry={vi.fn()} />);
|
| 254 |
+
|
| 255 |
+
// Should show the countdown timer text
|
| 256 |
+
expect(screen.getByText(/try again in/i)).toBeInTheDocument();
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
it('should format countdown correctly for seconds (e.g., "45s")', () => {
|
| 260 |
+
render(<ErrorState type="quota" retryAfter={45} onRetry={vi.fn()} />);
|
| 261 |
+
|
| 262 |
+
expect(screen.getByText(/45s/)).toBeInTheDocument();
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
it('should format countdown correctly for minutes (e.g., "1:30")', () => {
|
| 266 |
+
render(<ErrorState type="quota" retryAfter={90} onRetry={vi.fn()} />);
|
| 267 |
+
|
| 268 |
+
expect(screen.getByText(/1:30/)).toBeInTheDocument();
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
it('should format countdown correctly for exact minutes (e.g., "2:00")', () => {
|
| 272 |
+
render(<ErrorState type="quota" retryAfter={120} onRetry={vi.fn()} />);
|
| 273 |
+
|
| 274 |
+
expect(screen.getByText(/2:00/)).toBeInTheDocument();
|
| 275 |
+
});
|
| 276 |
+
|
| 277 |
+
it('should decrement countdown every second', async () => {
|
| 278 |
+
render(<ErrorState type="quota" retryAfter={5} onRetry={vi.fn()} />);
|
| 279 |
+
|
| 280 |
+
// Initially shows 5s
|
| 281 |
+
expect(screen.getByText(/5s/)).toBeInTheDocument();
|
| 282 |
+
|
| 283 |
+
// Advance by 1 second
|
| 284 |
+
await act(async () => {
|
| 285 |
+
vi.advanceTimersByTime(1000);
|
| 286 |
+
});
|
| 287 |
+
expect(screen.getByText(/4s/)).toBeInTheDocument();
|
| 288 |
+
|
| 289 |
+
// Advance by another second
|
| 290 |
+
await act(async () => {
|
| 291 |
+
vi.advanceTimersByTime(1000);
|
| 292 |
+
});
|
| 293 |
+
expect(screen.getByText(/3s/)).toBeInTheDocument();
|
| 294 |
+
|
| 295 |
+
// Advance by another second
|
| 296 |
+
await act(async () => {
|
| 297 |
+
vi.advanceTimersByTime(1000);
|
| 298 |
+
});
|
| 299 |
+
expect(screen.getByText(/2s/)).toBeInTheDocument();
|
| 300 |
+
});
|
| 301 |
+
|
| 302 |
+
it('should disable retry button during countdown', () => {
|
| 303 |
+
const mockRetry = vi.fn();
|
| 304 |
+
|
| 305 |
+
render(<ErrorState type="quota" retryAfter={30} onRetry={mockRetry} />);
|
| 306 |
+
|
| 307 |
+
const retryButton = screen.getByRole('button', { name: /please wait/i });
|
| 308 |
+
expect(retryButton).toBeDisabled();
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
it('should enable retry button when countdown reaches zero', async () => {
|
| 312 |
+
const mockRetry = vi.fn();
|
| 313 |
+
|
| 314 |
+
render(<ErrorState type="quota" retryAfter={2} onRetry={mockRetry} />);
|
| 315 |
+
|
| 316 |
+
// Initially disabled
|
| 317 |
+
expect(
|
| 318 |
+
screen.getByRole('button', { name: /please wait/i })
|
| 319 |
+
).toBeDisabled();
|
| 320 |
+
|
| 321 |
+
// Advance past countdown
|
| 322 |
+
await act(async () => {
|
| 323 |
+
vi.advanceTimersByTime(3000);
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
// Should now be enabled with "Try Again" text
|
| 327 |
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
| 328 |
+
expect(retryButton).not.toBeDisabled();
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
it('should show "Ready to retry" text when countdown reaches zero', async () => {
|
| 332 |
+
const mockRetry = vi.fn();
|
| 333 |
+
|
| 334 |
+
render(<ErrorState type="quota" retryAfter={2} onRetry={mockRetry} />);
|
| 335 |
+
|
| 336 |
+
// Advance past countdown
|
| 337 |
+
await act(async () => {
|
| 338 |
+
vi.advanceTimersByTime(3000);
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
expect(screen.getByText(/ready to retry/i)).toBeInTheDocument();
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
it('should not show countdown for network errors even with retryAfter', () => {
|
| 345 |
+
render(<ErrorState type="network" retryAfter={30} onRetry={vi.fn()} />);
|
| 346 |
+
|
| 347 |
+
// Network errors should not show countdown timer area
|
| 348 |
+
expect(screen.queryByText(/try again in/i)).not.toBeInTheDocument();
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
it('should not show countdown for general errors even with retryAfter', () => {
|
| 352 |
+
render(<ErrorState type="general" retryAfter={30} onRetry={vi.fn()} />);
|
| 353 |
+
|
| 354 |
+
// General errors should not show countdown timer area
|
| 355 |
+
expect(screen.queryByText(/try again in/i)).not.toBeInTheDocument();
|
| 356 |
+
});
|
| 357 |
+
|
| 358 |
+
it('should not disable retry button for network errors', () => {
|
| 359 |
+
const mockRetry = vi.fn();
|
| 360 |
+
|
| 361 |
+
render(<ErrorState type="network" retryAfter={30} onRetry={mockRetry} />);
|
| 362 |
+
|
| 363 |
+
// Network errors should have enabled retry button
|
| 364 |
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
| 365 |
+
expect(retryButton).not.toBeDisabled();
|
| 366 |
+
});
|
| 367 |
+
});
|
| 368 |
+
|
| 369 |
+
// ==========================================================================
|
| 370 |
+
// Interaction Tests
|
| 371 |
+
// ==========================================================================
|
| 372 |
+
|
| 373 |
+
describe('interactions', () => {
|
| 374 |
+
it('should call onRetry when retry button is clicked', () => {
|
| 375 |
+
const mockRetry = vi.fn();
|
| 376 |
+
|
| 377 |
+
render(<ErrorState type="general" onRetry={mockRetry} />);
|
| 378 |
+
|
| 379 |
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
| 380 |
+
fireEvent.click(retryButton);
|
| 381 |
+
|
| 382 |
+
expect(mockRetry).toHaveBeenCalledTimes(1);
|
| 383 |
+
});
|
| 384 |
+
|
| 385 |
+
it('should call onDismiss when dismiss button is clicked', () => {
|
| 386 |
+
const mockDismiss = vi.fn();
|
| 387 |
+
|
| 388 |
+
render(<ErrorState type="general" onDismiss={mockDismiss} />);
|
| 389 |
+
|
| 390 |
+
const dismissButton = screen.getByLabelText('Dismiss error');
|
| 391 |
+
fireEvent.click(dismissButton);
|
| 392 |
+
|
| 393 |
+
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
it('should not call onRetry when retry is disabled during countdown', async () => {
|
| 397 |
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
| 398 |
+
const mockRetry = vi.fn();
|
| 399 |
+
|
| 400 |
+
render(<ErrorState type="quota" retryAfter={60} onRetry={mockRetry} />);
|
| 401 |
+
|
| 402 |
+
const retryButton = screen.getByRole('button', { name: /please wait/i });
|
| 403 |
+
|
| 404 |
+
// Try to click the disabled button
|
| 405 |
+
fireEvent.click(retryButton);
|
| 406 |
+
|
| 407 |
+
expect(mockRetry).not.toHaveBeenCalled();
|
| 408 |
+
|
| 409 |
+
vi.useRealTimers();
|
| 410 |
+
});
|
| 411 |
+
|
| 412 |
+
it('should call onRetry after countdown completes', async () => {
|
| 413 |
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
| 414 |
+
const mockRetry = vi.fn();
|
| 415 |
+
|
| 416 |
+
render(<ErrorState type="quota" retryAfter={2} onRetry={mockRetry} />);
|
| 417 |
+
|
| 418 |
+
// Wait for countdown to complete
|
| 419 |
+
await act(async () => {
|
| 420 |
+
vi.advanceTimersByTime(3000);
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
// Now click should work
|
| 424 |
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
| 425 |
+
fireEvent.click(retryButton);
|
| 426 |
+
|
| 427 |
+
expect(mockRetry).toHaveBeenCalledTimes(1);
|
| 428 |
+
|
| 429 |
+
vi.useRealTimers();
|
| 430 |
+
});
|
| 431 |
+
});
|
| 432 |
+
|
| 433 |
+
// ==========================================================================
|
| 434 |
+
// Accessibility Tests
|
| 435 |
+
// ==========================================================================
|
| 436 |
+
|
| 437 |
+
describe('accessibility', () => {
|
| 438 |
+
it('should have role="alert" attribute', () => {
|
| 439 |
+
render(<ErrorState type="general" />);
|
| 440 |
+
|
| 441 |
+
const alert = screen.getByRole('alert');
|
| 442 |
+
expect(alert).toBeInTheDocument();
|
| 443 |
+
});
|
| 444 |
+
|
| 445 |
+
it('should have aria-live="assertive" attribute', () => {
|
| 446 |
+
render(<ErrorState type="general" />);
|
| 447 |
+
|
| 448 |
+
const alert = screen.getByRole('alert');
|
| 449 |
+
expect(alert).toHaveAttribute('aria-live', 'assertive');
|
| 450 |
+
});
|
| 451 |
+
|
| 452 |
+
it('should have icons with aria-hidden="true"', () => {
|
| 453 |
+
render(
|
| 454 |
+
<ErrorState
|
| 455 |
+
type="general"
|
| 456 |
+
onRetry={vi.fn()}
|
| 457 |
+
onDismiss={vi.fn()}
|
| 458 |
+
/>
|
| 459 |
+
);
|
| 460 |
+
|
| 461 |
+
// Main icon (AlertTriangle for general)
|
| 462 |
+
const alertIcon = screen.getByTestId('alert-triangle-icon');
|
| 463 |
+
expect(alertIcon).toHaveAttribute('aria-hidden', 'true');
|
| 464 |
+
|
| 465 |
+
// Refresh icon in retry button
|
| 466 |
+
const refreshIcon = screen.getByTestId('refresh-icon');
|
| 467 |
+
expect(refreshIcon).toHaveAttribute('aria-hidden', 'true');
|
| 468 |
+
|
| 469 |
+
// X icon in dismiss button
|
| 470 |
+
const xIcon = screen.getByTestId('x-icon');
|
| 471 |
+
expect(xIcon).toHaveAttribute('aria-hidden', 'true');
|
| 472 |
+
});
|
| 473 |
+
|
| 474 |
+
it('should have countdown with aria-live="polite" for updates', () => {
|
| 475 |
+
render(<ErrorState type="quota" retryAfter={30} onRetry={vi.fn()} />);
|
| 476 |
+
|
| 477 |
+
// Find the countdown container - it should have aria-live="polite"
|
| 478 |
+
const countdownContainer = screen.getByText(/try again in/i).closest('div');
|
| 479 |
+
expect(countdownContainer).toHaveAttribute('aria-live', 'polite');
|
| 480 |
+
});
|
| 481 |
+
|
| 482 |
+
it('should have dismiss button with aria-label', () => {
|
| 483 |
+
render(<ErrorState type="general" onDismiss={vi.fn()} />);
|
| 484 |
+
|
| 485 |
+
const dismissButton = screen.getByLabelText('Dismiss error');
|
| 486 |
+
expect(dismissButton).toBeInTheDocument();
|
| 487 |
+
expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss error');
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
+
it('should be keyboard navigable', () => {
|
| 491 |
+
const mockRetry = vi.fn();
|
| 492 |
+
const mockDismiss = vi.fn();
|
| 493 |
+
|
| 494 |
+
render(
|
| 495 |
+
<ErrorState
|
| 496 |
+
type="general"
|
| 497 |
+
onRetry={mockRetry}
|
| 498 |
+
onDismiss={mockDismiss}
|
| 499 |
+
/>
|
| 500 |
+
);
|
| 501 |
+
|
| 502 |
+
// Dismiss button should be focusable
|
| 503 |
+
const dismissButton = screen.getByLabelText('Dismiss error');
|
| 504 |
+
dismissButton.focus();
|
| 505 |
+
expect(dismissButton).toHaveFocus();
|
| 506 |
+
|
| 507 |
+
// Retry button should be focusable
|
| 508 |
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
| 509 |
+
retryButton.focus();
|
| 510 |
+
expect(retryButton).toHaveFocus();
|
| 511 |
+
|
| 512 |
+
// Press Enter to activate retry (using keyboard event)
|
| 513 |
+
fireEvent.keyDown(retryButton, { key: 'Enter', code: 'Enter' });
|
| 514 |
+
fireEvent.keyUp(retryButton, { key: 'Enter', code: 'Enter' });
|
| 515 |
+
// Note: button click events are triggered by Enter key natively in tests,
|
| 516 |
+
// but we verify focus works correctly
|
| 517 |
+
});
|
| 518 |
+
|
| 519 |
+
it('should have visible focus styles on buttons', () => {
|
| 520 |
+
render(
|
| 521 |
+
<ErrorState
|
| 522 |
+
type="general"
|
| 523 |
+
onRetry={vi.fn()}
|
| 524 |
+
onDismiss={vi.fn()}
|
| 525 |
+
/>
|
| 526 |
+
);
|
| 527 |
+
|
| 528 |
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
| 529 |
+
const dismissButton = screen.getByLabelText('Dismiss error');
|
| 530 |
+
|
| 531 |
+
// Check for focus-visible ring classes
|
| 532 |
+
expect(retryButton.className).toMatch(/focus-visible:ring/);
|
| 533 |
+
expect(dismissButton.className).toMatch(/focus-visible:ring/);
|
| 534 |
+
});
|
| 535 |
+
});
|
| 536 |
+
|
| 537 |
+
// ==========================================================================
|
| 538 |
+
// Snapshot/Visual Tests
|
| 539 |
+
// ==========================================================================
|
| 540 |
+
|
| 541 |
+
describe('snapshots', () => {
|
| 542 |
+
it('should render quota type correctly (snapshot)', () => {
|
| 543 |
+
const { container } = render(
|
| 544 |
+
<ErrorState
|
| 545 |
+
type="quota"
|
| 546 |
+
retryAfter={45}
|
| 547 |
+
onRetry={vi.fn()}
|
| 548 |
+
onDismiss={vi.fn()}
|
| 549 |
+
/>
|
| 550 |
+
);
|
| 551 |
+
|
| 552 |
+
expect(container.firstChild).toMatchSnapshot();
|
| 553 |
+
});
|
| 554 |
+
|
| 555 |
+
it('should render network type correctly (snapshot)', () => {
|
| 556 |
+
const { container } = render(
|
| 557 |
+
<ErrorState
|
| 558 |
+
type="network"
|
| 559 |
+
onRetry={vi.fn()}
|
| 560 |
+
onDismiss={vi.fn()}
|
| 561 |
+
/>
|
| 562 |
+
);
|
| 563 |
+
|
| 564 |
+
expect(container.firstChild).toMatchSnapshot();
|
| 565 |
+
});
|
| 566 |
+
|
| 567 |
+
it('should render general type correctly (snapshot)', () => {
|
| 568 |
+
const { container } = render(
|
| 569 |
+
<ErrorState
|
| 570 |
+
type="general"
|
| 571 |
+
message="A custom error message for the snapshot test."
|
| 572 |
+
onRetry={vi.fn()}
|
| 573 |
+
onDismiss={vi.fn()}
|
| 574 |
+
/>
|
| 575 |
+
);
|
| 576 |
+
|
| 577 |
+
expect(container.firstChild).toMatchSnapshot();
|
| 578 |
+
});
|
| 579 |
+
});
|
| 580 |
+
|
| 581 |
+
// ==========================================================================
|
| 582 |
+
// Edge Cases Tests
|
| 583 |
+
// ==========================================================================
|
| 584 |
+
|
| 585 |
+
describe('edge cases', () => {
|
| 586 |
+
it('should handle retryAfter of 0 correctly', () => {
|
| 587 |
+
const mockRetry = vi.fn();
|
| 588 |
+
|
| 589 |
+
render(<ErrorState type="quota" retryAfter={0} onRetry={mockRetry} />);
|
| 590 |
+
|
| 591 |
+
// Retry button should be enabled immediately
|
| 592 |
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
| 593 |
+
expect(retryButton).not.toBeDisabled();
|
| 594 |
+
});
|
| 595 |
+
|
| 596 |
+
it('should handle undefined retryAfter correctly', () => {
|
| 597 |
+
const mockRetry = vi.fn();
|
| 598 |
+
|
| 599 |
+
render(<ErrorState type="quota" onRetry={mockRetry} />);
|
| 600 |
+
|
| 601 |
+
// Should not show countdown area when retryAfter is undefined
|
| 602 |
+
expect(screen.queryByText(/try again in/i)).not.toBeInTheDocument();
|
| 603 |
+
});
|
| 604 |
+
|
| 605 |
+
it('should apply custom className', () => {
|
| 606 |
+
render(<ErrorState type="general" className="custom-test-class mt-8" />);
|
| 607 |
+
|
| 608 |
+
const alert = screen.getByRole('alert');
|
| 609 |
+
expect(alert).toHaveClass('custom-test-class', 'mt-8');
|
| 610 |
+
});
|
| 611 |
+
|
| 612 |
+
it('should render both retry and dismiss buttons together', () => {
|
| 613 |
+
render(
|
| 614 |
+
<ErrorState
|
| 615 |
+
type="general"
|
| 616 |
+
onRetry={vi.fn()}
|
| 617 |
+
onDismiss={vi.fn()}
|
| 618 |
+
/>
|
| 619 |
+
);
|
| 620 |
+
|
| 621 |
+
expect(
|
| 622 |
+
screen.getByRole('button', { name: /try again/i })
|
| 623 |
+
).toBeInTheDocument();
|
| 624 |
+
expect(screen.getByLabelText('Dismiss error')).toBeInTheDocument();
|
| 625 |
+
});
|
| 626 |
+
|
| 627 |
+
it('should handle very large retryAfter values', () => {
|
| 628 |
+
render(<ErrorState type="quota" retryAfter={3600} onRetry={vi.fn()} />);
|
| 629 |
+
|
| 630 |
+
// Should format large values correctly (60:00 for 1 hour)
|
| 631 |
+
expect(screen.getByText(/60:00/)).toBeInTheDocument();
|
| 632 |
+
});
|
| 633 |
+
|
| 634 |
+
it('should handle countdown reset when retryAfter prop changes', async () => {
|
| 635 |
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
| 636 |
+
|
| 637 |
+
const { rerender } = render(
|
| 638 |
+
<ErrorState type="quota" retryAfter={10} onRetry={vi.fn()} />
|
| 639 |
+
);
|
| 640 |
+
|
| 641 |
+
expect(screen.getByText(/10s/)).toBeInTheDocument();
|
| 642 |
+
|
| 643 |
+
// Advance some time
|
| 644 |
+
await act(async () => {
|
| 645 |
+
vi.advanceTimersByTime(3000);
|
| 646 |
+
});
|
| 647 |
+
expect(screen.getByText(/7s/)).toBeInTheDocument();
|
| 648 |
+
|
| 649 |
+
// Change retryAfter prop
|
| 650 |
+
rerender(<ErrorState type="quota" retryAfter={20} onRetry={vi.fn()} />);
|
| 651 |
+
|
| 652 |
+
// Should reset to new value
|
| 653 |
+
expect(screen.getByText(/20s/)).toBeInTheDocument();
|
| 654 |
+
|
| 655 |
+
vi.useRealTimers();
|
| 656 |
+
});
|
| 657 |
+
|
| 658 |
+
it('should forward additional HTML attributes', () => {
|
| 659 |
+
render(
|
| 660 |
+
<ErrorState
|
| 661 |
+
type="general"
|
| 662 |
+
data-testid="custom-error"
|
| 663 |
+
title="Error notification"
|
| 664 |
+
/>
|
| 665 |
+
);
|
| 666 |
+
|
| 667 |
+
const alert = screen.getByTestId('custom-error');
|
| 668 |
+
expect(alert).toHaveAttribute('title', 'Error notification');
|
| 669 |
+
});
|
| 670 |
+
|
| 671 |
+
it('should render with minimal props (just type)', () => {
|
| 672 |
+
render(<ErrorState type="general" />);
|
| 673 |
+
|
| 674 |
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
| 675 |
+
expect(
|
| 676 |
+
screen.getByText('Oops! Something Went Wrong')
|
| 677 |
+
).toBeInTheDocument();
|
| 678 |
+
});
|
| 679 |
+
});
|
| 680 |
+
|
| 681 |
+
// ==========================================================================
|
| 682 |
+
// Integration Tests
|
| 683 |
+
// ==========================================================================
|
| 684 |
+
|
| 685 |
+
describe('integration', () => {
|
| 686 |
+
it('should render complete quota error with all features', () => {
|
| 687 |
+
const customMessage =
|
| 688 |
+
'Rate limit exceeded. Please wait before sending more requests.';
|
| 689 |
+
|
| 690 |
+
render(
|
| 691 |
+
<ErrorState
|
| 692 |
+
type="quota"
|
| 693 |
+
message={customMessage}
|
| 694 |
+
retryAfter={45}
|
| 695 |
+
onRetry={vi.fn()}
|
| 696 |
+
onDismiss={vi.fn()}
|
| 697 |
+
/>
|
| 698 |
+
);
|
| 699 |
+
|
| 700 |
+
// Title
|
| 701 |
+
expect(
|
| 702 |
+
screen.getByText('Service Temporarily Unavailable')
|
| 703 |
+
).toBeInTheDocument();
|
| 704 |
+
|
| 705 |
+
// Custom message
|
| 706 |
+
expect(screen.getByText(customMessage)).toBeInTheDocument();
|
| 707 |
+
|
| 708 |
+
// Countdown
|
| 709 |
+
expect(screen.getByText(/45s/)).toBeInTheDocument();
|
| 710 |
+
|
| 711 |
+
// Buttons
|
| 712 |
+
expect(
|
| 713 |
+
screen.getByRole('button', { name: /please wait/i })
|
| 714 |
+
).toBeInTheDocument();
|
| 715 |
+
expect(screen.getByLabelText('Dismiss error')).toBeInTheDocument();
|
| 716 |
+
|
| 717 |
+
// Icons
|
| 718 |
+
const clockIcons = screen.getAllByTestId('clock-icon');
|
| 719 |
+
expect(clockIcons.length).toBeGreaterThan(0);
|
| 720 |
+
});
|
| 721 |
+
|
| 722 |
+
it('should render complete network error with all features', () => {
|
| 723 |
+
render(
|
| 724 |
+
<ErrorState
|
| 725 |
+
type="network"
|
| 726 |
+
onRetry={vi.fn()}
|
| 727 |
+
onDismiss={vi.fn()}
|
| 728 |
+
/>
|
| 729 |
+
);
|
| 730 |
+
|
| 731 |
+
// Title
|
| 732 |
+
expect(screen.getByText('Connection Lost')).toBeInTheDocument();
|
| 733 |
+
|
| 734 |
+
// Default message
|
| 735 |
+
expect(
|
| 736 |
+
screen.getByText(DEFAULT_MESSAGES.network)
|
| 737 |
+
).toBeInTheDocument();
|
| 738 |
+
|
| 739 |
+
// Buttons (not disabled for network type)
|
| 740 |
+
expect(
|
| 741 |
+
screen.getByRole('button', { name: /try again/i })
|
| 742 |
+
).not.toBeDisabled();
|
| 743 |
+
|
| 744 |
+
// Icon
|
| 745 |
+
expect(screen.getByTestId('wifi-off-icon')).toBeInTheDocument();
|
| 746 |
+
});
|
| 747 |
+
|
| 748 |
+
it('should work correctly through a complete user flow', async () => {
|
| 749 |
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
| 750 |
+
const mockRetry = vi.fn();
|
| 751 |
+
const mockDismiss = vi.fn();
|
| 752 |
+
|
| 753 |
+
render(
|
| 754 |
+
<ErrorState
|
| 755 |
+
type="quota"
|
| 756 |
+
retryAfter={3}
|
| 757 |
+
onRetry={mockRetry}
|
| 758 |
+
onDismiss={mockDismiss}
|
| 759 |
+
/>
|
| 760 |
+
);
|
| 761 |
+
|
| 762 |
+
// Initially button is disabled
|
| 763 |
+
expect(
|
| 764 |
+
screen.getByRole('button', { name: /please wait/i })
|
| 765 |
+
).toBeDisabled();
|
| 766 |
+
|
| 767 |
+
// Wait for countdown
|
| 768 |
+
await act(async () => {
|
| 769 |
+
vi.advanceTimersByTime(1000);
|
| 770 |
+
});
|
| 771 |
+
expect(screen.getByText(/2s/)).toBeInTheDocument();
|
| 772 |
+
|
| 773 |
+
await act(async () => {
|
| 774 |
+
vi.advanceTimersByTime(1000);
|
| 775 |
+
});
|
| 776 |
+
expect(screen.getByText(/1s/)).toBeInTheDocument();
|
| 777 |
+
|
| 778 |
+
await act(async () => {
|
| 779 |
+
vi.advanceTimersByTime(1000);
|
| 780 |
+
});
|
| 781 |
+
|
| 782 |
+
// Now button should be enabled
|
| 783 |
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
| 784 |
+
expect(retryButton).not.toBeDisabled();
|
| 785 |
+
|
| 786 |
+
// Click retry
|
| 787 |
+
fireEvent.click(retryButton);
|
| 788 |
+
expect(mockRetry).toHaveBeenCalledTimes(1);
|
| 789 |
+
|
| 790 |
+
// Click dismiss
|
| 791 |
+
fireEvent.click(screen.getByLabelText('Dismiss error'));
|
| 792 |
+
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
| 793 |
+
|
| 794 |
+
vi.useRealTimers();
|
| 795 |
+
});
|
| 796 |
+
});
|
| 797 |
+
});
|
frontend/src/components/chat/__tests__/provider-toggle.test.tsx
ADDED
|
@@ -0,0 +1,1376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Unit Tests for ProviderToggle Component
|
| 3 |
+
*
|
| 4 |
+
* Comprehensive test coverage for the provider selection component.
|
| 5 |
+
* Tests rendering states, provider pills, selection behavior,
|
| 6 |
+
* cooldown timers, accessibility, and compact mode.
|
| 7 |
+
*
|
| 8 |
+
* @module components/chat/__tests__/provider-toggle.test
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
| 12 |
+
import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react';
|
| 13 |
+
import userEvent from '@testing-library/user-event';
|
| 14 |
+
import { ProviderToggle } from '../provider-toggle';
|
| 15 |
+
import { useProviders, type ProviderStatus } from '@/hooks';
|
| 16 |
+
|
| 17 |
+
// ============================================================================
|
| 18 |
+
// Mocks
|
| 19 |
+
// ============================================================================
|
| 20 |
+
|
| 21 |
+
vi.mock('@/hooks', () => ({
|
| 22 |
+
useProviders: vi.fn(),
|
| 23 |
+
}));
|
| 24 |
+
|
| 25 |
+
// ============================================================================
|
| 26 |
+
// Test Helpers
|
| 27 |
+
// ============================================================================
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Create a mock provider status object.
|
| 31 |
+
*
|
| 32 |
+
* @param overrides - Properties to override in the default provider
|
| 33 |
+
* @returns Mock provider status
|
| 34 |
+
*/
|
| 35 |
+
function createMockProvider(
|
| 36 |
+
overrides: Partial<ProviderStatus> = {}
|
| 37 |
+
): ProviderStatus {
|
| 38 |
+
return {
|
| 39 |
+
id: 'gemini',
|
| 40 |
+
name: 'Gemini',
|
| 41 |
+
description: 'Gemini Flash / Gemma',
|
| 42 |
+
isAvailable: true,
|
| 43 |
+
cooldownSeconds: null,
|
| 44 |
+
totalRequestsRemaining: 10,
|
| 45 |
+
primaryModel: 'gemini-2.5-flash-lite',
|
| 46 |
+
allModels: ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash', 'gemma-3-27b-it'],
|
| 47 |
+
...overrides,
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Create default mock return value for useProviders hook.
|
| 53 |
+
*
|
| 54 |
+
* @param overrides - Properties to override in the default return value
|
| 55 |
+
* @returns Mock useProviders return value
|
| 56 |
+
*/
|
| 57 |
+
function createMockUseProvidersReturn(
|
| 58 |
+
overrides: Partial<ReturnType<typeof useProviders>> = {}
|
| 59 |
+
): ReturnType<typeof useProviders> {
|
| 60 |
+
return {
|
| 61 |
+
providers: [],
|
| 62 |
+
selectedProvider: null,
|
| 63 |
+
selectProvider: vi.fn(),
|
| 64 |
+
isLoading: false,
|
| 65 |
+
error: null,
|
| 66 |
+
refresh: vi.fn(),
|
| 67 |
+
lastUpdated: new Date(),
|
| 68 |
+
...overrides,
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Set up the useProviders mock with the given return value.
|
| 74 |
+
*
|
| 75 |
+
* @param mockReturn - Mock return value for useProviders
|
| 76 |
+
*/
|
| 77 |
+
function setupMock(mockReturn: ReturnType<typeof useProviders>): void {
|
| 78 |
+
(useProviders as Mock).mockReturnValue(mockReturn);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// ============================================================================
|
| 82 |
+
// Test Suite
|
| 83 |
+
// ============================================================================
|
| 84 |
+
|
| 85 |
+
describe('ProviderToggle', () => {
|
| 86 |
+
beforeEach(() => {
|
| 87 |
+
vi.resetAllMocks();
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
afterEach(() => {
|
| 91 |
+
vi.clearAllTimers();
|
| 92 |
+
vi.useRealTimers();
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
// ==========================================================================
|
| 96 |
+
// Rendering States Tests
|
| 97 |
+
// ==========================================================================
|
| 98 |
+
|
| 99 |
+
describe('rendering states', () => {
|
| 100 |
+
it('should render loading skeleton when isLoading is true', () => {
|
| 101 |
+
setupMock(
|
| 102 |
+
createMockUseProvidersReturn({
|
| 103 |
+
isLoading: true,
|
| 104 |
+
providers: [],
|
| 105 |
+
})
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
render(<ProviderToggle />);
|
| 109 |
+
|
| 110 |
+
// Should have skeleton elements with aria-label for loading
|
| 111 |
+
const loadingGroup = screen.getByRole('group', {
|
| 112 |
+
name: /loading provider options/i,
|
| 113 |
+
});
|
| 114 |
+
expect(loadingGroup).toBeInTheDocument();
|
| 115 |
+
|
| 116 |
+
// Should have 3 skeleton pills (Auto + 2 providers)
|
| 117 |
+
const skeletons = loadingGroup.querySelectorAll('.animate-pulse');
|
| 118 |
+
expect(skeletons).toHaveLength(3);
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
it('should render error state with refresh button when error and no providers', () => {
|
| 122 |
+
const mockRefresh = vi.fn();
|
| 123 |
+
setupMock(
|
| 124 |
+
createMockUseProvidersReturn({
|
| 125 |
+
isLoading: false,
|
| 126 |
+
error: 'Failed to load providers',
|
| 127 |
+
providers: [],
|
| 128 |
+
refresh: mockRefresh,
|
| 129 |
+
})
|
| 130 |
+
);
|
| 131 |
+
|
| 132 |
+
render(<ProviderToggle />);
|
| 133 |
+
|
| 134 |
+
// Should show error alert
|
| 135 |
+
const alert = screen.getByRole('alert');
|
| 136 |
+
expect(alert).toBeInTheDocument();
|
| 137 |
+
expect(alert).toHaveTextContent('Failed to load providers');
|
| 138 |
+
|
| 139 |
+
// Should have retry button
|
| 140 |
+
const retryButton = screen.getByRole('button', {
|
| 141 |
+
name: /retry loading providers/i,
|
| 142 |
+
});
|
| 143 |
+
expect(retryButton).toBeInTheDocument();
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
it('should render provider pills when providers are available', () => {
|
| 147 |
+
const mockProviders = [
|
| 148 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 149 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 150 |
+
];
|
| 151 |
+
|
| 152 |
+
setupMock(
|
| 153 |
+
createMockUseProvidersReturn({
|
| 154 |
+
providers: mockProviders,
|
| 155 |
+
})
|
| 156 |
+
);
|
| 157 |
+
|
| 158 |
+
render(<ProviderToggle />);
|
| 159 |
+
|
| 160 |
+
// Should have radiogroup role
|
| 161 |
+
const radiogroup = screen.getByRole('radiogroup', {
|
| 162 |
+
name: /select llm provider/i,
|
| 163 |
+
});
|
| 164 |
+
expect(radiogroup).toBeInTheDocument();
|
| 165 |
+
|
| 166 |
+
// Should have provider pills (Auto + 2 providers)
|
| 167 |
+
const radios = screen.getAllByRole('radio');
|
| 168 |
+
expect(radios).toHaveLength(3);
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
it('should always render "Auto" option first', () => {
|
| 172 |
+
const mockProviders = [
|
| 173 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 174 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 175 |
+
];
|
| 176 |
+
|
| 177 |
+
setupMock(
|
| 178 |
+
createMockUseProvidersReturn({
|
| 179 |
+
providers: mockProviders,
|
| 180 |
+
})
|
| 181 |
+
);
|
| 182 |
+
|
| 183 |
+
render(<ProviderToggle />);
|
| 184 |
+
|
| 185 |
+
const radios = screen.getAllByRole('radio');
|
| 186 |
+
|
| 187 |
+
// First radio should be Auto
|
| 188 |
+
expect(radios[0]).toHaveAccessibleName(/auto/i);
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
it('should show providers even when there is an error if providers exist', () => {
|
| 192 |
+
const mockProviders = [
|
| 193 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 194 |
+
];
|
| 195 |
+
|
| 196 |
+
setupMock(
|
| 197 |
+
createMockUseProvidersReturn({
|
| 198 |
+
providers: mockProviders,
|
| 199 |
+
error: 'Refresh failed',
|
| 200 |
+
})
|
| 201 |
+
);
|
| 202 |
+
|
| 203 |
+
render(<ProviderToggle />);
|
| 204 |
+
|
| 205 |
+
// Should show providers, not error
|
| 206 |
+
const radiogroup = screen.getByRole('radiogroup');
|
| 207 |
+
expect(radiogroup).toBeInTheDocument();
|
| 208 |
+
|
| 209 |
+
// Should not show error alert when providers exist
|
| 210 |
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
| 211 |
+
});
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
// ==========================================================================
|
| 215 |
+
// Provider Pills Tests
|
| 216 |
+
// ==========================================================================
|
| 217 |
+
|
| 218 |
+
describe('provider pills', () => {
|
| 219 |
+
it('should display provider name', () => {
|
| 220 |
+
const mockProviders = [
|
| 221 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 222 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 223 |
+
];
|
| 224 |
+
|
| 225 |
+
setupMock(
|
| 226 |
+
createMockUseProvidersReturn({
|
| 227 |
+
providers: mockProviders,
|
| 228 |
+
})
|
| 229 |
+
);
|
| 230 |
+
|
| 231 |
+
render(<ProviderToggle />);
|
| 232 |
+
|
| 233 |
+
expect(screen.getByText('Auto')).toBeInTheDocument();
|
| 234 |
+
expect(screen.getByText('Gemini')).toBeInTheDocument();
|
| 235 |
+
expect(screen.getByText('Groq')).toBeInTheDocument();
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
it('should show green status indicator for available providers', () => {
|
| 239 |
+
const mockProviders = [
|
| 240 |
+
createMockProvider({
|
| 241 |
+
id: 'gemini',
|
| 242 |
+
name: 'Gemini',
|
| 243 |
+
isAvailable: true,
|
| 244 |
+
cooldownSeconds: null,
|
| 245 |
+
}),
|
| 246 |
+
];
|
| 247 |
+
|
| 248 |
+
setupMock(
|
| 249 |
+
createMockUseProvidersReturn({
|
| 250 |
+
providers: mockProviders,
|
| 251 |
+
})
|
| 252 |
+
);
|
| 253 |
+
|
| 254 |
+
const { container } = render(<ProviderToggle />);
|
| 255 |
+
|
| 256 |
+
// Find the Gemini button and check for green indicator
|
| 257 |
+
const geminiButton = screen.getByRole('radio', { name: /gemini/i });
|
| 258 |
+
const indicator = geminiButton.querySelector('.bg-\\[var\\(--success\\)\\]');
|
| 259 |
+
expect(indicator).toBeInTheDocument();
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
it('should show red status indicator for providers in cooldown', () => {
|
| 263 |
+
const mockProviders = [
|
| 264 |
+
createMockProvider({
|
| 265 |
+
id: 'groq',
|
| 266 |
+
name: 'Groq',
|
| 267 |
+
isAvailable: false,
|
| 268 |
+
cooldownSeconds: 45,
|
| 269 |
+
}),
|
| 270 |
+
];
|
| 271 |
+
|
| 272 |
+
setupMock(
|
| 273 |
+
createMockUseProvidersReturn({
|
| 274 |
+
providers: mockProviders,
|
| 275 |
+
})
|
| 276 |
+
);
|
| 277 |
+
|
| 278 |
+
const { container } = render(<ProviderToggle />);
|
| 279 |
+
|
| 280 |
+
// Find the Groq button and check for red indicator
|
| 281 |
+
const groqButton = screen.getByRole('radio', { name: /groq/i });
|
| 282 |
+
const indicator = groqButton.querySelector('.bg-\\[var\\(--error\\)\\]');
|
| 283 |
+
expect(indicator).toBeInTheDocument();
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
it('should show gray status indicator for unavailable providers without cooldown', () => {
|
| 287 |
+
const mockProviders = [
|
| 288 |
+
createMockProvider({
|
| 289 |
+
id: 'deepseek',
|
| 290 |
+
name: 'DeepSeek',
|
| 291 |
+
isAvailable: false,
|
| 292 |
+
cooldownSeconds: null,
|
| 293 |
+
}),
|
| 294 |
+
];
|
| 295 |
+
|
| 296 |
+
setupMock(
|
| 297 |
+
createMockUseProvidersReturn({
|
| 298 |
+
providers: mockProviders,
|
| 299 |
+
})
|
| 300 |
+
);
|
| 301 |
+
|
| 302 |
+
const { container } = render(<ProviderToggle />);
|
| 303 |
+
|
| 304 |
+
// Find the DeepSeek button and check for gray indicator
|
| 305 |
+
const deepseekButton = screen.getByRole('radio', { name: /deepseek/i });
|
| 306 |
+
const indicator = deepseekButton.querySelector(
|
| 307 |
+
'.bg-\\[var\\(--foreground-muted\\)\\]'
|
| 308 |
+
);
|
| 309 |
+
expect(indicator).toBeInTheDocument();
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
it('should mark selected provider with aria-checked true', () => {
|
| 313 |
+
const mockProviders = [
|
| 314 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 315 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 316 |
+
];
|
| 317 |
+
|
| 318 |
+
setupMock(
|
| 319 |
+
createMockUseProvidersReturn({
|
| 320 |
+
providers: mockProviders,
|
| 321 |
+
selectedProvider: 'gemini',
|
| 322 |
+
})
|
| 323 |
+
);
|
| 324 |
+
|
| 325 |
+
render(<ProviderToggle />);
|
| 326 |
+
|
| 327 |
+
const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
|
| 328 |
+
expect(geminiButton).toHaveAttribute('aria-checked', 'true');
|
| 329 |
+
|
| 330 |
+
const groqButton = screen.getByRole('radio', { name: /groq/i });
|
| 331 |
+
expect(groqButton).toHaveAttribute('aria-checked', 'false');
|
| 332 |
+
|
| 333 |
+
const autoButton = screen.getByRole('radio', { name: /auto/i });
|
| 334 |
+
expect(autoButton).toHaveAttribute('aria-checked', 'false');
|
| 335 |
+
});
|
| 336 |
+
|
| 337 |
+
it('should mark Auto as selected when selectedProvider is null', () => {
|
| 338 |
+
const mockProviders = [
|
| 339 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 340 |
+
];
|
| 341 |
+
|
| 342 |
+
setupMock(
|
| 343 |
+
createMockUseProvidersReturn({
|
| 344 |
+
providers: mockProviders,
|
| 345 |
+
selectedProvider: null,
|
| 346 |
+
})
|
| 347 |
+
);
|
| 348 |
+
|
| 349 |
+
render(<ProviderToggle />);
|
| 350 |
+
|
| 351 |
+
const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
|
| 352 |
+
expect(autoButton).toHaveAttribute('aria-checked', 'true');
|
| 353 |
+
});
|
| 354 |
+
});
|
| 355 |
+
|
| 356 |
+
// ==========================================================================
|
| 357 |
+
// Provider Selection Tests
|
| 358 |
+
// ==========================================================================
|
| 359 |
+
|
| 360 |
+
describe('provider selection', () => {
|
| 361 |
+
it('should call selectProvider when a pill is clicked', async () => {
|
| 362 |
+
const user = userEvent.setup();
|
| 363 |
+
const mockSelectProvider = vi.fn();
|
| 364 |
+
const mockProviders = [
|
| 365 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 366 |
+
];
|
| 367 |
+
|
| 368 |
+
setupMock(
|
| 369 |
+
createMockUseProvidersReturn({
|
| 370 |
+
providers: mockProviders,
|
| 371 |
+
selectProvider: mockSelectProvider,
|
| 372 |
+
})
|
| 373 |
+
);
|
| 374 |
+
|
| 375 |
+
render(<ProviderToggle />);
|
| 376 |
+
|
| 377 |
+
const geminiButton = screen.getByRole('radio', { name: /gemini/i });
|
| 378 |
+
await user.click(geminiButton);
|
| 379 |
+
|
| 380 |
+
expect(mockSelectProvider).toHaveBeenCalledTimes(1);
|
| 381 |
+
expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
|
| 382 |
+
});
|
| 383 |
+
|
| 384 |
+
it('should update aria-checked attribute for selected provider', () => {
|
| 385 |
+
const mockProviders = [
|
| 386 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 387 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 388 |
+
];
|
| 389 |
+
|
| 390 |
+
setupMock(
|
| 391 |
+
createMockUseProvidersReturn({
|
| 392 |
+
providers: mockProviders,
|
| 393 |
+
selectedProvider: 'groq',
|
| 394 |
+
})
|
| 395 |
+
);
|
| 396 |
+
|
| 397 |
+
render(<ProviderToggle />);
|
| 398 |
+
|
| 399 |
+
const groqButton = screen.getByRole('radio', { name: /groq.*selected/i });
|
| 400 |
+
expect(groqButton).toHaveAttribute('aria-checked', 'true');
|
| 401 |
+
|
| 402 |
+
const geminiButton = screen.getByRole('radio', { name: /gemini/i });
|
| 403 |
+
expect(geminiButton).toHaveAttribute('aria-checked', 'false');
|
| 404 |
+
});
|
| 405 |
+
|
| 406 |
+
it('should allow selecting Auto (null) option', async () => {
|
| 407 |
+
const user = userEvent.setup();
|
| 408 |
+
const mockSelectProvider = vi.fn();
|
| 409 |
+
const mockProviders = [
|
| 410 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 411 |
+
];
|
| 412 |
+
|
| 413 |
+
setupMock(
|
| 414 |
+
createMockUseProvidersReturn({
|
| 415 |
+
providers: mockProviders,
|
| 416 |
+
selectedProvider: 'gemini',
|
| 417 |
+
selectProvider: mockSelectProvider,
|
| 418 |
+
})
|
| 419 |
+
);
|
| 420 |
+
|
| 421 |
+
render(<ProviderToggle />);
|
| 422 |
+
|
| 423 |
+
const autoButton = screen.getByRole('radio', { name: /auto/i });
|
| 424 |
+
await user.click(autoButton);
|
| 425 |
+
|
| 426 |
+
expect(mockSelectProvider).toHaveBeenCalledWith(null);
|
| 427 |
+
});
|
| 428 |
+
|
| 429 |
+
it('should allow selecting unavailable providers (user choice)', async () => {
|
| 430 |
+
const user = userEvent.setup();
|
| 431 |
+
const mockSelectProvider = vi.fn();
|
| 432 |
+
const mockProviders = [
|
| 433 |
+
createMockProvider({
|
| 434 |
+
id: 'groq',
|
| 435 |
+
name: 'Groq',
|
| 436 |
+
isAvailable: false,
|
| 437 |
+
cooldownSeconds: 60,
|
| 438 |
+
}),
|
| 439 |
+
];
|
| 440 |
+
|
| 441 |
+
setupMock(
|
| 442 |
+
createMockUseProvidersReturn({
|
| 443 |
+
providers: mockProviders,
|
| 444 |
+
selectProvider: mockSelectProvider,
|
| 445 |
+
})
|
| 446 |
+
);
|
| 447 |
+
|
| 448 |
+
render(<ProviderToggle />);
|
| 449 |
+
|
| 450 |
+
const groqButton = screen.getByRole('radio', { name: /groq/i });
|
| 451 |
+
await user.click(groqButton);
|
| 452 |
+
|
| 453 |
+
// Should still allow selection even if unavailable
|
| 454 |
+
expect(mockSelectProvider).toHaveBeenCalledWith('groq');
|
| 455 |
+
});
|
| 456 |
+
});
|
| 457 |
+
|
| 458 |
+
// ==========================================================================
|
| 459 |
+
// Cooldown Timer Tests
|
| 460 |
+
// ==========================================================================
|
| 461 |
+
|
| 462 |
+
describe('cooldown timer', () => {
|
| 463 |
+
beforeEach(() => {
|
| 464 |
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
| 465 |
+
});
|
| 466 |
+
|
| 467 |
+
afterEach(() => {
|
| 468 |
+
vi.useRealTimers();
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
it('should display cooldown time for providers with cooldownSeconds', () => {
|
| 472 |
+
const mockProviders = [
|
| 473 |
+
createMockProvider({
|
| 474 |
+
id: 'groq',
|
| 475 |
+
name: 'Groq',
|
| 476 |
+
isAvailable: false,
|
| 477 |
+
cooldownSeconds: 45,
|
| 478 |
+
}),
|
| 479 |
+
];
|
| 480 |
+
|
| 481 |
+
setupMock(
|
| 482 |
+
createMockUseProvidersReturn({
|
| 483 |
+
providers: mockProviders,
|
| 484 |
+
})
|
| 485 |
+
);
|
| 486 |
+
|
| 487 |
+
render(<ProviderToggle />);
|
| 488 |
+
|
| 489 |
+
// Should display the countdown
|
| 490 |
+
expect(screen.getByText('45s')).toBeInTheDocument();
|
| 491 |
+
});
|
| 492 |
+
|
| 493 |
+
it('should format time as "Xm Ys" for times with minutes and seconds', () => {
|
| 494 |
+
const mockProviders = [
|
| 495 |
+
createMockProvider({
|
| 496 |
+
id: 'groq',
|
| 497 |
+
name: 'Groq',
|
| 498 |
+
isAvailable: false,
|
| 499 |
+
cooldownSeconds: 130, // 2m 10s
|
| 500 |
+
}),
|
| 501 |
+
];
|
| 502 |
+
|
| 503 |
+
setupMock(
|
| 504 |
+
createMockUseProvidersReturn({
|
| 505 |
+
providers: mockProviders,
|
| 506 |
+
})
|
| 507 |
+
);
|
| 508 |
+
|
| 509 |
+
render(<ProviderToggle />);
|
| 510 |
+
|
| 511 |
+
expect(screen.getByText('2m 10s')).toBeInTheDocument();
|
| 512 |
+
});
|
| 513 |
+
|
| 514 |
+
it('should format time as "Xm" for exact minutes', () => {
|
| 515 |
+
const mockProviders = [
|
| 516 |
+
createMockProvider({
|
| 517 |
+
id: 'groq',
|
| 518 |
+
name: 'Groq',
|
| 519 |
+
isAvailable: false,
|
| 520 |
+
cooldownSeconds: 120, // 2m 0s
|
| 521 |
+
}),
|
| 522 |
+
];
|
| 523 |
+
|
| 524 |
+
setupMock(
|
| 525 |
+
createMockUseProvidersReturn({
|
| 526 |
+
providers: mockProviders,
|
| 527 |
+
})
|
| 528 |
+
);
|
| 529 |
+
|
| 530 |
+
render(<ProviderToggle />);
|
| 531 |
+
|
| 532 |
+
expect(screen.getByText('2m')).toBeInTheDocument();
|
| 533 |
+
});
|
| 534 |
+
|
| 535 |
+
it('should format time as "Xs" for times under a minute', () => {
|
| 536 |
+
const mockProviders = [
|
| 537 |
+
createMockProvider({
|
| 538 |
+
id: 'groq',
|
| 539 |
+
name: 'Groq',
|
| 540 |
+
isAvailable: false,
|
| 541 |
+
cooldownSeconds: 30,
|
| 542 |
+
}),
|
| 543 |
+
];
|
| 544 |
+
|
| 545 |
+
setupMock(
|
| 546 |
+
createMockUseProvidersReturn({
|
| 547 |
+
providers: mockProviders,
|
| 548 |
+
})
|
| 549 |
+
);
|
| 550 |
+
|
| 551 |
+
render(<ProviderToggle />);
|
| 552 |
+
|
| 553 |
+
expect(screen.getByText('30s')).toBeInTheDocument();
|
| 554 |
+
});
|
| 555 |
+
|
| 556 |
+
it('should decrement countdown every second', async () => {
|
| 557 |
+
const mockProviders = [
|
| 558 |
+
createMockProvider({
|
| 559 |
+
id: 'groq',
|
| 560 |
+
name: 'Groq',
|
| 561 |
+
isAvailable: false,
|
| 562 |
+
cooldownSeconds: 5,
|
| 563 |
+
}),
|
| 564 |
+
];
|
| 565 |
+
|
| 566 |
+
setupMock(
|
| 567 |
+
createMockUseProvidersReturn({
|
| 568 |
+
providers: mockProviders,
|
| 569 |
+
})
|
| 570 |
+
);
|
| 571 |
+
|
| 572 |
+
render(<ProviderToggle />);
|
| 573 |
+
|
| 574 |
+
expect(screen.getByText('5s')).toBeInTheDocument();
|
| 575 |
+
|
| 576 |
+
// Advance by 1 second using act to wrap the state update
|
| 577 |
+
await act(async () => {
|
| 578 |
+
vi.advanceTimersByTime(1000);
|
| 579 |
+
});
|
| 580 |
+
expect(screen.getByText('4s')).toBeInTheDocument();
|
| 581 |
+
|
| 582 |
+
// Advance by another second
|
| 583 |
+
await act(async () => {
|
| 584 |
+
vi.advanceTimersByTime(1000);
|
| 585 |
+
});
|
| 586 |
+
expect(screen.getByText('3s')).toBeInTheDocument();
|
| 587 |
+
});
|
| 588 |
+
|
| 589 |
+
it('should stop countdown and hide when reaching 0', async () => {
|
| 590 |
+
const mockProviders = [
|
| 591 |
+
createMockProvider({
|
| 592 |
+
id: 'groq',
|
| 593 |
+
name: 'Groq',
|
| 594 |
+
isAvailable: false,
|
| 595 |
+
cooldownSeconds: 2,
|
| 596 |
+
}),
|
| 597 |
+
];
|
| 598 |
+
|
| 599 |
+
setupMock(
|
| 600 |
+
createMockUseProvidersReturn({
|
| 601 |
+
providers: mockProviders,
|
| 602 |
+
})
|
| 603 |
+
);
|
| 604 |
+
|
| 605 |
+
render(<ProviderToggle />);
|
| 606 |
+
|
| 607 |
+
expect(screen.getByText('2s')).toBeInTheDocument();
|
| 608 |
+
|
| 609 |
+
// Advance to 1s
|
| 610 |
+
await act(async () => {
|
| 611 |
+
vi.advanceTimersByTime(1000);
|
| 612 |
+
});
|
| 613 |
+
expect(screen.getByText('1s')).toBeInTheDocument();
|
| 614 |
+
|
| 615 |
+
// Advance past 0
|
| 616 |
+
await act(async () => {
|
| 617 |
+
vi.advanceTimersByTime(1000);
|
| 618 |
+
});
|
| 619 |
+
|
| 620 |
+
// Timer should be hidden
|
| 621 |
+
expect(screen.queryByText('0s')).not.toBeInTheDocument();
|
| 622 |
+
expect(screen.queryByText('1s')).not.toBeInTheDocument();
|
| 623 |
+
});
|
| 624 |
+
|
| 625 |
+
it('should not display timer when cooldownSeconds is null', () => {
|
| 626 |
+
const mockProviders = [
|
| 627 |
+
createMockProvider({
|
| 628 |
+
id: 'gemini',
|
| 629 |
+
name: 'Gemini',
|
| 630 |
+
isAvailable: true,
|
| 631 |
+
cooldownSeconds: null,
|
| 632 |
+
}),
|
| 633 |
+
];
|
| 634 |
+
|
| 635 |
+
setupMock(
|
| 636 |
+
createMockUseProvidersReturn({
|
| 637 |
+
providers: mockProviders,
|
| 638 |
+
})
|
| 639 |
+
);
|
| 640 |
+
|
| 641 |
+
const { container } = render(<ProviderToggle />);
|
| 642 |
+
|
| 643 |
+
// Should not have any time elements in Gemini button
|
| 644 |
+
const geminiButton = screen.getByRole('radio', { name: /gemini/i });
|
| 645 |
+
expect(geminiButton.textContent).not.toMatch(/\d+s/);
|
| 646 |
+
expect(geminiButton.textContent).not.toMatch(/\d+m/);
|
| 647 |
+
});
|
| 648 |
+
|
| 649 |
+
it('should not display timer when cooldownSeconds is 0', () => {
|
| 650 |
+
const mockProviders = [
|
| 651 |
+
createMockProvider({
|
| 652 |
+
id: 'groq',
|
| 653 |
+
name: 'Groq',
|
| 654 |
+
isAvailable: false,
|
| 655 |
+
cooldownSeconds: 0,
|
| 656 |
+
}),
|
| 657 |
+
];
|
| 658 |
+
|
| 659 |
+
setupMock(
|
| 660 |
+
createMockUseProvidersReturn({
|
| 661 |
+
providers: mockProviders,
|
| 662 |
+
})
|
| 663 |
+
);
|
| 664 |
+
|
| 665 |
+
const { container } = render(<ProviderToggle />);
|
| 666 |
+
|
| 667 |
+
// Should not display any countdown
|
| 668 |
+
const groqButton = screen.getByRole('radio', { name: /groq/i });
|
| 669 |
+
expect(groqButton.textContent).not.toMatch(/\d+s/);
|
| 670 |
+
});
|
| 671 |
+
});
|
| 672 |
+
|
| 673 |
+
// ==========================================================================
|
| 674 |
+
// Accessibility Tests
|
| 675 |
+
// ==========================================================================
|
| 676 |
+
|
| 677 |
+
describe('accessibility', () => {
|
| 678 |
+
it('should have radiogroup role on container', () => {
|
| 679 |
+
const mockProviders = [
|
| 680 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 681 |
+
];
|
| 682 |
+
|
| 683 |
+
setupMock(
|
| 684 |
+
createMockUseProvidersReturn({
|
| 685 |
+
providers: mockProviders,
|
| 686 |
+
})
|
| 687 |
+
);
|
| 688 |
+
|
| 689 |
+
render(<ProviderToggle />);
|
| 690 |
+
|
| 691 |
+
const radiogroup = screen.getByRole('radiogroup', {
|
| 692 |
+
name: /select llm provider/i,
|
| 693 |
+
});
|
| 694 |
+
expect(radiogroup).toBeInTheDocument();
|
| 695 |
+
});
|
| 696 |
+
|
| 697 |
+
it('should have radio role on each pill', () => {
|
| 698 |
+
const mockProviders = [
|
| 699 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 700 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 701 |
+
];
|
| 702 |
+
|
| 703 |
+
setupMock(
|
| 704 |
+
createMockUseProvidersReturn({
|
| 705 |
+
providers: mockProviders,
|
| 706 |
+
})
|
| 707 |
+
);
|
| 708 |
+
|
| 709 |
+
render(<ProviderToggle />);
|
| 710 |
+
|
| 711 |
+
const radios = screen.getAllByRole('radio');
|
| 712 |
+
expect(radios).toHaveLength(3); // Auto + 2 providers
|
| 713 |
+
});
|
| 714 |
+
|
| 715 |
+
it('should support keyboard navigation with arrow keys', async () => {
|
| 716 |
+
const user = userEvent.setup();
|
| 717 |
+
const mockSelectProvider = vi.fn();
|
| 718 |
+
const mockProviders = [
|
| 719 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 720 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 721 |
+
];
|
| 722 |
+
|
| 723 |
+
setupMock(
|
| 724 |
+
createMockUseProvidersReturn({
|
| 725 |
+
providers: mockProviders,
|
| 726 |
+
selectedProvider: null,
|
| 727 |
+
selectProvider: mockSelectProvider,
|
| 728 |
+
})
|
| 729 |
+
);
|
| 730 |
+
|
| 731 |
+
render(<ProviderToggle />);
|
| 732 |
+
|
| 733 |
+
// Focus the first (Auto) button
|
| 734 |
+
const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
|
| 735 |
+
autoButton.focus();
|
| 736 |
+
|
| 737 |
+
// Press ArrowRight to move to next option
|
| 738 |
+
await user.keyboard('{ArrowRight}');
|
| 739 |
+
|
| 740 |
+
expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
|
| 741 |
+
});
|
| 742 |
+
|
| 743 |
+
it('should support ArrowDown as alternative to ArrowRight', async () => {
|
| 744 |
+
const user = userEvent.setup();
|
| 745 |
+
const mockSelectProvider = vi.fn();
|
| 746 |
+
const mockProviders = [
|
| 747 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 748 |
+
];
|
| 749 |
+
|
| 750 |
+
setupMock(
|
| 751 |
+
createMockUseProvidersReturn({
|
| 752 |
+
providers: mockProviders,
|
| 753 |
+
selectedProvider: null,
|
| 754 |
+
selectProvider: mockSelectProvider,
|
| 755 |
+
})
|
| 756 |
+
);
|
| 757 |
+
|
| 758 |
+
render(<ProviderToggle />);
|
| 759 |
+
|
| 760 |
+
const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
|
| 761 |
+
autoButton.focus();
|
| 762 |
+
|
| 763 |
+
await user.keyboard('{ArrowDown}');
|
| 764 |
+
|
| 765 |
+
expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
|
| 766 |
+
});
|
| 767 |
+
|
| 768 |
+
it('should support ArrowLeft/ArrowUp to move backwards', async () => {
|
| 769 |
+
const user = userEvent.setup();
|
| 770 |
+
const mockSelectProvider = vi.fn();
|
| 771 |
+
const mockProviders = [
|
| 772 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 773 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 774 |
+
];
|
| 775 |
+
|
| 776 |
+
setupMock(
|
| 777 |
+
createMockUseProvidersReturn({
|
| 778 |
+
providers: mockProviders,
|
| 779 |
+
selectedProvider: 'gemini',
|
| 780 |
+
selectProvider: mockSelectProvider,
|
| 781 |
+
})
|
| 782 |
+
);
|
| 783 |
+
|
| 784 |
+
render(<ProviderToggle />);
|
| 785 |
+
|
| 786 |
+
const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
|
| 787 |
+
geminiButton.focus();
|
| 788 |
+
|
| 789 |
+
// Press ArrowLeft to go back to Auto
|
| 790 |
+
await user.keyboard('{ArrowLeft}');
|
| 791 |
+
|
| 792 |
+
expect(mockSelectProvider).toHaveBeenCalledWith(null);
|
| 793 |
+
});
|
| 794 |
+
|
| 795 |
+
it('should support Home key to go to first option', async () => {
|
| 796 |
+
const user = userEvent.setup();
|
| 797 |
+
const mockSelectProvider = vi.fn();
|
| 798 |
+
const mockProviders = [
|
| 799 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 800 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 801 |
+
];
|
| 802 |
+
|
| 803 |
+
setupMock(
|
| 804 |
+
createMockUseProvidersReturn({
|
| 805 |
+
providers: mockProviders,
|
| 806 |
+
selectedProvider: 'groq',
|
| 807 |
+
selectProvider: mockSelectProvider,
|
| 808 |
+
})
|
| 809 |
+
);
|
| 810 |
+
|
| 811 |
+
render(<ProviderToggle />);
|
| 812 |
+
|
| 813 |
+
const groqButton = screen.getByRole('radio', { name: /groq.*selected/i });
|
| 814 |
+
groqButton.focus();
|
| 815 |
+
|
| 816 |
+
await user.keyboard('{Home}');
|
| 817 |
+
|
| 818 |
+
// Should select Auto (first option, id = null)
|
| 819 |
+
expect(mockSelectProvider).toHaveBeenCalledWith(null);
|
| 820 |
+
});
|
| 821 |
+
|
| 822 |
+
it('should support End key to go to last option', async () => {
|
| 823 |
+
const user = userEvent.setup();
|
| 824 |
+
const mockSelectProvider = vi.fn();
|
| 825 |
+
const mockProviders = [
|
| 826 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 827 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 828 |
+
];
|
| 829 |
+
|
| 830 |
+
setupMock(
|
| 831 |
+
createMockUseProvidersReturn({
|
| 832 |
+
providers: mockProviders,
|
| 833 |
+
selectedProvider: null,
|
| 834 |
+
selectProvider: mockSelectProvider,
|
| 835 |
+
})
|
| 836 |
+
);
|
| 837 |
+
|
| 838 |
+
render(<ProviderToggle />);
|
| 839 |
+
|
| 840 |
+
const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
|
| 841 |
+
autoButton.focus();
|
| 842 |
+
|
| 843 |
+
await user.keyboard('{End}');
|
| 844 |
+
|
| 845 |
+
// Should select last option (groq)
|
| 846 |
+
expect(mockSelectProvider).toHaveBeenCalledWith('groq');
|
| 847 |
+
});
|
| 848 |
+
|
| 849 |
+
it('should use roving tabindex pattern', () => {
|
| 850 |
+
const mockProviders = [
|
| 851 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 852 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 853 |
+
];
|
| 854 |
+
|
| 855 |
+
setupMock(
|
| 856 |
+
createMockUseProvidersReturn({
|
| 857 |
+
providers: mockProviders,
|
| 858 |
+
selectedProvider: 'gemini',
|
| 859 |
+
})
|
| 860 |
+
);
|
| 861 |
+
|
| 862 |
+
render(<ProviderToggle />);
|
| 863 |
+
|
| 864 |
+
const autoButton = screen.getByRole('radio', { name: /auto/i });
|
| 865 |
+
const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
|
| 866 |
+
const groqButton = screen.getByRole('radio', { name: /groq/i });
|
| 867 |
+
|
| 868 |
+
// Only the selected option should have tabindex=0
|
| 869 |
+
expect(geminiButton).toHaveAttribute('tabindex', '0');
|
| 870 |
+
expect(autoButton).toHaveAttribute('tabindex', '-1');
|
| 871 |
+
expect(groqButton).toHaveAttribute('tabindex', '-1');
|
| 872 |
+
});
|
| 873 |
+
|
| 874 |
+
it('should wrap around when navigating past the end', async () => {
|
| 875 |
+
const user = userEvent.setup();
|
| 876 |
+
const mockSelectProvider = vi.fn();
|
| 877 |
+
const mockProviders = [
|
| 878 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 879 |
+
];
|
| 880 |
+
|
| 881 |
+
setupMock(
|
| 882 |
+
createMockUseProvidersReturn({
|
| 883 |
+
providers: mockProviders,
|
| 884 |
+
selectedProvider: 'gemini',
|
| 885 |
+
selectProvider: mockSelectProvider,
|
| 886 |
+
})
|
| 887 |
+
);
|
| 888 |
+
|
| 889 |
+
render(<ProviderToggle />);
|
| 890 |
+
|
| 891 |
+
const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
|
| 892 |
+
geminiButton.focus();
|
| 893 |
+
|
| 894 |
+
// Press ArrowRight to wrap to Auto (first option)
|
| 895 |
+
await user.keyboard('{ArrowRight}');
|
| 896 |
+
|
| 897 |
+
expect(mockSelectProvider).toHaveBeenCalledWith(null);
|
| 898 |
+
});
|
| 899 |
+
|
| 900 |
+
it('should wrap around when navigating before the start', async () => {
|
| 901 |
+
const user = userEvent.setup();
|
| 902 |
+
const mockSelectProvider = vi.fn();
|
| 903 |
+
const mockProviders = [
|
| 904 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 905 |
+
];
|
| 906 |
+
|
| 907 |
+
setupMock(
|
| 908 |
+
createMockUseProvidersReturn({
|
| 909 |
+
providers: mockProviders,
|
| 910 |
+
selectedProvider: null,
|
| 911 |
+
selectProvider: mockSelectProvider,
|
| 912 |
+
})
|
| 913 |
+
);
|
| 914 |
+
|
| 915 |
+
render(<ProviderToggle />);
|
| 916 |
+
|
| 917 |
+
const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
|
| 918 |
+
autoButton.focus();
|
| 919 |
+
|
| 920 |
+
// Press ArrowLeft to wrap to last option (gemini)
|
| 921 |
+
await user.keyboard('{ArrowLeft}');
|
| 922 |
+
|
| 923 |
+
expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
|
| 924 |
+
});
|
| 925 |
+
|
| 926 |
+
it('should have proper aria-label for unavailable providers', () => {
|
| 927 |
+
const mockProviders = [
|
| 928 |
+
createMockProvider({
|
| 929 |
+
id: 'groq',
|
| 930 |
+
name: 'Groq',
|
| 931 |
+
isAvailable: false,
|
| 932 |
+
cooldownSeconds: null,
|
| 933 |
+
}),
|
| 934 |
+
];
|
| 935 |
+
|
| 936 |
+
setupMock(
|
| 937 |
+
createMockUseProvidersReturn({
|
| 938 |
+
providers: mockProviders,
|
| 939 |
+
})
|
| 940 |
+
);
|
| 941 |
+
|
| 942 |
+
render(<ProviderToggle />);
|
| 943 |
+
|
| 944 |
+
const groqButton = screen.getByRole('radio', { name: /groq.*unavailable/i });
|
| 945 |
+
expect(groqButton).toBeInTheDocument();
|
| 946 |
+
});
|
| 947 |
+
|
| 948 |
+
it('should have proper aria-label for providers in cooldown', () => {
|
| 949 |
+
const mockProviders = [
|
| 950 |
+
createMockProvider({
|
| 951 |
+
id: 'groq',
|
| 952 |
+
name: 'Groq',
|
| 953 |
+
isAvailable: false,
|
| 954 |
+
cooldownSeconds: 45,
|
| 955 |
+
}),
|
| 956 |
+
];
|
| 957 |
+
|
| 958 |
+
setupMock(
|
| 959 |
+
createMockUseProvidersReturn({
|
| 960 |
+
providers: mockProviders,
|
| 961 |
+
})
|
| 962 |
+
);
|
| 963 |
+
|
| 964 |
+
render(<ProviderToggle />);
|
| 965 |
+
|
| 966 |
+
const groqButton = screen.getByRole('radio', {
|
| 967 |
+
name: /groq.*cooling down/i,
|
| 968 |
+
});
|
| 969 |
+
expect(groqButton).toBeInTheDocument();
|
| 970 |
+
});
|
| 971 |
+
});
|
| 972 |
+
|
| 973 |
+
// ==========================================================================
|
| 974 |
+
// Refresh Button Tests
|
| 975 |
+
// ==========================================================================
|
| 976 |
+
|
| 977 |
+
describe('refresh button', () => {
|
| 978 |
+
it('should call refresh when refresh button is clicked', async () => {
|
| 979 |
+
const user = userEvent.setup();
|
| 980 |
+
const mockRefresh = vi.fn();
|
| 981 |
+
const mockProviders = [
|
| 982 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 983 |
+
];
|
| 984 |
+
|
| 985 |
+
setupMock(
|
| 986 |
+
createMockUseProvidersReturn({
|
| 987 |
+
providers: mockProviders,
|
| 988 |
+
refresh: mockRefresh,
|
| 989 |
+
})
|
| 990 |
+
);
|
| 991 |
+
|
| 992 |
+
render(<ProviderToggle />);
|
| 993 |
+
|
| 994 |
+
const refreshButton = screen.getByRole('button', {
|
| 995 |
+
name: /refresh provider status/i,
|
| 996 |
+
});
|
| 997 |
+
await user.click(refreshButton);
|
| 998 |
+
|
| 999 |
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
| 1000 |
+
});
|
| 1001 |
+
|
| 1002 |
+
it('should be accessible with proper aria-label', () => {
|
| 1003 |
+
const mockProviders = [
|
| 1004 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 1005 |
+
];
|
| 1006 |
+
|
| 1007 |
+
setupMock(
|
| 1008 |
+
createMockUseProvidersReturn({
|
| 1009 |
+
providers: mockProviders,
|
| 1010 |
+
})
|
| 1011 |
+
);
|
| 1012 |
+
|
| 1013 |
+
render(<ProviderToggle />);
|
| 1014 |
+
|
| 1015 |
+
const refreshButton = screen.getByRole('button', {
|
| 1016 |
+
name: /refresh provider status/i,
|
| 1017 |
+
});
|
| 1018 |
+
expect(refreshButton).toHaveAttribute('title', 'Refresh provider status');
|
| 1019 |
+
});
|
| 1020 |
+
|
| 1021 |
+
it('should call refresh in error state', async () => {
|
| 1022 |
+
const user = userEvent.setup();
|
| 1023 |
+
const mockRefresh = vi.fn();
|
| 1024 |
+
|
| 1025 |
+
setupMock(
|
| 1026 |
+
createMockUseProvidersReturn({
|
| 1027 |
+
providers: [],
|
| 1028 |
+
error: 'Failed to load',
|
| 1029 |
+
refresh: mockRefresh,
|
| 1030 |
+
})
|
| 1031 |
+
);
|
| 1032 |
+
|
| 1033 |
+
render(<ProviderToggle />);
|
| 1034 |
+
|
| 1035 |
+
const retryButton = screen.getByRole('button', {
|
| 1036 |
+
name: /retry loading providers/i,
|
| 1037 |
+
});
|
| 1038 |
+
await user.click(retryButton);
|
| 1039 |
+
|
| 1040 |
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
| 1041 |
+
});
|
| 1042 |
+
});
|
| 1043 |
+
|
| 1044 |
+
// ==========================================================================
|
| 1045 |
+
// Compact Mode Tests
|
| 1046 |
+
// ==========================================================================
|
| 1047 |
+
|
| 1048 |
+
describe('compact mode', () => {
|
| 1049 |
+
it('should render with smaller sizes when compact prop is true', () => {
|
| 1050 |
+
const mockProviders = [
|
| 1051 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 1052 |
+
];
|
| 1053 |
+
|
| 1054 |
+
setupMock(
|
| 1055 |
+
createMockUseProvidersReturn({
|
| 1056 |
+
providers: mockProviders,
|
| 1057 |
+
})
|
| 1058 |
+
);
|
| 1059 |
+
|
| 1060 |
+
const { container } = render(<ProviderToggle compact />);
|
| 1061 |
+
|
| 1062 |
+
// Check that compact classes are applied
|
| 1063 |
+
const radiogroup = screen.getByRole('radiogroup');
|
| 1064 |
+
|
| 1065 |
+
// Container should have smaller gap
|
| 1066 |
+
expect(radiogroup).toHaveClass('gap-1.5');
|
| 1067 |
+
});
|
| 1068 |
+
|
| 1069 |
+
it('should render smaller refresh button in compact mode', () => {
|
| 1070 |
+
const mockProviders = [
|
| 1071 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 1072 |
+
];
|
| 1073 |
+
|
| 1074 |
+
setupMock(
|
| 1075 |
+
createMockUseProvidersReturn({
|
| 1076 |
+
providers: mockProviders,
|
| 1077 |
+
})
|
| 1078 |
+
);
|
| 1079 |
+
|
| 1080 |
+
render(<ProviderToggle compact />);
|
| 1081 |
+
|
| 1082 |
+
const refreshButton = screen.getByRole('button', {
|
| 1083 |
+
name: /refresh provider status/i,
|
| 1084 |
+
});
|
| 1085 |
+
|
| 1086 |
+
// Compact refresh button should have smaller dimensions
|
| 1087 |
+
expect(refreshButton).toHaveClass('h-6', 'w-6');
|
| 1088 |
+
});
|
| 1089 |
+
|
| 1090 |
+
it('should render normal size without compact prop', () => {
|
| 1091 |
+
const mockProviders = [
|
| 1092 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 1093 |
+
];
|
| 1094 |
+
|
| 1095 |
+
setupMock(
|
| 1096 |
+
createMockUseProvidersReturn({
|
| 1097 |
+
providers: mockProviders,
|
| 1098 |
+
})
|
| 1099 |
+
);
|
| 1100 |
+
|
| 1101 |
+
render(<ProviderToggle />);
|
| 1102 |
+
|
| 1103 |
+
const refreshButton = screen.getByRole('button', {
|
| 1104 |
+
name: /refresh provider status/i,
|
| 1105 |
+
});
|
| 1106 |
+
|
| 1107 |
+
// Normal refresh button should have larger dimensions
|
| 1108 |
+
expect(refreshButton).toHaveClass('h-7', 'w-7');
|
| 1109 |
+
});
|
| 1110 |
+
|
| 1111 |
+
it('should render smaller skeleton in compact mode when loading', () => {
|
| 1112 |
+
setupMock(
|
| 1113 |
+
createMockUseProvidersReturn({
|
| 1114 |
+
isLoading: true,
|
| 1115 |
+
providers: [],
|
| 1116 |
+
})
|
| 1117 |
+
);
|
| 1118 |
+
|
| 1119 |
+
const { container } = render(<ProviderToggle compact />);
|
| 1120 |
+
|
| 1121 |
+
const skeletons = container.querySelectorAll('.animate-pulse');
|
| 1122 |
+
|
| 1123 |
+
// Check that skeletons have compact height
|
| 1124 |
+
skeletons.forEach((skeleton) => {
|
| 1125 |
+
expect(skeleton).toHaveClass('h-7');
|
| 1126 |
+
});
|
| 1127 |
+
});
|
| 1128 |
+
|
| 1129 |
+
it('should render smaller error state in compact mode', () => {
|
| 1130 |
+
setupMock(
|
| 1131 |
+
createMockUseProvidersReturn({
|
| 1132 |
+
providers: [],
|
| 1133 |
+
error: 'Error message',
|
| 1134 |
+
})
|
| 1135 |
+
);
|
| 1136 |
+
|
| 1137 |
+
render(<ProviderToggle compact />);
|
| 1138 |
+
|
| 1139 |
+
const alert = screen.getByRole('alert');
|
| 1140 |
+
expect(alert).toHaveClass('text-xs');
|
| 1141 |
+
});
|
| 1142 |
+
|
| 1143 |
+
it('should apply custom className', () => {
|
| 1144 |
+
const mockProviders = [
|
| 1145 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 1146 |
+
];
|
| 1147 |
+
|
| 1148 |
+
setupMock(
|
| 1149 |
+
createMockUseProvidersReturn({
|
| 1150 |
+
providers: mockProviders,
|
| 1151 |
+
})
|
| 1152 |
+
);
|
| 1153 |
+
|
| 1154 |
+
render(<ProviderToggle className="custom-class mt-4" />);
|
| 1155 |
+
|
| 1156 |
+
const radiogroup = screen.getByRole('radiogroup');
|
| 1157 |
+
expect(radiogroup).toHaveClass('custom-class', 'mt-4');
|
| 1158 |
+
});
|
| 1159 |
+
});
|
| 1160 |
+
|
| 1161 |
+
// ==========================================================================
|
| 1162 |
+
// Edge Cases Tests
|
| 1163 |
+
// ==========================================================================
|
| 1164 |
+
|
| 1165 |
+
describe('edge cases', () => {
|
| 1166 |
+
it('should handle empty providers array', () => {
|
| 1167 |
+
setupMock(
|
| 1168 |
+
createMockUseProvidersReturn({
|
| 1169 |
+
providers: [],
|
| 1170 |
+
isLoading: false,
|
| 1171 |
+
error: null,
|
| 1172 |
+
})
|
| 1173 |
+
);
|
| 1174 |
+
|
| 1175 |
+
render(<ProviderToggle />);
|
| 1176 |
+
|
| 1177 |
+
// Should still render radiogroup with just Auto option
|
| 1178 |
+
const radiogroup = screen.getByRole('radiogroup');
|
| 1179 |
+
expect(radiogroup).toBeInTheDocument();
|
| 1180 |
+
|
| 1181 |
+
const radios = screen.getAllByRole('radio');
|
| 1182 |
+
expect(radios).toHaveLength(1); // Just Auto
|
| 1183 |
+
});
|
| 1184 |
+
|
| 1185 |
+
it('should handle provider with very long name', () => {
|
| 1186 |
+
const mockProviders = [
|
| 1187 |
+
createMockProvider({
|
| 1188 |
+
id: 'long-name-provider',
|
| 1189 |
+
name: 'Very Long Provider Name That Might Overflow',
|
| 1190 |
+
}),
|
| 1191 |
+
];
|
| 1192 |
+
|
| 1193 |
+
setupMock(
|
| 1194 |
+
createMockUseProvidersReturn({
|
| 1195 |
+
providers: mockProviders,
|
| 1196 |
+
})
|
| 1197 |
+
);
|
| 1198 |
+
|
| 1199 |
+
render(<ProviderToggle />);
|
| 1200 |
+
|
| 1201 |
+
expect(
|
| 1202 |
+
screen.getByText('Very Long Provider Name That Might Overflow')
|
| 1203 |
+
).toBeInTheDocument();
|
| 1204 |
+
});
|
| 1205 |
+
|
| 1206 |
+
it('should handle multiple providers with mixed availability', () => {
|
| 1207 |
+
const mockProviders = [
|
| 1208 |
+
createMockProvider({
|
| 1209 |
+
id: 'gemini',
|
| 1210 |
+
name: 'Gemini',
|
| 1211 |
+
isAvailable: true,
|
| 1212 |
+
}),
|
| 1213 |
+
createMockProvider({
|
| 1214 |
+
id: 'groq',
|
| 1215 |
+
name: 'Groq',
|
| 1216 |
+
isAvailable: false,
|
| 1217 |
+
cooldownSeconds: 30,
|
| 1218 |
+
}),
|
| 1219 |
+
createMockProvider({
|
| 1220 |
+
id: 'deepseek',
|
| 1221 |
+
name: 'DeepSeek',
|
| 1222 |
+
isAvailable: false,
|
| 1223 |
+
cooldownSeconds: null,
|
| 1224 |
+
}),
|
| 1225 |
+
];
|
| 1226 |
+
|
| 1227 |
+
setupMock(
|
| 1228 |
+
createMockUseProvidersReturn({
|
| 1229 |
+
providers: mockProviders,
|
| 1230 |
+
})
|
| 1231 |
+
);
|
| 1232 |
+
|
| 1233 |
+
render(<ProviderToggle />);
|
| 1234 |
+
|
| 1235 |
+
const radios = screen.getAllByRole('radio');
|
| 1236 |
+
expect(radios).toHaveLength(4); // Auto + 3 providers
|
| 1237 |
+
|
| 1238 |
+
// Each should render correctly
|
| 1239 |
+
expect(screen.getByText('Gemini')).toBeInTheDocument();
|
| 1240 |
+
expect(screen.getByText('Groq')).toBeInTheDocument();
|
| 1241 |
+
expect(screen.getByText('DeepSeek')).toBeInTheDocument();
|
| 1242 |
+
expect(screen.getByText('30s')).toBeInTheDocument(); // Groq cooldown
|
| 1243 |
+
});
|
| 1244 |
+
|
| 1245 |
+
it('should handle very long error message with truncation', () => {
|
| 1246 |
+
const longError =
|
| 1247 |
+
'This is a very long error message that should be truncated to prevent layout issues in the UI when displaying error states';
|
| 1248 |
+
|
| 1249 |
+
setupMock(
|
| 1250 |
+
createMockUseProvidersReturn({
|
| 1251 |
+
providers: [],
|
| 1252 |
+
error: longError,
|
| 1253 |
+
})
|
| 1254 |
+
);
|
| 1255 |
+
|
| 1256 |
+
render(<ProviderToggle />);
|
| 1257 |
+
|
| 1258 |
+
const errorSpan = screen.getByText(longError);
|
| 1259 |
+
expect(errorSpan).toHaveClass('truncate');
|
| 1260 |
+
expect(errorSpan).toHaveAttribute('title', longError);
|
| 1261 |
+
});
|
| 1262 |
+
|
| 1263 |
+
it('should maintain focus after keyboard navigation', async () => {
|
| 1264 |
+
const user = userEvent.setup();
|
| 1265 |
+
const mockSelectProvider = vi.fn();
|
| 1266 |
+
const mockProviders = [
|
| 1267 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 1268 |
+
createMockProvider({ id: 'groq', name: 'Groq' }),
|
| 1269 |
+
];
|
| 1270 |
+
|
| 1271 |
+
setupMock(
|
| 1272 |
+
createMockUseProvidersReturn({
|
| 1273 |
+
providers: mockProviders,
|
| 1274 |
+
selectedProvider: null,
|
| 1275 |
+
selectProvider: mockSelectProvider,
|
| 1276 |
+
})
|
| 1277 |
+
);
|
| 1278 |
+
|
| 1279 |
+
render(<ProviderToggle />);
|
| 1280 |
+
|
| 1281 |
+
const autoButton = screen.getByRole('radio', { name: /auto.*selected/i });
|
| 1282 |
+
autoButton.focus();
|
| 1283 |
+
|
| 1284 |
+
// Navigate to Gemini
|
| 1285 |
+
await user.keyboard('{ArrowRight}');
|
| 1286 |
+
|
| 1287 |
+
// selectProvider should have been called
|
| 1288 |
+
expect(mockSelectProvider).toHaveBeenCalledWith('gemini');
|
| 1289 |
+
});
|
| 1290 |
+
});
|
| 1291 |
+
|
| 1292 |
+
// ==========================================================================
|
| 1293 |
+
// Integration Tests
|
| 1294 |
+
// ==========================================================================
|
| 1295 |
+
|
| 1296 |
+
describe('integration', () => {
|
| 1297 |
+
it('should work correctly with a complete provider list', () => {
|
| 1298 |
+
const mockProviders = [
|
| 1299 |
+
createMockProvider({
|
| 1300 |
+
id: 'gemini',
|
| 1301 |
+
name: 'Gemini',
|
| 1302 |
+
description: 'Google Gemini Pro',
|
| 1303 |
+
isAvailable: true,
|
| 1304 |
+
cooldownSeconds: null,
|
| 1305 |
+
totalRequestsRemaining: 10,
|
| 1306 |
+
}),
|
| 1307 |
+
createMockProvider({
|
| 1308 |
+
id: 'groq',
|
| 1309 |
+
name: 'Groq',
|
| 1310 |
+
description: 'Groq LLM',
|
| 1311 |
+
isAvailable: false,
|
| 1312 |
+
cooldownSeconds: 45,
|
| 1313 |
+
totalRequestsRemaining: 0,
|
| 1314 |
+
}),
|
| 1315 |
+
createMockProvider({
|
| 1316 |
+
id: 'deepseek',
|
| 1317 |
+
name: 'DeepSeek',
|
| 1318 |
+
description: 'DeepSeek Chat',
|
| 1319 |
+
isAvailable: true,
|
| 1320 |
+
cooldownSeconds: null,
|
| 1321 |
+
totalRequestsRemaining: 5,
|
| 1322 |
+
}),
|
| 1323 |
+
];
|
| 1324 |
+
|
| 1325 |
+
setupMock(
|
| 1326 |
+
createMockUseProvidersReturn({
|
| 1327 |
+
providers: mockProviders,
|
| 1328 |
+
selectedProvider: 'gemini',
|
| 1329 |
+
})
|
| 1330 |
+
);
|
| 1331 |
+
|
| 1332 |
+
render(<ProviderToggle />);
|
| 1333 |
+
|
| 1334 |
+
// All providers should be rendered
|
| 1335 |
+
expect(screen.getByText('Auto')).toBeInTheDocument();
|
| 1336 |
+
expect(screen.getByText('Gemini')).toBeInTheDocument();
|
| 1337 |
+
expect(screen.getByText('Groq')).toBeInTheDocument();
|
| 1338 |
+
expect(screen.getByText('DeepSeek')).toBeInTheDocument();
|
| 1339 |
+
|
| 1340 |
+
// Cooldown should be shown
|
| 1341 |
+
expect(screen.getByText('45s')).toBeInTheDocument();
|
| 1342 |
+
|
| 1343 |
+
// Selection should be correct
|
| 1344 |
+
const geminiButton = screen.getByRole('radio', { name: /gemini.*selected/i });
|
| 1345 |
+
expect(geminiButton).toHaveAttribute('aria-checked', 'true');
|
| 1346 |
+
});
|
| 1347 |
+
|
| 1348 |
+
it('should render all elements with correct structure', () => {
|
| 1349 |
+
const mockProviders = [
|
| 1350 |
+
createMockProvider({ id: 'gemini', name: 'Gemini' }),
|
| 1351 |
+
];
|
| 1352 |
+
|
| 1353 |
+
setupMock(
|
| 1354 |
+
createMockUseProvidersReturn({
|
| 1355 |
+
providers: mockProviders,
|
| 1356 |
+
})
|
| 1357 |
+
);
|
| 1358 |
+
|
| 1359 |
+
const { container } = render(<ProviderToggle />);
|
| 1360 |
+
|
| 1361 |
+
// Check structure
|
| 1362 |
+
const radiogroup = screen.getByRole('radiogroup');
|
| 1363 |
+
expect(radiogroup).toBeInTheDocument();
|
| 1364 |
+
|
| 1365 |
+
// Radio buttons should be inside the radiogroup
|
| 1366 |
+
const radios = within(radiogroup).getAllByRole('radio');
|
| 1367 |
+
expect(radios).toHaveLength(2);
|
| 1368 |
+
|
| 1369 |
+
// Refresh button should be present
|
| 1370 |
+
const refreshButton = screen.getByRole('button', {
|
| 1371 |
+
name: /refresh provider status/i,
|
| 1372 |
+
});
|
| 1373 |
+
expect(refreshButton).toBeInTheDocument();
|
| 1374 |
+
});
|
| 1375 |
+
});
|
| 1376 |
+
});
|
frontend/src/components/chat/__tests__/source-card.test.tsx
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Unit Tests for SourceCard Component
|
| 3 |
+
*
|
| 4 |
+
* Comprehensive test coverage for the source citation card component.
|
| 5 |
+
* Tests rendering, expand/collapse functionality, accessibility features,
|
| 6 |
+
* and edge cases for text truncation and special characters.
|
| 7 |
+
*
|
| 8 |
+
* @module components/chat/__tests__/source-card.test
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
| 12 |
+
import { render, screen, fireEvent, within } from '@testing-library/react';
|
| 13 |
+
import userEvent from '@testing-library/user-event';
|
| 14 |
+
import { SourceCard } from '../source-card';
|
| 15 |
+
import type { Source } from '@/types';
|
| 16 |
+
|
| 17 |
+
// ============================================================================
|
| 18 |
+
// Mocks
|
| 19 |
+
// ============================================================================
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Mock lucide-react icons for faster tests and to avoid lazy loading issues.
|
| 23 |
+
* Each icon is replaced with a simple span containing a test ID.
|
| 24 |
+
*/
|
| 25 |
+
vi.mock('lucide-react', () => ({
|
| 26 |
+
FileText: ({ className }: { className?: string }) => (
|
| 27 |
+
<span data-testid="file-icon" className={className} />
|
| 28 |
+
),
|
| 29 |
+
ChevronDown: ({ className }: { className?: string }) => (
|
| 30 |
+
<span data-testid="chevron-down-icon" className={className} />
|
| 31 |
+
),
|
| 32 |
+
ChevronUp: ({ className }: { className?: string }) => (
|
| 33 |
+
<span data-testid="chevron-up-icon" className={className} />
|
| 34 |
+
),
|
| 35 |
+
}));
|
| 36 |
+
|
| 37 |
+
// ============================================================================
|
| 38 |
+
// Test Fixtures
|
| 39 |
+
// ============================================================================
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Create a mock source object for testing.
|
| 43 |
+
*
|
| 44 |
+
* @param overrides - Properties to override in the default source
|
| 45 |
+
* @returns Mock source object with sensible defaults
|
| 46 |
+
*/
|
| 47 |
+
function createMockSource(overrides: Partial<Source> = {}): Source {
|
| 48 |
+
return {
|
| 49 |
+
id: 'test-source-1',
|
| 50 |
+
headingPath: 'Chapter 1 > Section 2 > Subsection A',
|
| 51 |
+
page: 42,
|
| 52 |
+
text: 'This is the source text content from the pythermalcomfort documentation.',
|
| 53 |
+
score: 0.85,
|
| 54 |
+
...overrides,
|
| 55 |
+
};
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Long text fixture for testing truncation behavior.
|
| 60 |
+
* Contains more than 150 characters to trigger truncation.
|
| 61 |
+
*/
|
| 62 |
+
const LONG_TEXT =
|
| 63 |
+
'This is a very long text that exceeds the default truncation length of 150 characters. ' +
|
| 64 |
+
'It contains multiple sentences to test the word-boundary truncation behavior. ' +
|
| 65 |
+
'The truncation should happen at a word boundary to avoid cutting words in the middle. ' +
|
| 66 |
+
'This ensures a clean user experience when displaying source excerpts.';
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Short text fixture for testing non-truncation behavior.
|
| 70 |
+
* Contains fewer than 150 characters.
|
| 71 |
+
*/
|
| 72 |
+
const SHORT_TEXT = 'This is short text that fits within the truncation limit.';
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Text with special characters for edge case testing.
|
| 76 |
+
*/
|
| 77 |
+
const SPECIAL_CHARS_TEXT =
|
| 78 |
+
'Temperature formula: T = (a + b) / 2 where a > 0 & b < 100. Use <code>calc()</code> for computation.';
|
| 79 |
+
|
| 80 |
+
// ============================================================================
|
| 81 |
+
// Test Suite
|
| 82 |
+
// ============================================================================
|
| 83 |
+
|
| 84 |
+
describe('SourceCard', () => {
|
| 85 |
+
beforeEach(() => {
|
| 86 |
+
vi.resetAllMocks();
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
// ==========================================================================
|
| 90 |
+
// Rendering Tests
|
| 91 |
+
// ==========================================================================
|
| 92 |
+
|
| 93 |
+
describe('rendering', () => {
|
| 94 |
+
it('should render heading path correctly', () => {
|
| 95 |
+
// Test that the heading path is displayed with proper hierarchy
|
| 96 |
+
const source = createMockSource({
|
| 97 |
+
headingPath: 'Introduction > Thermal Comfort > PMV Model',
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
render(<SourceCard source={source} />);
|
| 101 |
+
|
| 102 |
+
expect(
|
| 103 |
+
screen.getByText('Introduction > Thermal Comfort > PMV Model')
|
| 104 |
+
).toBeInTheDocument();
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
it('should render page number badge', () => {
|
| 108 |
+
// Test that page number is shown in a badge format
|
| 109 |
+
const source = createMockSource({ page: 78 });
|
| 110 |
+
|
| 111 |
+
render(<SourceCard source={source} />);
|
| 112 |
+
|
| 113 |
+
// Should show "Page X" format
|
| 114 |
+
expect(screen.getByText('Page 78')).toBeInTheDocument();
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
it('should render page badge with correct aria-label', () => {
|
| 118 |
+
// Test accessibility of page badge
|
| 119 |
+
const source = createMockSource({ page: 123 });
|
| 120 |
+
|
| 121 |
+
render(<SourceCard source={source} />);
|
| 122 |
+
|
| 123 |
+
const pageBadge = screen.getByLabelText('Page 123');
|
| 124 |
+
expect(pageBadge).toBeInTheDocument();
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
it('should render source text (truncated when long)', () => {
|
| 128 |
+
// Test that long text is truncated by default
|
| 129 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 130 |
+
|
| 131 |
+
render(<SourceCard source={source} />);
|
| 132 |
+
|
| 133 |
+
// Should show truncated text with ellipsis
|
| 134 |
+
const textElement = screen.getByText(/This is a very long text/);
|
| 135 |
+
expect(textElement).toBeInTheDocument();
|
| 136 |
+
// Full text should not be visible
|
| 137 |
+
expect(screen.queryByText(LONG_TEXT)).not.toBeInTheDocument();
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
it('should render full text when short enough', () => {
|
| 141 |
+
// Test that short text is not truncated
|
| 142 |
+
const source = createMockSource({ text: SHORT_TEXT });
|
| 143 |
+
|
| 144 |
+
render(<SourceCard source={source} />);
|
| 145 |
+
|
| 146 |
+
expect(screen.getByText(SHORT_TEXT)).toBeInTheDocument();
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
it('should render score badge when showScore is true', () => {
|
| 150 |
+
// Test score badge rendering with showScore prop
|
| 151 |
+
const source = createMockSource({ score: 0.85 });
|
| 152 |
+
|
| 153 |
+
render(<SourceCard source={source} showScore />);
|
| 154 |
+
|
| 155 |
+
// Score should be displayed as percentage
|
| 156 |
+
expect(screen.getByText('85%')).toBeInTheDocument();
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
it('should render score badge with correct aria-label', () => {
|
| 160 |
+
// Test accessibility of score badge
|
| 161 |
+
const source = createMockSource({ score: 0.92 });
|
| 162 |
+
|
| 163 |
+
render(<SourceCard source={source} showScore />);
|
| 164 |
+
|
| 165 |
+
expect(
|
| 166 |
+
screen.getByLabelText('Relevance score: 92 percent')
|
| 167 |
+
).toBeInTheDocument();
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
it('should not render score badge when showScore is false', () => {
|
| 171 |
+
// Test that score is hidden by default
|
| 172 |
+
const source = createMockSource({ score: 0.75 });
|
| 173 |
+
|
| 174 |
+
render(<SourceCard source={source} showScore={false} />);
|
| 175 |
+
|
| 176 |
+
expect(screen.queryByText('75%')).not.toBeInTheDocument();
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
it('should not render score badge when showScore is true but score is undefined', () => {
|
| 180 |
+
// Test handling of undefined score
|
| 181 |
+
const source = createMockSource({ score: undefined });
|
| 182 |
+
|
| 183 |
+
render(<SourceCard source={source} showScore />);
|
| 184 |
+
|
| 185 |
+
// No percentage should be rendered
|
| 186 |
+
expect(screen.queryByText(/%/)).not.toBeInTheDocument();
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
it('should not render score badge by default (showScore defaults to false)', () => {
|
| 190 |
+
// Test default behavior
|
| 191 |
+
const source = createMockSource({ score: 0.88 });
|
| 192 |
+
|
| 193 |
+
render(<SourceCard source={source} />);
|
| 194 |
+
|
| 195 |
+
expect(screen.queryByText('88%')).not.toBeInTheDocument();
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
it('should render file icon in header', () => {
|
| 199 |
+
// Test that FileText icon is present
|
| 200 |
+
const source = createMockSource();
|
| 201 |
+
|
| 202 |
+
render(<SourceCard source={source} />);
|
| 203 |
+
|
| 204 |
+
expect(screen.getByTestId('file-icon')).toBeInTheDocument();
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
it('should apply high score color styling (>= 80%)', () => {
|
| 208 |
+
// Test that high scores get success color
|
| 209 |
+
const source = createMockSource({ score: 0.85 });
|
| 210 |
+
|
| 211 |
+
render(<SourceCard source={source} showScore />);
|
| 212 |
+
|
| 213 |
+
const scoreBadge = screen.getByLabelText('Relevance score: 85 percent');
|
| 214 |
+
expect(scoreBadge.className).toMatch(/success/);
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
it('should apply medium score color styling (60-79%)', () => {
|
| 218 |
+
// Test that medium scores get primary color
|
| 219 |
+
const source = createMockSource({ score: 0.65 });
|
| 220 |
+
|
| 221 |
+
render(<SourceCard source={source} showScore />);
|
| 222 |
+
|
| 223 |
+
const scoreBadge = screen.getByLabelText('Relevance score: 65 percent');
|
| 224 |
+
expect(scoreBadge.className).toMatch(/primary/);
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
it('should apply low score color styling (< 60%)', () => {
|
| 228 |
+
// Test that low scores get muted color
|
| 229 |
+
const source = createMockSource({ score: 0.45 });
|
| 230 |
+
|
| 231 |
+
render(<SourceCard source={source} showScore />);
|
| 232 |
+
|
| 233 |
+
const scoreBadge = screen.getByLabelText('Relevance score: 45 percent');
|
| 234 |
+
expect(scoreBadge.className).toMatch(/foreground-muted|background-tertiary/);
|
| 235 |
+
});
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
// ==========================================================================
|
| 239 |
+
// Expand/Collapse Tests
|
| 240 |
+
// ==========================================================================
|
| 241 |
+
|
| 242 |
+
describe('expand/collapse', () => {
|
| 243 |
+
it('should start collapsed by default', () => {
|
| 244 |
+
// Test default collapsed state
|
| 245 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 246 |
+
|
| 247 |
+
render(<SourceCard source={source} />);
|
| 248 |
+
|
| 249 |
+
// Should show "Show more" button indicating collapsed state
|
| 250 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
it('should start expanded when defaultExpanded is true', () => {
|
| 254 |
+
// Test defaultExpanded prop
|
| 255 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 256 |
+
|
| 257 |
+
render(<SourceCard source={source} defaultExpanded />);
|
| 258 |
+
|
| 259 |
+
// Should show "Show less" button indicating expanded state
|
| 260 |
+
expect(screen.getByText('Show less')).toBeInTheDocument();
|
| 261 |
+
// Full text should be visible
|
| 262 |
+
expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
it('should toggle expand/collapse on button click', async () => {
|
| 266 |
+
// Test click interaction
|
| 267 |
+
const user = userEvent.setup();
|
| 268 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 269 |
+
|
| 270 |
+
render(<SourceCard source={source} />);
|
| 271 |
+
|
| 272 |
+
// Initially collapsed
|
| 273 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 274 |
+
|
| 275 |
+
// Click to expand
|
| 276 |
+
await user.click(screen.getByText('Show more'));
|
| 277 |
+
|
| 278 |
+
// Should now be expanded
|
| 279 |
+
expect(screen.getByText('Show less')).toBeInTheDocument();
|
| 280 |
+
expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
|
| 281 |
+
|
| 282 |
+
// Click to collapse
|
| 283 |
+
await user.click(screen.getByText('Show less'));
|
| 284 |
+
|
| 285 |
+
// Should be collapsed again
|
| 286 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
it('should show full text when expanded', () => {
|
| 290 |
+
// Test that full text is visible when expanded
|
| 291 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 292 |
+
|
| 293 |
+
render(<SourceCard source={source} defaultExpanded />);
|
| 294 |
+
|
| 295 |
+
expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
|
| 296 |
+
});
|
| 297 |
+
|
| 298 |
+
it('should show truncated text when collapsed', () => {
|
| 299 |
+
// Test truncation in collapsed state
|
| 300 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 301 |
+
|
| 302 |
+
render(<SourceCard source={source} />);
|
| 303 |
+
|
| 304 |
+
// Full text should not be present
|
| 305 |
+
expect(screen.queryByText(LONG_TEXT)).not.toBeInTheDocument();
|
| 306 |
+
// Truncated version should be present (with ellipsis)
|
| 307 |
+
expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
|
| 308 |
+
});
|
| 309 |
+
|
| 310 |
+
it('should change button text from "Show more" to "Show less"', async () => {
|
| 311 |
+
// Test button label changes
|
| 312 |
+
const user = userEvent.setup();
|
| 313 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 314 |
+
|
| 315 |
+
render(<SourceCard source={source} />);
|
| 316 |
+
|
| 317 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 318 |
+
expect(screen.queryByText('Show less')).not.toBeInTheDocument();
|
| 319 |
+
|
| 320 |
+
await user.click(screen.getByText('Show more'));
|
| 321 |
+
|
| 322 |
+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
|
| 323 |
+
expect(screen.getByText('Show less')).toBeInTheDocument();
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
it('should show ChevronDown icon when collapsed', () => {
|
| 327 |
+
// Test chevron icon in collapsed state
|
| 328 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 329 |
+
|
| 330 |
+
render(<SourceCard source={source} />);
|
| 331 |
+
|
| 332 |
+
expect(screen.getByTestId('chevron-down-icon')).toBeInTheDocument();
|
| 333 |
+
});
|
| 334 |
+
|
| 335 |
+
it('should show ChevronUp icon when expanded', () => {
|
| 336 |
+
// Test chevron icon in expanded state
|
| 337 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 338 |
+
|
| 339 |
+
render(<SourceCard source={source} defaultExpanded />);
|
| 340 |
+
|
| 341 |
+
expect(screen.getByTestId('chevron-up-icon')).toBeInTheDocument();
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
it('should not show expand button for short text', () => {
|
| 345 |
+
// Test that button is hidden when not needed
|
| 346 |
+
const source = createMockSource({ text: SHORT_TEXT });
|
| 347 |
+
|
| 348 |
+
render(<SourceCard source={source} />);
|
| 349 |
+
|
| 350 |
+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
|
| 351 |
+
expect(screen.queryByText('Show less')).not.toBeInTheDocument();
|
| 352 |
+
});
|
| 353 |
+
|
| 354 |
+
it('should respect custom truncateLength prop', () => {
|
| 355 |
+
// Test custom truncation length
|
| 356 |
+
const source = createMockSource({ text: SHORT_TEXT });
|
| 357 |
+
|
| 358 |
+
// Set truncateLength shorter than the text
|
| 359 |
+
render(<SourceCard source={source} truncateLength={30} />);
|
| 360 |
+
|
| 361 |
+
// Should now show expand button since text exceeds custom limit
|
| 362 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 363 |
+
});
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
// ==========================================================================
|
| 367 |
+
// Accessibility Tests
|
| 368 |
+
// ==========================================================================
|
| 369 |
+
|
| 370 |
+
describe('accessibility', () => {
|
| 371 |
+
it('should have correct aria-expanded attribute when collapsed', () => {
|
| 372 |
+
// Test aria-expanded state for collapsed
|
| 373 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 374 |
+
|
| 375 |
+
render(<SourceCard source={source} />);
|
| 376 |
+
|
| 377 |
+
const button = screen.getByRole('button');
|
| 378 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
it('should have correct aria-expanded attribute when expanded', () => {
|
| 382 |
+
// Test aria-expanded state for expanded
|
| 383 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 384 |
+
|
| 385 |
+
render(<SourceCard source={source} defaultExpanded />);
|
| 386 |
+
|
| 387 |
+
const button = screen.getByRole('button');
|
| 388 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 389 |
+
});
|
| 390 |
+
|
| 391 |
+
it('should update aria-expanded attribute on toggle', async () => {
|
| 392 |
+
// Test aria-expanded updates dynamically
|
| 393 |
+
const user = userEvent.setup();
|
| 394 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 395 |
+
|
| 396 |
+
render(<SourceCard source={source} />);
|
| 397 |
+
|
| 398 |
+
const button = screen.getByRole('button');
|
| 399 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 400 |
+
|
| 401 |
+
await user.click(button);
|
| 402 |
+
|
| 403 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 404 |
+
});
|
| 405 |
+
|
| 406 |
+
it('should have aria-controls linking to text region', () => {
|
| 407 |
+
// Test aria-controls relationship
|
| 408 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 409 |
+
|
| 410 |
+
render(<SourceCard source={source} />);
|
| 411 |
+
|
| 412 |
+
const button = screen.getByRole('button');
|
| 413 |
+
const controlsId = button.getAttribute('aria-controls');
|
| 414 |
+
|
| 415 |
+
expect(controlsId).toBe('source-text-region');
|
| 416 |
+
expect(document.getElementById('source-text-region')).toBeInTheDocument();
|
| 417 |
+
});
|
| 418 |
+
|
| 419 |
+
it('should respond to Enter key for toggle', async () => {
|
| 420 |
+
// Test keyboard navigation with Enter
|
| 421 |
+
const user = userEvent.setup();
|
| 422 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 423 |
+
|
| 424 |
+
render(<SourceCard source={source} />);
|
| 425 |
+
|
| 426 |
+
const button = screen.getByRole('button');
|
| 427 |
+
button.focus();
|
| 428 |
+
|
| 429 |
+
// Press Enter to expand
|
| 430 |
+
await user.keyboard('{Enter}');
|
| 431 |
+
|
| 432 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
it('should respond to Space key for toggle', async () => {
|
| 436 |
+
// Test keyboard navigation with Space
|
| 437 |
+
const user = userEvent.setup();
|
| 438 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 439 |
+
|
| 440 |
+
render(<SourceCard source={source} />);
|
| 441 |
+
|
| 442 |
+
const button = screen.getByRole('button');
|
| 443 |
+
button.focus();
|
| 444 |
+
|
| 445 |
+
// Press Space to expand
|
| 446 |
+
await user.keyboard(' ');
|
| 447 |
+
|
| 448 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 449 |
+
});
|
| 450 |
+
|
| 451 |
+
it('should have proper semantic structure with article element', () => {
|
| 452 |
+
// Test that card uses article element for semantic grouping
|
| 453 |
+
const source = createMockSource();
|
| 454 |
+
|
| 455 |
+
render(<SourceCard source={source} />);
|
| 456 |
+
|
| 457 |
+
const article = screen.getByRole('article');
|
| 458 |
+
expect(article).toBeInTheDocument();
|
| 459 |
+
});
|
| 460 |
+
|
| 461 |
+
it('should have header element for card header content', () => {
|
| 462 |
+
// Test semantic header structure
|
| 463 |
+
const source = createMockSource();
|
| 464 |
+
|
| 465 |
+
const { container } = render(<SourceCard source={source} />);
|
| 466 |
+
|
| 467 |
+
const header = container.querySelector('header');
|
| 468 |
+
expect(header).toBeInTheDocument();
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
it('should have descriptive aria-label on article element', () => {
|
| 472 |
+
// Test article has accessible name
|
| 473 |
+
const source = createMockSource({
|
| 474 |
+
headingPath: 'Thermal Comfort > PMV',
|
| 475 |
+
page: 15,
|
| 476 |
+
});
|
| 477 |
+
|
| 478 |
+
render(<SourceCard source={source} />);
|
| 479 |
+
|
| 480 |
+
const article = screen.getByRole('article');
|
| 481 |
+
expect(article).toHaveAttribute(
|
| 482 |
+
'aria-label',
|
| 483 |
+
'Source from Thermal Comfort > PMV, page 15'
|
| 484 |
+
);
|
| 485 |
+
});
|
| 486 |
+
|
| 487 |
+
it('should be keyboard focusable on expand button', () => {
|
| 488 |
+
// Test focus is possible
|
| 489 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 490 |
+
|
| 491 |
+
render(<SourceCard source={source} />);
|
| 492 |
+
|
| 493 |
+
const button = screen.getByRole('button');
|
| 494 |
+
button.focus();
|
| 495 |
+
|
| 496 |
+
expect(document.activeElement).toBe(button);
|
| 497 |
+
});
|
| 498 |
+
|
| 499 |
+
it('should have visible focus styles', () => {
|
| 500 |
+
// Test that focus ring classes are present
|
| 501 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 502 |
+
|
| 503 |
+
render(<SourceCard source={source} />);
|
| 504 |
+
|
| 505 |
+
const button = screen.getByRole('button');
|
| 506 |
+
expect(button.className).toMatch(/focus-visible:ring/);
|
| 507 |
+
});
|
| 508 |
+
});
|
| 509 |
+
|
| 510 |
+
// ==========================================================================
|
| 511 |
+
// Edge Cases Tests
|
| 512 |
+
// ==========================================================================
|
| 513 |
+
|
| 514 |
+
describe('edge cases', () => {
|
| 515 |
+
it('should handle very long text correctly', () => {
|
| 516 |
+
// Test with extremely long text
|
| 517 |
+
const veryLongText = 'A'.repeat(1000);
|
| 518 |
+
const source = createMockSource({ text: veryLongText });
|
| 519 |
+
|
| 520 |
+
render(<SourceCard source={source} />);
|
| 521 |
+
|
| 522 |
+
// Should truncate and show expand button
|
| 523 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 524 |
+
// Full text should not be visible
|
| 525 |
+
expect(screen.queryByText(veryLongText)).not.toBeInTheDocument();
|
| 526 |
+
});
|
| 527 |
+
|
| 528 |
+
it('should handle short text (no truncation needed)', () => {
|
| 529 |
+
// Test that short text shows in full without button
|
| 530 |
+
const source = createMockSource({ text: 'Short.' });
|
| 531 |
+
|
| 532 |
+
render(<SourceCard source={source} />);
|
| 533 |
+
|
| 534 |
+
expect(screen.getByText('Short.')).toBeInTheDocument();
|
| 535 |
+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
|
| 536 |
+
});
|
| 537 |
+
|
| 538 |
+
it('should handle text at exact truncation boundary', () => {
|
| 539 |
+
// Test text that is exactly at the truncation limit
|
| 540 |
+
const exactLengthText = 'X'.repeat(150);
|
| 541 |
+
const source = createMockSource({ text: exactLengthText });
|
| 542 |
+
|
| 543 |
+
render(<SourceCard source={source} />);
|
| 544 |
+
|
| 545 |
+
// Should show full text since it's exactly at the limit
|
| 546 |
+
expect(screen.getByText(exactLengthText)).toBeInTheDocument();
|
| 547 |
+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
|
| 548 |
+
});
|
| 549 |
+
|
| 550 |
+
it('should handle text just over truncation boundary', () => {
|
| 551 |
+
// Test text that is just over the truncation limit
|
| 552 |
+
const overLimitText = 'X'.repeat(151);
|
| 553 |
+
const source = createMockSource({ text: overLimitText });
|
| 554 |
+
|
| 555 |
+
render(<SourceCard source={source} />);
|
| 556 |
+
|
| 557 |
+
// Should be truncated
|
| 558 |
+
expect(screen.queryByText(overLimitText)).not.toBeInTheDocument();
|
| 559 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 560 |
+
});
|
| 561 |
+
|
| 562 |
+
it('should handle special characters in heading path', () => {
|
| 563 |
+
// Test special characters in heading path
|
| 564 |
+
const source = createMockSource({
|
| 565 |
+
headingPath: 'Chapter <1> & Section "2" > Sub\'section',
|
| 566 |
+
});
|
| 567 |
+
|
| 568 |
+
render(<SourceCard source={source} />);
|
| 569 |
+
|
| 570 |
+
expect(
|
| 571 |
+
screen.getByText('Chapter <1> & Section "2" > Sub\'section')
|
| 572 |
+
).toBeInTheDocument();
|
| 573 |
+
});
|
| 574 |
+
|
| 575 |
+
it('should handle special characters in text content', () => {
|
| 576 |
+
// Test special characters in text
|
| 577 |
+
const source = createMockSource({ text: SPECIAL_CHARS_TEXT });
|
| 578 |
+
|
| 579 |
+
render(<SourceCard source={source} />);
|
| 580 |
+
|
| 581 |
+
expect(screen.getByText(SPECIAL_CHARS_TEXT)).toBeInTheDocument();
|
| 582 |
+
});
|
| 583 |
+
|
| 584 |
+
it('should handle empty heading path', () => {
|
| 585 |
+
// Test empty heading path edge case
|
| 586 |
+
const source = createMockSource({ headingPath: '' });
|
| 587 |
+
|
| 588 |
+
render(<SourceCard source={source} />);
|
| 589 |
+
|
| 590 |
+
// Should still render without crashing
|
| 591 |
+
expect(screen.getByRole('article')).toBeInTheDocument();
|
| 592 |
+
});
|
| 593 |
+
|
| 594 |
+
it('should handle zero score correctly', () => {
|
| 595 |
+
// Test zero score display
|
| 596 |
+
const source = createMockSource({ score: 0 });
|
| 597 |
+
|
| 598 |
+
render(<SourceCard source={source} showScore />);
|
| 599 |
+
|
| 600 |
+
expect(screen.getByText('0%')).toBeInTheDocument();
|
| 601 |
+
});
|
| 602 |
+
|
| 603 |
+
it('should handle perfect score (1.0) correctly', () => {
|
| 604 |
+
// Test maximum score display
|
| 605 |
+
const source = createMockSource({ score: 1 });
|
| 606 |
+
|
| 607 |
+
render(<SourceCard source={source} showScore />);
|
| 608 |
+
|
| 609 |
+
expect(screen.getByText('100%')).toBeInTheDocument();
|
| 610 |
+
});
|
| 611 |
+
|
| 612 |
+
it('should round score to nearest integer', () => {
|
| 613 |
+
// Test score rounding
|
| 614 |
+
const source = createMockSource({ score: 0.876 });
|
| 615 |
+
|
| 616 |
+
render(<SourceCard source={source} showScore />);
|
| 617 |
+
|
| 618 |
+
expect(screen.getByText('88%')).toBeInTheDocument();
|
| 619 |
+
});
|
| 620 |
+
|
| 621 |
+
it('should handle page number 0', () => {
|
| 622 |
+
// Test edge case of page 0
|
| 623 |
+
const source = createMockSource({ page: 0 });
|
| 624 |
+
|
| 625 |
+
render(<SourceCard source={source} />);
|
| 626 |
+
|
| 627 |
+
expect(screen.getByText('Page 0')).toBeInTheDocument();
|
| 628 |
+
});
|
| 629 |
+
|
| 630 |
+
it('should handle very large page numbers', () => {
|
| 631 |
+
// Test large page number display
|
| 632 |
+
const source = createMockSource({ page: 99999 });
|
| 633 |
+
|
| 634 |
+
render(<SourceCard source={source} />);
|
| 635 |
+
|
| 636 |
+
expect(screen.getByText('Page 99999')).toBeInTheDocument();
|
| 637 |
+
});
|
| 638 |
+
|
| 639 |
+
it('should apply custom className to article element', () => {
|
| 640 |
+
// Test className prop is passed through
|
| 641 |
+
const source = createMockSource();
|
| 642 |
+
|
| 643 |
+
render(<SourceCard source={source} className="custom-class mt-4" />);
|
| 644 |
+
|
| 645 |
+
const article = screen.getByRole('article');
|
| 646 |
+
expect(article).toHaveClass('custom-class', 'mt-4');
|
| 647 |
+
});
|
| 648 |
+
|
| 649 |
+
it('should forward ref to article element', () => {
|
| 650 |
+
// Test ref forwarding
|
| 651 |
+
const source = createMockSource();
|
| 652 |
+
const ref = vi.fn();
|
| 653 |
+
|
| 654 |
+
render(<SourceCard source={source} ref={ref} />);
|
| 655 |
+
|
| 656 |
+
expect(ref).toHaveBeenCalled();
|
| 657 |
+
expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLElement);
|
| 658 |
+
});
|
| 659 |
+
|
| 660 |
+
it('should preserve whitespace in text content', () => {
|
| 661 |
+
// Test that whitespace is preserved
|
| 662 |
+
const textWithSpaces = 'Line 1\n\nLine 2\n Indented line';
|
| 663 |
+
const source = createMockSource({ text: textWithSpaces });
|
| 664 |
+
|
| 665 |
+
const { container } = render(
|
| 666 |
+
<SourceCard source={source} defaultExpanded />
|
| 667 |
+
);
|
| 668 |
+
|
| 669 |
+
// Check whitespace-pre-wrap class is applied
|
| 670 |
+
const textElement = container.querySelector('p');
|
| 671 |
+
expect(textElement).toHaveClass('whitespace-pre-wrap');
|
| 672 |
+
});
|
| 673 |
+
|
| 674 |
+
it('should handle Unicode characters correctly', () => {
|
| 675 |
+
// Test Unicode text content
|
| 676 |
+
const unicodeText =
|
| 677 |
+
'Temperature: 25\u00B0C, PMV: \u00B10.5, Humidity: 50%';
|
| 678 |
+
const source = createMockSource({ text: unicodeText });
|
| 679 |
+
|
| 680 |
+
render(<SourceCard source={source} />);
|
| 681 |
+
|
| 682 |
+
expect(screen.getByText(unicodeText)).toBeInTheDocument();
|
| 683 |
+
});
|
| 684 |
+
|
| 685 |
+
it('should handle multi-byte characters in heading path', () => {
|
| 686 |
+
// Test international characters
|
| 687 |
+
const source = createMockSource({
|
| 688 |
+
headingPath: '\u65E5\u672C\u8A9E > \u7B2C1\u7AE0',
|
| 689 |
+
});
|
| 690 |
+
|
| 691 |
+
render(<SourceCard source={source} />);
|
| 692 |
+
|
| 693 |
+
expect(
|
| 694 |
+
screen.getByText('\u65E5\u672C\u8A9E > \u7B2C1\u7AE0')
|
| 695 |
+
).toBeInTheDocument();
|
| 696 |
+
});
|
| 697 |
+
});
|
| 698 |
+
|
| 699 |
+
// ==========================================================================
|
| 700 |
+
// Props Tests
|
| 701 |
+
// ==========================================================================
|
| 702 |
+
|
| 703 |
+
describe('props', () => {
|
| 704 |
+
it('should use default truncateLength of 150', () => {
|
| 705 |
+
// Test default truncation behavior
|
| 706 |
+
const text149 = 'A'.repeat(149);
|
| 707 |
+
const text151 = 'A'.repeat(151);
|
| 708 |
+
|
| 709 |
+
const source149 = createMockSource({ text: text149 });
|
| 710 |
+
const source151 = createMockSource({ text: text151 });
|
| 711 |
+
|
| 712 |
+
const { rerender } = render(<SourceCard source={source149} />);
|
| 713 |
+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
|
| 714 |
+
|
| 715 |
+
rerender(<SourceCard source={source151} />);
|
| 716 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 717 |
+
});
|
| 718 |
+
|
| 719 |
+
it('should accept custom truncateLength', () => {
|
| 720 |
+
// Test custom truncation length
|
| 721 |
+
const text = 'A'.repeat(100);
|
| 722 |
+
const source = createMockSource({ text });
|
| 723 |
+
|
| 724 |
+
render(<SourceCard source={source} truncateLength={50} />);
|
| 725 |
+
|
| 726 |
+
// Should be truncated with custom length
|
| 727 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 728 |
+
});
|
| 729 |
+
|
| 730 |
+
it('should pass additional HTML attributes to article', () => {
|
| 731 |
+
// Test that other props are spread to article
|
| 732 |
+
const source = createMockSource();
|
| 733 |
+
|
| 734 |
+
render(
|
| 735 |
+
<SourceCard source={source} data-testid="custom-card" title="Test" />
|
| 736 |
+
);
|
| 737 |
+
|
| 738 |
+
const article = screen.getByTestId('custom-card');
|
| 739 |
+
expect(article).toHaveAttribute('title', 'Test');
|
| 740 |
+
});
|
| 741 |
+
});
|
| 742 |
+
|
| 743 |
+
// ==========================================================================
|
| 744 |
+
// Integration Tests
|
| 745 |
+
// ==========================================================================
|
| 746 |
+
|
| 747 |
+
describe('integration', () => {
|
| 748 |
+
it('should render complete source card with all elements', () => {
|
| 749 |
+
// Test full rendering with all features
|
| 750 |
+
const source = createMockSource({
|
| 751 |
+
id: 'complete-source',
|
| 752 |
+
headingPath: 'Documentation > API Reference > Functions',
|
| 753 |
+
page: 42,
|
| 754 |
+
text: LONG_TEXT,
|
| 755 |
+
score: 0.92,
|
| 756 |
+
});
|
| 757 |
+
|
| 758 |
+
render(<SourceCard source={source} showScore defaultExpanded />);
|
| 759 |
+
|
| 760 |
+
// All elements should be present
|
| 761 |
+
expect(
|
| 762 |
+
screen.getByText('Documentation > API Reference > Functions')
|
| 763 |
+
).toBeInTheDocument();
|
| 764 |
+
expect(screen.getByText('Page 42')).toBeInTheDocument();
|
| 765 |
+
expect(screen.getByText('92%')).toBeInTheDocument();
|
| 766 |
+
expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
|
| 767 |
+
expect(screen.getByText('Show less')).toBeInTheDocument();
|
| 768 |
+
expect(screen.getByTestId('file-icon')).toBeInTheDocument();
|
| 769 |
+
expect(screen.getByTestId('chevron-up-icon')).toBeInTheDocument();
|
| 770 |
+
});
|
| 771 |
+
|
| 772 |
+
it('should maintain state across multiple expand/collapse cycles', async () => {
|
| 773 |
+
// Test state persistence
|
| 774 |
+
const user = userEvent.setup();
|
| 775 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 776 |
+
|
| 777 |
+
render(<SourceCard source={source} />);
|
| 778 |
+
|
| 779 |
+
// Initial state
|
| 780 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 781 |
+
|
| 782 |
+
// Cycle through states
|
| 783 |
+
for (let i = 0; i < 3; i++) {
|
| 784 |
+
await user.click(screen.getByRole('button'));
|
| 785 |
+
expect(screen.getByText('Show less')).toBeInTheDocument();
|
| 786 |
+
|
| 787 |
+
await user.click(screen.getByRole('button'));
|
| 788 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 789 |
+
}
|
| 790 |
+
});
|
| 791 |
+
|
| 792 |
+
it('should work with fireEvent as alternative to userEvent', () => {
|
| 793 |
+
// Test with fireEvent for synchronous testing
|
| 794 |
+
const source = createMockSource({ text: LONG_TEXT });
|
| 795 |
+
|
| 796 |
+
render(<SourceCard source={source} />);
|
| 797 |
+
|
| 798 |
+
expect(screen.getByText('Show more')).toBeInTheDocument();
|
| 799 |
+
|
| 800 |
+
fireEvent.click(screen.getByRole('button'));
|
| 801 |
+
|
| 802 |
+
expect(screen.getByText('Show less')).toBeInTheDocument();
|
| 803 |
+
});
|
| 804 |
+
});
|
| 805 |
+
});
|
frontend/src/components/chat/__tests__/source-citations.test.tsx
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Unit Tests for SourceCitations Component
|
| 3 |
+
*
|
| 4 |
+
* Comprehensive test coverage for the collapsible source citations container.
|
| 5 |
+
* Tests rendering states, expand/collapse animations, accessibility features,
|
| 6 |
+
* and integration with SourceCard components.
|
| 7 |
+
*
|
| 8 |
+
* @module components/chat/__tests__/source-citations.test
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
| 12 |
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
| 13 |
+
import userEvent from '@testing-library/user-event';
|
| 14 |
+
import { SourceCitations } from '../source-citations';
|
| 15 |
+
import type { Source } from '@/types';
|
| 16 |
+
|
| 17 |
+
// ============================================================================
|
| 18 |
+
// Test Fixtures
|
| 19 |
+
// ============================================================================
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Create a mock source object for testing.
|
| 23 |
+
*
|
| 24 |
+
* @param overrides - Properties to override in the default source
|
| 25 |
+
* @returns Mock source object with sensible defaults
|
| 26 |
+
*/
|
| 27 |
+
function createMockSource(overrides: Partial<Source> = {}): Source {
|
| 28 |
+
return {
|
| 29 |
+
id: `source-${Math.random().toString(36).substring(7)}`,
|
| 30 |
+
headingPath: 'Chapter 1 > Section 2',
|
| 31 |
+
page: 10,
|
| 32 |
+
text: 'Sample source text for testing purposes.',
|
| 33 |
+
score: 0.8,
|
| 34 |
+
...overrides,
|
| 35 |
+
};
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Create an array of mock sources for testing.
|
| 40 |
+
*
|
| 41 |
+
* @param count - Number of sources to create
|
| 42 |
+
* @returns Array of mock source objects
|
| 43 |
+
*/
|
| 44 |
+
function createMockSources(count: number): Source[] {
|
| 45 |
+
return Array.from({ length: count }, (_, index) =>
|
| 46 |
+
createMockSource({
|
| 47 |
+
id: `source-${index + 1}`,
|
| 48 |
+
headingPath: `Chapter ${index + 1} > Section ${index + 1}`,
|
| 49 |
+
page: (index + 1) * 10,
|
| 50 |
+
text: `This is the text content for source ${index + 1}.`,
|
| 51 |
+
score: 0.9 - index * 0.1,
|
| 52 |
+
})
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// ============================================================================
|
| 57 |
+
// Test Suite
|
| 58 |
+
// ============================================================================
|
| 59 |
+
|
| 60 |
+
describe('SourceCitations', () => {
|
| 61 |
+
// ==========================================================================
|
| 62 |
+
// Rendering Tests
|
| 63 |
+
// ==========================================================================
|
| 64 |
+
|
| 65 |
+
describe('rendering', () => {
|
| 66 |
+
it('should render correct source count', () => {
|
| 67 |
+
// Test that source count is displayed correctly
|
| 68 |
+
const sources = createMockSources(5);
|
| 69 |
+
|
| 70 |
+
render(<SourceCitations sources={sources} />);
|
| 71 |
+
|
| 72 |
+
expect(screen.getByText('5 Sources')).toBeInTheDocument();
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
it('should render singular "Source" when count is 1', () => {
|
| 76 |
+
// Test singular grammar
|
| 77 |
+
const sources = createMockSources(1);
|
| 78 |
+
|
| 79 |
+
render(<SourceCitations sources={sources} />);
|
| 80 |
+
|
| 81 |
+
expect(screen.getByText('1 Source')).toBeInTheDocument();
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
it('should render plural "Sources" when count > 1', () => {
|
| 85 |
+
// Test plural grammar
|
| 86 |
+
const sources = createMockSources(2);
|
| 87 |
+
|
| 88 |
+
render(<SourceCitations sources={sources} />);
|
| 89 |
+
|
| 90 |
+
expect(screen.getByText('2 Sources')).toBeInTheDocument();
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
it('should render plural "Sources" for large count', () => {
|
| 94 |
+
// Test plural grammar with larger numbers
|
| 95 |
+
const sources = createMockSources(10);
|
| 96 |
+
|
| 97 |
+
render(<SourceCitations sources={sources} />);
|
| 98 |
+
|
| 99 |
+
expect(screen.getByText('10 Sources')).toBeInTheDocument();
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
it('should not render when sources array is empty', () => {
|
| 103 |
+
// Test that component returns null for empty sources
|
| 104 |
+
const { container } = render(<SourceCitations sources={[]} />);
|
| 105 |
+
|
| 106 |
+
// Container should be empty (only the root div from render)
|
| 107 |
+
expect(container.firstChild).toBeNull();
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it('should not render when sources is an empty array', () => {
|
| 111 |
+
// Explicitly test empty array
|
| 112 |
+
const sources: Source[] = [];
|
| 113 |
+
|
| 114 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 115 |
+
|
| 116 |
+
expect(container.firstChild).toBeNull();
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
it('should render all SourceCard components when expanded', () => {
|
| 120 |
+
// Test that all source cards are rendered
|
| 121 |
+
const sources = createMockSources(3);
|
| 122 |
+
|
| 123 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 124 |
+
|
| 125 |
+
// Each source should have its heading path rendered
|
| 126 |
+
expect(screen.getByText('Chapter 1 > Section 1')).toBeInTheDocument();
|
| 127 |
+
expect(screen.getByText('Chapter 2 > Section 2')).toBeInTheDocument();
|
| 128 |
+
expect(screen.getByText('Chapter 3 > Section 3')).toBeInTheDocument();
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
it('should render toggle button in header', () => {
|
| 132 |
+
// Test toggle button presence
|
| 133 |
+
const sources = createMockSources(1);
|
| 134 |
+
|
| 135 |
+
render(<SourceCitations sources={sources} />);
|
| 136 |
+
|
| 137 |
+
expect(screen.getByRole('button')).toBeInTheDocument();
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
it('should render source count in toggle button', () => {
|
| 141 |
+
// Test source count in button
|
| 142 |
+
const sources = createMockSources(3);
|
| 143 |
+
|
| 144 |
+
render(<SourceCitations sources={sources} />);
|
| 145 |
+
|
| 146 |
+
const button = screen.getByRole('button');
|
| 147 |
+
expect(button).toHaveTextContent('3 Sources');
|
| 148 |
+
});
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
// ==========================================================================
|
| 152 |
+
// Expand/Collapse Tests
|
| 153 |
+
// ==========================================================================
|
| 154 |
+
|
| 155 |
+
describe('expand/collapse', () => {
|
| 156 |
+
it('should start collapsed by default', () => {
|
| 157 |
+
// Test default collapsed state
|
| 158 |
+
const sources = createMockSources(3);
|
| 159 |
+
|
| 160 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 161 |
+
|
| 162 |
+
// Content region should be hidden (use container query since aria-hidden elements are not accessible)
|
| 163 |
+
const region = container.querySelector('[role="region"]');
|
| 164 |
+
expect(region).toHaveAttribute('aria-hidden', 'true');
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
it('should start expanded when defaultExpanded is true', () => {
|
| 168 |
+
// Test defaultExpanded prop
|
| 169 |
+
const sources = createMockSources(3);
|
| 170 |
+
|
| 171 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 172 |
+
|
| 173 |
+
// Content region should be visible (accessible when expanded)
|
| 174 |
+
const region = screen.getByRole('region');
|
| 175 |
+
expect(region).toHaveAttribute('aria-hidden', 'false');
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
it('should toggle on button click', async () => {
|
| 179 |
+
// Test click interaction
|
| 180 |
+
const user = userEvent.setup();
|
| 181 |
+
const sources = createMockSources(2);
|
| 182 |
+
|
| 183 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 184 |
+
|
| 185 |
+
const button = screen.getByRole('button');
|
| 186 |
+
const region = container.querySelector('[role="region"]');
|
| 187 |
+
|
| 188 |
+
// Initially collapsed
|
| 189 |
+
expect(region).toHaveAttribute('aria-hidden', 'true');
|
| 190 |
+
|
| 191 |
+
// Click to expand
|
| 192 |
+
await user.click(button);
|
| 193 |
+
expect(region).toHaveAttribute('aria-hidden', 'false');
|
| 194 |
+
|
| 195 |
+
// Click to collapse
|
| 196 |
+
await user.click(button);
|
| 197 |
+
expect(region).toHaveAttribute('aria-hidden', 'true');
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
it('should apply rotation class to chevron when expanded', () => {
|
| 201 |
+
// Test chevron rotation class - check the button contains rotated element
|
| 202 |
+
const sources = createMockSources(1);
|
| 203 |
+
|
| 204 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 205 |
+
|
| 206 |
+
const button = screen.getByRole('button');
|
| 207 |
+
// The rotated element should have rotate-180 class when expanded
|
| 208 |
+
const rotatedElement = button.querySelector('.rotate-180');
|
| 209 |
+
expect(rotatedElement).toBeInTheDocument();
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
it('should not have rotation class when collapsed', () => {
|
| 213 |
+
// Test chevron not rotated when collapsed
|
| 214 |
+
const sources = createMockSources(1);
|
| 215 |
+
|
| 216 |
+
render(<SourceCitations sources={sources} />);
|
| 217 |
+
|
| 218 |
+
const button = screen.getByRole('button');
|
| 219 |
+
// Should not have rotate-180 when collapsed
|
| 220 |
+
const rotatedElement = button.querySelector('.rotate-180');
|
| 221 |
+
expect(rotatedElement).toBeNull();
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
it('should apply grid-rows-[0fr] when collapsed', () => {
|
| 225 |
+
// Test CSS grid animation class for collapsed state
|
| 226 |
+
const sources = createMockSources(1);
|
| 227 |
+
|
| 228 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 229 |
+
|
| 230 |
+
const region = container.querySelector('[role="region"]');
|
| 231 |
+
expect(region?.className).toMatch(/grid-rows-\[0fr\]/);
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
it('should apply grid-rows-[1fr] when expanded', () => {
|
| 235 |
+
// Test CSS grid animation class for expanded state
|
| 236 |
+
const sources = createMockSources(1);
|
| 237 |
+
|
| 238 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 239 |
+
|
| 240 |
+
const region = screen.getByRole('region');
|
| 241 |
+
expect(region.className).toMatch(/grid-rows-\[1fr\]/);
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
it('should hide content when collapsed', () => {
|
| 245 |
+
// Test that content is hidden in collapsed state
|
| 246 |
+
const sources = createMockSources(1);
|
| 247 |
+
|
| 248 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 249 |
+
|
| 250 |
+
const region = container.querySelector('[role="region"]');
|
| 251 |
+
expect(region).toHaveAttribute('aria-hidden', 'true');
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
it('should show content when expanded', () => {
|
| 255 |
+
// Test that content is visible in expanded state
|
| 256 |
+
const sources = createMockSources(1);
|
| 257 |
+
|
| 258 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 259 |
+
|
| 260 |
+
const region = screen.getByRole('region');
|
| 261 |
+
expect(region).toHaveAttribute('aria-hidden', 'false');
|
| 262 |
+
// Source text should be visible
|
| 263 |
+
expect(
|
| 264 |
+
screen.getByText('This is the text content for source 1.')
|
| 265 |
+
).toBeInTheDocument();
|
| 266 |
+
});
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
// ==========================================================================
|
| 270 |
+
// Accessibility Tests
|
| 271 |
+
// ==========================================================================
|
| 272 |
+
|
| 273 |
+
describe('accessibility', () => {
|
| 274 |
+
it('should have correct aria-expanded attribute when collapsed', () => {
|
| 275 |
+
// Test aria-expanded in collapsed state
|
| 276 |
+
const sources = createMockSources(1);
|
| 277 |
+
|
| 278 |
+
render(<SourceCitations sources={sources} />);
|
| 279 |
+
|
| 280 |
+
const button = screen.getByRole('button');
|
| 281 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
it('should have correct aria-expanded attribute when expanded', () => {
|
| 285 |
+
// Test aria-expanded in expanded state
|
| 286 |
+
const sources = createMockSources(1);
|
| 287 |
+
|
| 288 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 289 |
+
|
| 290 |
+
const button = screen.getByRole('button');
|
| 291 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
it('should update aria-expanded on toggle', async () => {
|
| 295 |
+
// Test dynamic aria-expanded updates
|
| 296 |
+
const user = userEvent.setup();
|
| 297 |
+
const sources = createMockSources(1);
|
| 298 |
+
|
| 299 |
+
render(<SourceCitations sources={sources} />);
|
| 300 |
+
|
| 301 |
+
const button = screen.getByRole('button');
|
| 302 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 303 |
+
|
| 304 |
+
await user.click(button);
|
| 305 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 306 |
+
|
| 307 |
+
await user.click(button);
|
| 308 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
it('should have aria-controls linking to content region', () => {
|
| 312 |
+
// Test aria-controls relationship
|
| 313 |
+
const sources = createMockSources(1);
|
| 314 |
+
|
| 315 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 316 |
+
|
| 317 |
+
const button = screen.getByRole('button');
|
| 318 |
+
const region = container.querySelector('[role="region"]');
|
| 319 |
+
|
| 320 |
+
const controlsId = button.getAttribute('aria-controls');
|
| 321 |
+
expect(controlsId).toBeTruthy();
|
| 322 |
+
expect(region).toHaveAttribute('id', controlsId);
|
| 323 |
+
});
|
| 324 |
+
|
| 325 |
+
it('should have content region with role="region"', () => {
|
| 326 |
+
// Test region role on content
|
| 327 |
+
const sources = createMockSources(1);
|
| 328 |
+
|
| 329 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 330 |
+
|
| 331 |
+
const region = container.querySelector('[role="region"]');
|
| 332 |
+
expect(region).toBeInTheDocument();
|
| 333 |
+
});
|
| 334 |
+
|
| 335 |
+
it('should have region with aria-labelledby pointing to button', () => {
|
| 336 |
+
// Test aria-labelledby relationship
|
| 337 |
+
const sources = createMockSources(1);
|
| 338 |
+
|
| 339 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 340 |
+
|
| 341 |
+
const button = screen.getByRole('button');
|
| 342 |
+
const region = container.querySelector('[role="region"]');
|
| 343 |
+
|
| 344 |
+
const buttonId = button.getAttribute('id');
|
| 345 |
+
expect(buttonId).toBeTruthy();
|
| 346 |
+
expect(region).toHaveAttribute('aria-labelledby', buttonId);
|
| 347 |
+
});
|
| 348 |
+
|
| 349 |
+
it('should have descriptive aria-label on toggle button', () => {
|
| 350 |
+
// Test button has accessible name
|
| 351 |
+
const sources = createMockSources(3);
|
| 352 |
+
|
| 353 |
+
render(<SourceCitations sources={sources} />);
|
| 354 |
+
|
| 355 |
+
const button = screen.getByRole('button');
|
| 356 |
+
const ariaLabel = button.getAttribute('aria-label');
|
| 357 |
+
|
| 358 |
+
expect(ariaLabel).toMatch(/3 Sources/);
|
| 359 |
+
expect(ariaLabel).toMatch(/Expand/);
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
+
it('should update aria-label when expanded', () => {
|
| 363 |
+
// Test aria-label changes based on state
|
| 364 |
+
const sources = createMockSources(3);
|
| 365 |
+
|
| 366 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 367 |
+
|
| 368 |
+
const button = screen.getByRole('button');
|
| 369 |
+
const ariaLabel = button.getAttribute('aria-label');
|
| 370 |
+
|
| 371 |
+
expect(ariaLabel).toMatch(/3 Sources/);
|
| 372 |
+
expect(ariaLabel).toMatch(/Collapse/);
|
| 373 |
+
});
|
| 374 |
+
|
| 375 |
+
it('should respond to Enter key for toggle', async () => {
|
| 376 |
+
// Test keyboard navigation with Enter
|
| 377 |
+
const user = userEvent.setup();
|
| 378 |
+
const sources = createMockSources(1);
|
| 379 |
+
|
| 380 |
+
render(<SourceCitations sources={sources} />);
|
| 381 |
+
|
| 382 |
+
const button = screen.getByRole('button');
|
| 383 |
+
button.focus();
|
| 384 |
+
|
| 385 |
+
await user.keyboard('{Enter}');
|
| 386 |
+
|
| 387 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
it('should respond to Space key for toggle', async () => {
|
| 391 |
+
// Test keyboard navigation with Space
|
| 392 |
+
const user = userEvent.setup();
|
| 393 |
+
const sources = createMockSources(1);
|
| 394 |
+
|
| 395 |
+
render(<SourceCitations sources={sources} />);
|
| 396 |
+
|
| 397 |
+
const button = screen.getByRole('button');
|
| 398 |
+
button.focus();
|
| 399 |
+
|
| 400 |
+
await user.keyboard(' ');
|
| 401 |
+
|
| 402 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
it('should be keyboard focusable on toggle button', () => {
|
| 406 |
+
// Test focus is possible on button
|
| 407 |
+
const sources = createMockSources(1);
|
| 408 |
+
|
| 409 |
+
render(<SourceCitations sources={sources} />);
|
| 410 |
+
|
| 411 |
+
const button = screen.getByRole('button');
|
| 412 |
+
button.focus();
|
| 413 |
+
|
| 414 |
+
expect(document.activeElement).toBe(button);
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
it('should have visible focus styles', () => {
|
| 418 |
+
// Test focus ring classes are present
|
| 419 |
+
const sources = createMockSources(1);
|
| 420 |
+
|
| 421 |
+
render(<SourceCitations sources={sources} />);
|
| 422 |
+
|
| 423 |
+
const button = screen.getByRole('button');
|
| 424 |
+
expect(button.className).toMatch(/focus-visible:ring/);
|
| 425 |
+
});
|
| 426 |
+
|
| 427 |
+
it('should have unique IDs for multiple instances', () => {
|
| 428 |
+
// Test that multiple instances have unique IDs (using useId)
|
| 429 |
+
const sources1 = createMockSources(1);
|
| 430 |
+
const sources2 = createMockSources(2);
|
| 431 |
+
|
| 432 |
+
const { container } = render(
|
| 433 |
+
<>
|
| 434 |
+
<SourceCitations sources={sources1} />
|
| 435 |
+
<SourceCitations sources={sources2} />
|
| 436 |
+
</>
|
| 437 |
+
);
|
| 438 |
+
|
| 439 |
+
const buttons = screen.getAllByRole('button');
|
| 440 |
+
const regions = container.querySelectorAll('[role="region"]');
|
| 441 |
+
|
| 442 |
+
// IDs should be unique
|
| 443 |
+
expect(buttons[0].id).not.toBe(buttons[1].id);
|
| 444 |
+
expect(regions[0].id).not.toBe(regions[1].id);
|
| 445 |
+
});
|
| 446 |
+
});
|
| 447 |
+
|
| 448 |
+
// ==========================================================================
|
| 449 |
+
// Props Tests
|
| 450 |
+
// ==========================================================================
|
| 451 |
+
|
| 452 |
+
describe('props', () => {
|
| 453 |
+
it('should pass showScores prop to SourceCards', () => {
|
| 454 |
+
// Test that showScores propagates to child components
|
| 455 |
+
const sources = createMockSources(1);
|
| 456 |
+
sources[0].score = 0.95;
|
| 457 |
+
|
| 458 |
+
render(<SourceCitations sources={sources} showScores defaultExpanded />);
|
| 459 |
+
|
| 460 |
+
// Score should be visible on the source card
|
| 461 |
+
expect(screen.getByText('95%')).toBeInTheDocument();
|
| 462 |
+
});
|
| 463 |
+
|
| 464 |
+
it('should not show scores when showScores is false', () => {
|
| 465 |
+
// Test default behavior (showScores = false)
|
| 466 |
+
const sources = createMockSources(1);
|
| 467 |
+
sources[0].score = 0.95;
|
| 468 |
+
|
| 469 |
+
render(
|
| 470 |
+
<SourceCitations sources={sources} showScores={false} defaultExpanded />
|
| 471 |
+
);
|
| 472 |
+
|
| 473 |
+
// Score should not be visible
|
| 474 |
+
expect(screen.queryByText('95%')).not.toBeInTheDocument();
|
| 475 |
+
});
|
| 476 |
+
|
| 477 |
+
it('should not show scores by default', () => {
|
| 478 |
+
// Test that showScores defaults to false
|
| 479 |
+
const sources = createMockSources(1);
|
| 480 |
+
sources[0].score = 0.85;
|
| 481 |
+
|
| 482 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 483 |
+
|
| 484 |
+
expect(screen.queryByText('85%')).not.toBeInTheDocument();
|
| 485 |
+
});
|
| 486 |
+
|
| 487 |
+
it('should apply className prop to container', () => {
|
| 488 |
+
// Test className is applied
|
| 489 |
+
const sources = createMockSources(1);
|
| 490 |
+
|
| 491 |
+
const { container } = render(
|
| 492 |
+
<SourceCitations sources={sources} className="custom-class mt-8" />
|
| 493 |
+
);
|
| 494 |
+
|
| 495 |
+
const mainContainer = container.firstChild as HTMLElement;
|
| 496 |
+
expect(mainContainer).toHaveClass('custom-class', 'mt-8');
|
| 497 |
+
});
|
| 498 |
+
|
| 499 |
+
it('should apply defaultExpanded prop correctly', () => {
|
| 500 |
+
// Test defaultExpanded initial state
|
| 501 |
+
// Note: defaultExpanded only sets the initial state, it does not update when changed
|
| 502 |
+
// We need to test two separate renders
|
| 503 |
+
const sources = createMockSources(1);
|
| 504 |
+
|
| 505 |
+
// Test collapsed by default
|
| 506 |
+
const { unmount } = render(<SourceCitations sources={sources} />);
|
| 507 |
+
let button = screen.getByRole('button');
|
| 508 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 509 |
+
unmount();
|
| 510 |
+
|
| 511 |
+
// Test expanded by default
|
| 512 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 513 |
+
button = screen.getByRole('button');
|
| 514 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 515 |
+
});
|
| 516 |
+
|
| 517 |
+
it('should pass additional HTML attributes to container', () => {
|
| 518 |
+
// Test that other props spread to container div
|
| 519 |
+
const sources = createMockSources(1);
|
| 520 |
+
|
| 521 |
+
render(
|
| 522 |
+
<SourceCitations
|
| 523 |
+
sources={sources}
|
| 524 |
+
data-testid="citations-container"
|
| 525 |
+
title="Source citations"
|
| 526 |
+
/>
|
| 527 |
+
);
|
| 528 |
+
|
| 529 |
+
const container = screen.getByTestId('citations-container');
|
| 530 |
+
expect(container).toHaveAttribute('title', 'Source citations');
|
| 531 |
+
});
|
| 532 |
+
});
|
| 533 |
+
|
| 534 |
+
// ==========================================================================
|
| 535 |
+
// Edge Cases Tests
|
| 536 |
+
// ==========================================================================
|
| 537 |
+
|
| 538 |
+
describe('edge cases', () => {
|
| 539 |
+
it('should handle single source correctly', () => {
|
| 540 |
+
// Test with exactly one source
|
| 541 |
+
const sources = createMockSources(1);
|
| 542 |
+
|
| 543 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 544 |
+
|
| 545 |
+
expect(screen.getByText('1 Source')).toBeInTheDocument();
|
| 546 |
+
expect(screen.getByText('Chapter 1 > Section 1')).toBeInTheDocument();
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
it('should handle many sources correctly', () => {
|
| 550 |
+
// Test with large number of sources
|
| 551 |
+
const sources = createMockSources(20);
|
| 552 |
+
|
| 553 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 554 |
+
|
| 555 |
+
expect(screen.getByText('20 Sources')).toBeInTheDocument();
|
| 556 |
+
});
|
| 557 |
+
|
| 558 |
+
it('should preserve source order', () => {
|
| 559 |
+
// Test that sources are rendered in provided order
|
| 560 |
+
const sources = [
|
| 561 |
+
createMockSource({ id: '1', headingPath: 'First Source' }),
|
| 562 |
+
createMockSource({ id: '2', headingPath: 'Second Source' }),
|
| 563 |
+
createMockSource({ id: '3', headingPath: 'Third Source' }),
|
| 564 |
+
];
|
| 565 |
+
|
| 566 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 567 |
+
|
| 568 |
+
const articles = screen.getAllByRole('article');
|
| 569 |
+
|
| 570 |
+
// Check order is preserved
|
| 571 |
+
expect(articles[0]).toHaveAttribute(
|
| 572 |
+
'aria-label',
|
| 573 |
+
expect.stringContaining('First Source')
|
| 574 |
+
);
|
| 575 |
+
expect(articles[1]).toHaveAttribute(
|
| 576 |
+
'aria-label',
|
| 577 |
+
expect.stringContaining('Second Source')
|
| 578 |
+
);
|
| 579 |
+
expect(articles[2]).toHaveAttribute(
|
| 580 |
+
'aria-label',
|
| 581 |
+
expect.stringContaining('Third Source')
|
| 582 |
+
);
|
| 583 |
+
});
|
| 584 |
+
|
| 585 |
+
it('should handle sources with missing optional fields', () => {
|
| 586 |
+
// Test sources without score
|
| 587 |
+
const sources = [
|
| 588 |
+
createMockSource({ id: '1', score: undefined }),
|
| 589 |
+
createMockSource({ id: '2', score: undefined }),
|
| 590 |
+
];
|
| 591 |
+
|
| 592 |
+
render(
|
| 593 |
+
<SourceCitations sources={sources} showScores defaultExpanded />
|
| 594 |
+
);
|
| 595 |
+
|
| 596 |
+
// Should render without crashing
|
| 597 |
+
expect(screen.getByText('2 Sources')).toBeInTheDocument();
|
| 598 |
+
// No percentages should be visible
|
| 599 |
+
expect(screen.queryByText(/%/)).not.toBeInTheDocument();
|
| 600 |
+
});
|
| 601 |
+
|
| 602 |
+
it('should handle sources with special characters', () => {
|
| 603 |
+
// Test sources with special characters in text
|
| 604 |
+
const sources = [
|
| 605 |
+
createMockSource({
|
| 606 |
+
id: '1',
|
| 607 |
+
headingPath: 'Chapter <1> & Section "2"',
|
| 608 |
+
text: 'Formula: T = (a + b) / 2 where a > 0 & b < 100',
|
| 609 |
+
}),
|
| 610 |
+
];
|
| 611 |
+
|
| 612 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 613 |
+
|
| 614 |
+
expect(screen.getByText('Chapter <1> & Section "2"')).toBeInTheDocument();
|
| 615 |
+
});
|
| 616 |
+
|
| 617 |
+
it('should apply staggered animation delays to source cards', () => {
|
| 618 |
+
// Test animation delay styles
|
| 619 |
+
const sources = createMockSources(3);
|
| 620 |
+
|
| 621 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 622 |
+
|
| 623 |
+
const articles = screen.getAllByRole('article');
|
| 624 |
+
|
| 625 |
+
// Check animation delays are applied (0ms, 50ms, 100ms)
|
| 626 |
+
expect(articles[0]).toHaveStyle({ animationDelay: '0ms' });
|
| 627 |
+
expect(articles[1]).toHaveStyle({ animationDelay: '50ms' });
|
| 628 |
+
expect(articles[2]).toHaveStyle({ animationDelay: '100ms' });
|
| 629 |
+
});
|
| 630 |
+
|
| 631 |
+
it('should cap animation delay at 200ms', () => {
|
| 632 |
+
// Test animation delay cap for many sources
|
| 633 |
+
const sources = createMockSources(10);
|
| 634 |
+
|
| 635 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 636 |
+
|
| 637 |
+
const articles = screen.getAllByRole('article');
|
| 638 |
+
|
| 639 |
+
// Fifth and later sources should have 200ms delay (index 4+ = 200ms)
|
| 640 |
+
expect(articles[4]).toHaveStyle({ animationDelay: '200ms' });
|
| 641 |
+
expect(articles[9]).toHaveStyle({ animationDelay: '200ms' });
|
| 642 |
+
});
|
| 643 |
+
|
| 644 |
+
it('should not apply animation styles when collapsed', () => {
|
| 645 |
+
// Test that animation is disabled when collapsed
|
| 646 |
+
const sources = createMockSources(3);
|
| 647 |
+
|
| 648 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 649 |
+
|
| 650 |
+
// When collapsed, articles are inside aria-hidden region so we need to query directly
|
| 651 |
+
const articles = container.querySelectorAll('article');
|
| 652 |
+
|
| 653 |
+
// Animation should be none when collapsed
|
| 654 |
+
articles.forEach((article) => {
|
| 655 |
+
expect(article.className).toMatch(/animate-none/);
|
| 656 |
+
});
|
| 657 |
+
});
|
| 658 |
+
|
| 659 |
+
it('should handle undefined sources gracefully', () => {
|
| 660 |
+
// Test with undefined-like values (empty array)
|
| 661 |
+
// Note: TypeScript prevents passing undefined, but empty array is allowed
|
| 662 |
+
const { container } = render(<SourceCitations sources={[]} />);
|
| 663 |
+
|
| 664 |
+
expect(container.firstChild).toBeNull();
|
| 665 |
+
});
|
| 666 |
+
|
| 667 |
+
it('should use unique keys for source cards', () => {
|
| 668 |
+
// Test that source IDs are used as keys (no key warnings in console)
|
| 669 |
+
const sources = createMockSources(3);
|
| 670 |
+
|
| 671 |
+
// This should not produce any React key warnings
|
| 672 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 673 |
+
|
| 674 |
+
expect(screen.getAllByRole('article')).toHaveLength(3);
|
| 675 |
+
});
|
| 676 |
+
});
|
| 677 |
+
|
| 678 |
+
// ==========================================================================
|
| 679 |
+
// Integration Tests
|
| 680 |
+
// ==========================================================================
|
| 681 |
+
|
| 682 |
+
describe('integration', () => {
|
| 683 |
+
it('should render complete component with all features enabled', () => {
|
| 684 |
+
// Test full rendering with all props
|
| 685 |
+
const sources = createMockSources(3);
|
| 686 |
+
|
| 687 |
+
render(
|
| 688 |
+
<SourceCitations
|
| 689 |
+
sources={sources}
|
| 690 |
+
defaultExpanded
|
| 691 |
+
showScores
|
| 692 |
+
className="test-class"
|
| 693 |
+
/>
|
| 694 |
+
);
|
| 695 |
+
|
| 696 |
+
// Count label
|
| 697 |
+
expect(screen.getByText('3 Sources')).toBeInTheDocument();
|
| 698 |
+
|
| 699 |
+
// All source cards
|
| 700 |
+
expect(screen.getAllByRole('article')).toHaveLength(3);
|
| 701 |
+
|
| 702 |
+
// Scores should be visible
|
| 703 |
+
expect(screen.getByText('90%')).toBeInTheDocument();
|
| 704 |
+
});
|
| 705 |
+
|
| 706 |
+
it('should maintain state across multiple expand/collapse cycles', async () => {
|
| 707 |
+
// Test state persistence
|
| 708 |
+
const user = userEvent.setup();
|
| 709 |
+
const sources = createMockSources(2);
|
| 710 |
+
|
| 711 |
+
render(<SourceCitations sources={sources} />);
|
| 712 |
+
|
| 713 |
+
const button = screen.getByRole('button');
|
| 714 |
+
|
| 715 |
+
// Cycle through states multiple times
|
| 716 |
+
for (let i = 0; i < 3; i++) {
|
| 717 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 718 |
+
|
| 719 |
+
await user.click(button);
|
| 720 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 721 |
+
|
| 722 |
+
await user.click(button);
|
| 723 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 724 |
+
}
|
| 725 |
+
});
|
| 726 |
+
|
| 727 |
+
it('should work with fireEvent as alternative to userEvent', () => {
|
| 728 |
+
// Test with synchronous fireEvent
|
| 729 |
+
const sources = createMockSources(1);
|
| 730 |
+
|
| 731 |
+
render(<SourceCitations sources={sources} />);
|
| 732 |
+
|
| 733 |
+
const button = screen.getByRole('button');
|
| 734 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 735 |
+
|
| 736 |
+
fireEvent.click(button);
|
| 737 |
+
|
| 738 |
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
| 739 |
+
});
|
| 740 |
+
|
| 741 |
+
it('should correctly toggle aria-hidden on content region', async () => {
|
| 742 |
+
// Test aria-hidden updates on content
|
| 743 |
+
const user = userEvent.setup();
|
| 744 |
+
const sources = createMockSources(1);
|
| 745 |
+
|
| 746 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 747 |
+
|
| 748 |
+
const region = container.querySelector('[role="region"]');
|
| 749 |
+
|
| 750 |
+
expect(region).toHaveAttribute('aria-hidden', 'true');
|
| 751 |
+
|
| 752 |
+
await user.click(screen.getByRole('button'));
|
| 753 |
+
expect(region).toHaveAttribute('aria-hidden', 'false');
|
| 754 |
+
|
| 755 |
+
await user.click(screen.getByRole('button'));
|
| 756 |
+
expect(region).toHaveAttribute('aria-hidden', 'true');
|
| 757 |
+
});
|
| 758 |
+
|
| 759 |
+
it('should render source cards that are individually expandable', async () => {
|
| 760 |
+
// Test that nested SourceCards work correctly
|
| 761 |
+
const sources = [
|
| 762 |
+
createMockSource({
|
| 763 |
+
id: '1',
|
| 764 |
+
text: 'A'.repeat(200), // Long enough to need truncation
|
| 765 |
+
}),
|
| 766 |
+
];
|
| 767 |
+
|
| 768 |
+
render(<SourceCitations sources={sources} defaultExpanded />);
|
| 769 |
+
|
| 770 |
+
// SourceCard should have its own expand button
|
| 771 |
+
const buttons = screen.getAllByRole('button');
|
| 772 |
+
// One for SourceCitations, one for SourceCard
|
| 773 |
+
expect(buttons.length).toBeGreaterThanOrEqual(2);
|
| 774 |
+
});
|
| 775 |
+
|
| 776 |
+
it('should handle rapid toggle clicks', async () => {
|
| 777 |
+
// Test rapid clicking doesn't break state
|
| 778 |
+
const user = userEvent.setup();
|
| 779 |
+
const sources = createMockSources(1);
|
| 780 |
+
|
| 781 |
+
render(<SourceCitations sources={sources} />);
|
| 782 |
+
|
| 783 |
+
const button = screen.getByRole('button');
|
| 784 |
+
|
| 785 |
+
// Rapid clicks
|
| 786 |
+
await user.click(button);
|
| 787 |
+
await user.click(button);
|
| 788 |
+
await user.click(button);
|
| 789 |
+
await user.click(button);
|
| 790 |
+
|
| 791 |
+
// Should be in stable state (4 clicks = collapsed)
|
| 792 |
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
| 793 |
+
});
|
| 794 |
+
});
|
| 795 |
+
|
| 796 |
+
// ==========================================================================
|
| 797 |
+
// Visual Tests
|
| 798 |
+
// ==========================================================================
|
| 799 |
+
|
| 800 |
+
describe('visual styling', () => {
|
| 801 |
+
it('should have top border separator', () => {
|
| 802 |
+
// Test border styling
|
| 803 |
+
const sources = createMockSources(1);
|
| 804 |
+
|
| 805 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 806 |
+
|
| 807 |
+
const mainContainer = container.firstChild as HTMLElement;
|
| 808 |
+
expect(mainContainer.className).toMatch(/border-t/);
|
| 809 |
+
});
|
| 810 |
+
|
| 811 |
+
it('should have proper padding and margin', () => {
|
| 812 |
+
// Test spacing classes
|
| 813 |
+
const sources = createMockSources(1);
|
| 814 |
+
|
| 815 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 816 |
+
|
| 817 |
+
const mainContainer = container.firstChild as HTMLElement;
|
| 818 |
+
expect(mainContainer.className).toMatch(/mt-4/);
|
| 819 |
+
expect(mainContainer.className).toMatch(/pt-4/);
|
| 820 |
+
});
|
| 821 |
+
|
| 822 |
+
it('should have hover styles on toggle button', () => {
|
| 823 |
+
// Test hover classes are present
|
| 824 |
+
const sources = createMockSources(1);
|
| 825 |
+
|
| 826 |
+
render(<SourceCitations sources={sources} />);
|
| 827 |
+
|
| 828 |
+
const button = screen.getByRole('button');
|
| 829 |
+
expect(button.className).toMatch(/hover:/);
|
| 830 |
+
});
|
| 831 |
+
|
| 832 |
+
it('should have transition classes for smooth animations', () => {
|
| 833 |
+
// Test transition classes are present
|
| 834 |
+
const sources = createMockSources(1);
|
| 835 |
+
|
| 836 |
+
const { container } = render(<SourceCitations sources={sources} />);
|
| 837 |
+
|
| 838 |
+
const region = container.querySelector('[role="region"]');
|
| 839 |
+
expect(region?.className).toMatch(/transition/);
|
| 840 |
+
});
|
| 841 |
+
|
| 842 |
+
it('should have group class for coordinated hover states', () => {
|
| 843 |
+
// Test group class on button for child hover coordination
|
| 844 |
+
const sources = createMockSources(1);
|
| 845 |
+
|
| 846 |
+
render(<SourceCitations sources={sources} />);
|
| 847 |
+
|
| 848 |
+
const button = screen.getByRole('button');
|
| 849 |
+
expect(button.className).toMatch(/group/);
|
| 850 |
+
});
|
| 851 |
+
});
|
| 852 |
+
});
|
frontend/src/components/chat/chat-container.tsx
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ChatContainer Component
|
| 3 |
+
*
|
| 4 |
+
* The main orchestrating component for the chat interface following a
|
| 5 |
+
* true Claude-style minimal design philosophy. This component achieves
|
| 6 |
+
* a seamless, boundary-free aesthetic where the chat interface feels
|
| 7 |
+
* like a natural extension of the page itself rather than a contained widget.
|
| 8 |
+
*
|
| 9 |
+
* @module components/chat/chat-container
|
| 10 |
+
* @since 1.0.0
|
| 11 |
+
*
|
| 12 |
+
* @design
|
| 13 |
+
* ## Design Philosophy
|
| 14 |
+
*
|
| 15 |
+
* This component follows Claude's true minimal design principles:
|
| 16 |
+
* - **No visible boundaries**: No borders, shadows, or rounded corners on the main container
|
| 17 |
+
* - **Seamless integration**: Chat interface blends directly into the page background
|
| 18 |
+
* - **Minimal chrome**: Headers and inputs use subtle separation, not heavy styling
|
| 19 |
+
* - **Content-first**: Messages flow naturally on the page without visual containment
|
| 20 |
+
* - **Restrained color**: Purple accent color used sparingly for emphasis
|
| 21 |
+
* - **Flat design**: Zero shadows for a completely uncluttered feel
|
| 22 |
+
*
|
| 23 |
+
* @example
|
| 24 |
+
* // Basic usage in a page
|
| 25 |
+
* <ChatContainer />
|
| 26 |
+
*
|
| 27 |
+
* @example
|
| 28 |
+
* // With custom styling
|
| 29 |
+
* <ChatContainer className="h-screen" />
|
| 30 |
+
*
|
| 31 |
+
* @example
|
| 32 |
+
* // With initial messages (for session restoration)
|
| 33 |
+
* <ChatContainer initialMessages={savedMessages} />
|
| 34 |
+
*/
|
| 35 |
+
|
| 36 |
+
'use client';
|
| 37 |
+
|
| 38 |
+
import {
|
| 39 |
+
forwardRef,
|
| 40 |
+
useCallback,
|
| 41 |
+
useEffect,
|
| 42 |
+
useRef,
|
| 43 |
+
useState,
|
| 44 |
+
type HTMLAttributes,
|
| 45 |
+
} from 'react';
|
| 46 |
+
import { MessageSquare, AlertCircle, X } from 'lucide-react';
|
| 47 |
+
import { cn } from '@/lib/utils';
|
| 48 |
+
import { useChat, useSSE, useProviders } from '@/hooks';
|
| 49 |
+
import type { SSEDoneResult, SSEError } from '@/hooks';
|
| 50 |
+
import {
|
| 51 |
+
MemoizedChatMessage,
|
| 52 |
+
ChatInput,
|
| 53 |
+
} from '@/components/chat';
|
| 54 |
+
import type { ChatInputHandle } from '@/components/chat';
|
| 55 |
+
import { EmptyState } from './empty-state';
|
| 56 |
+
import { ErrorState } from './error-state';
|
| 57 |
+
import type { ErrorType } from './error-state';
|
| 58 |
+
import { Button } from '@/components/ui/button';
|
| 59 |
+
import { Spinner } from '@/components/ui/spinner';
|
| 60 |
+
import type { HistoryMessage, Message } from '@/types';
|
| 61 |
+
import { CHAT_CONFIG } from '@/config/constants';
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Props for the ChatContainer component.
|
| 65 |
+
*
|
| 66 |
+
* Extends standard HTML div attributes for flexibility in styling
|
| 67 |
+
* and event handling, while providing chat-specific configuration.
|
| 68 |
+
*/
|
| 69 |
+
export interface ChatContainerProps extends Omit<
|
| 70 |
+
HTMLAttributes<HTMLDivElement>,
|
| 71 |
+
'title'
|
| 72 |
+
> {
|
| 73 |
+
/**
|
| 74 |
+
* Title displayed in the chat header.
|
| 75 |
+
* Defaults to "pythermalcomfort Chat".
|
| 76 |
+
*
|
| 77 |
+
* @default "pythermalcomfort Chat"
|
| 78 |
+
*/
|
| 79 |
+
title?: string;
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* Whether to show the internal chat header.
|
| 83 |
+
* Set to false when the title is rendered at the page level.
|
| 84 |
+
*
|
| 85 |
+
* @default true
|
| 86 |
+
*/
|
| 87 |
+
showHeader?: boolean;
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Initial messages to populate the chat.
|
| 91 |
+
* Useful for restoring conversation state from localStorage or URL params.
|
| 92 |
+
*
|
| 93 |
+
* @default []
|
| 94 |
+
*/
|
| 95 |
+
initialMessages?: Message[];
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* Callback fired when messages change.
|
| 99 |
+
* Useful for persisting conversation to localStorage.
|
| 100 |
+
*
|
| 101 |
+
* @param messages - The updated messages array
|
| 102 |
+
*/
|
| 103 |
+
onMessagesChange?: (messages: Message[]) => void;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* ChatHeader Component
|
| 108 |
+
*
|
| 109 |
+
* Renders a minimal, unobtrusive header section that stays out of the way.
|
| 110 |
+
* Follows Claude-style design where the header is subtle and doesn't
|
| 111 |
+
* distract from the main chat content.
|
| 112 |
+
*
|
| 113 |
+
* Note: Provider selection has been moved to the input area (ChatInput)
|
| 114 |
+
* to match Claude's UI pattern where the model selector is near the
|
| 115 |
+
* send button.
|
| 116 |
+
*
|
| 117 |
+
* @design
|
| 118 |
+
* - Small icon and text size to minimize visual weight
|
| 119 |
+
* - Muted colors so it doesn't compete with chat content
|
| 120 |
+
* - Very subtle bottom separator (minimal opacity)
|
| 121 |
+
* - Left-aligned, compact layout
|
| 122 |
+
*
|
| 123 |
+
* @param title - The main title text
|
| 124 |
+
*
|
| 125 |
+
* @internal
|
| 126 |
+
*/
|
| 127 |
+
function ChatHeader({
|
| 128 |
+
title,
|
| 129 |
+
}: {
|
| 130 |
+
title: string;
|
| 131 |
+
}): React.ReactElement {
|
| 132 |
+
return (
|
| 133 |
+
<header
|
| 134 |
+
className={cn(
|
| 135 |
+
/* Fixed header positioning - prevents shrinking */
|
| 136 |
+
'shrink-0',
|
| 137 |
+
/* Minimal padding - positioned far left */
|
| 138 |
+
'px-2 py-2',
|
| 139 |
+
/*
|
| 140 |
+
* Very subtle bottom border - minimal opacity for seamless design.
|
| 141 |
+
* This provides just enough visual separation without creating
|
| 142 |
+
* a heavy boundary. The low opacity makes it almost invisible
|
| 143 |
+
* while still providing structure.
|
| 144 |
+
*/
|
| 145 |
+
'border-b border-[var(--border)]/10',
|
| 146 |
+
/* Flex layout with centered alignment */
|
| 147 |
+
'flex items-center gap-2'
|
| 148 |
+
)}
|
| 149 |
+
>
|
| 150 |
+
{/*
|
| 151 |
+
* Icon in purple - matches brand color.
|
| 152 |
+
*/}
|
| 153 |
+
<MessageSquare
|
| 154 |
+
className="h-5 w-5 text-[var(--color-primary-500)]"
|
| 155 |
+
strokeWidth={2}
|
| 156 |
+
aria-hidden="true"
|
| 157 |
+
/>
|
| 158 |
+
|
| 159 |
+
{/* Title - original size, positioned far left */}
|
| 160 |
+
<h1 className="text-base font-medium text-[var(--foreground)]">
|
| 161 |
+
{title}
|
| 162 |
+
</h1>
|
| 163 |
+
</header>
|
| 164 |
+
);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/**
|
| 168 |
+
* ErrorBanner Component
|
| 169 |
+
*
|
| 170 |
+
* Displays a subtle error message that doesn't dominate the interface.
|
| 171 |
+
* Uses a lighter error styling with reduced opacity backgrounds and
|
| 172 |
+
* softer colors for a less alarming appearance.
|
| 173 |
+
*
|
| 174 |
+
* @design
|
| 175 |
+
* - Light error background (reduced opacity) for subtle presence
|
| 176 |
+
* - No border for cleaner appearance
|
| 177 |
+
* - Smaller, less intrusive icon
|
| 178 |
+
* - Gentle dismiss button
|
| 179 |
+
*
|
| 180 |
+
* @param message - The error message to display
|
| 181 |
+
* @param onDismiss - Callback fired when the dismiss button is clicked
|
| 182 |
+
*
|
| 183 |
+
* @internal
|
| 184 |
+
*/
|
| 185 |
+
function ErrorBanner({
|
| 186 |
+
message,
|
| 187 |
+
onDismiss,
|
| 188 |
+
}: {
|
| 189 |
+
message: string;
|
| 190 |
+
onDismiss: () => void;
|
| 191 |
+
}): React.ReactElement {
|
| 192 |
+
return (
|
| 193 |
+
<div
|
| 194 |
+
role="alert"
|
| 195 |
+
className={cn(
|
| 196 |
+
/* Horizontal margin for alignment with content */
|
| 197 |
+
'mx-6 mb-4',
|
| 198 |
+
/* Compact padding for minimal footprint */
|
| 199 |
+
'px-3 py-2',
|
| 200 |
+
/* Very light error background - subtle, not alarming */
|
| 201 |
+
'bg-[var(--error)]/5',
|
| 202 |
+
/* Rounded corners matching the minimal design system */
|
| 203 |
+
'rounded-[var(--radius)]',
|
| 204 |
+
/* Flex layout for icon, message, and dismiss button */
|
| 205 |
+
'flex items-center gap-2',
|
| 206 |
+
/* Fade-in animation for smooth appearance */
|
| 207 |
+
'animate-fade-in'
|
| 208 |
+
)}
|
| 209 |
+
>
|
| 210 |
+
{/* Small error icon - less prominent for minimal design */}
|
| 211 |
+
<AlertCircle
|
| 212 |
+
className="h-4 w-4 shrink-0 text-[var(--error)]/80"
|
| 213 |
+
strokeWidth={1.5}
|
| 214 |
+
aria-hidden="true"
|
| 215 |
+
/>
|
| 216 |
+
|
| 217 |
+
{/* Error message with softer color */}
|
| 218 |
+
<p className="flex-1 text-sm text-[var(--error)]/90">{message}</p>
|
| 219 |
+
|
| 220 |
+
{/* Minimal dismiss button */}
|
| 221 |
+
<Button
|
| 222 |
+
variant="ghost"
|
| 223 |
+
size="icon"
|
| 224 |
+
onClick={onDismiss}
|
| 225 |
+
aria-label="Dismiss error"
|
| 226 |
+
className={cn(
|
| 227 |
+
'h-5 w-5',
|
| 228 |
+
'text-[var(--error)]/60',
|
| 229 |
+
'hover:text-[var(--error)]',
|
| 230 |
+
'hover:bg-[var(--error)]/5'
|
| 231 |
+
)}
|
| 232 |
+
>
|
| 233 |
+
<X className="h-3 w-3" />
|
| 234 |
+
</Button>
|
| 235 |
+
</div>
|
| 236 |
+
);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
/**
|
| 240 |
+
* LoadingOverlay Component
|
| 241 |
+
*
|
| 242 |
+
* A minimal loading indicator using the purple accent color.
|
| 243 |
+
* Designed to be subtle and unobtrusive while still communicating
|
| 244 |
+
* that a response is being generated.
|
| 245 |
+
*
|
| 246 |
+
* @design
|
| 247 |
+
* - Purple spinner to match the brand color
|
| 248 |
+
* - Muted text for the loading message
|
| 249 |
+
* - Compact layout that doesn't disrupt the message flow
|
| 250 |
+
* - Fade-in animation for smooth appearance
|
| 251 |
+
*
|
| 252 |
+
* @internal
|
| 253 |
+
*/
|
| 254 |
+
function LoadingOverlay(): React.ReactElement {
|
| 255 |
+
return (
|
| 256 |
+
<div
|
| 257 |
+
className={cn(
|
| 258 |
+
/* Generous horizontal padding to align with messages */
|
| 259 |
+
'px-6 py-4',
|
| 260 |
+
/* Flex layout for spinner and text */
|
| 261 |
+
'flex items-center gap-3',
|
| 262 |
+
/* Smooth fade-in animation */
|
| 263 |
+
'animate-fade-in'
|
| 264 |
+
)}
|
| 265 |
+
aria-live="polite"
|
| 266 |
+
aria-label="Generating response..."
|
| 267 |
+
>
|
| 268 |
+
{/* Purple spinner - matches the minimal design accent color */}
|
| 269 |
+
<Spinner
|
| 270 |
+
size="sm"
|
| 271 |
+
color="primary"
|
| 272 |
+
spinnerStyle="ring"
|
| 273 |
+
label="Thinking..."
|
| 274 |
+
/>
|
| 275 |
+
{/* Subtle loading text - doesn't demand attention */}
|
| 276 |
+
<span className="text-sm text-[var(--foreground-muted)]">
|
| 277 |
+
Generating response...
|
| 278 |
+
</span>
|
| 279 |
+
</div>
|
| 280 |
+
);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
/**
|
| 284 |
+
* Parsed error information for determining display type and retry behavior.
|
| 285 |
+
*
|
| 286 |
+
* @internal
|
| 287 |
+
*/
|
| 288 |
+
interface ParsedError {
|
| 289 |
+
/** The type of error for visual differentiation */
|
| 290 |
+
type: ErrorType;
|
| 291 |
+
/** Seconds to wait before retrying (for quota errors) */
|
| 292 |
+
retryAfterSeconds?: number;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/**
|
| 296 |
+
* Parse an SSE error to determine its type and retry information.
|
| 297 |
+
*
|
| 298 |
+
* Maps SSE error properties to ErrorState types:
|
| 299 |
+
* - Network errors (TypeError, fetch failures) -> 'network'
|
| 300 |
+
* - Errors with retryAfter (503 quota exceeded) -> 'quota'
|
| 301 |
+
* - Other errors -> 'general'
|
| 302 |
+
*
|
| 303 |
+
* @param error - The SSE error to parse
|
| 304 |
+
* @returns Parsed error with type and optional retry delay
|
| 305 |
+
*
|
| 306 |
+
* @internal
|
| 307 |
+
*/
|
| 308 |
+
function parseErrorType(error: SSEError): ParsedError {
|
| 309 |
+
// Network errors get special styling
|
| 310 |
+
if (error.isNetworkError) {
|
| 311 |
+
return { type: 'network' };
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// Errors with retryAfter are quota/rate limit errors (HTTP 503)
|
| 315 |
+
if (error.retryAfter !== undefined && error.retryAfter > 0) {
|
| 316 |
+
return {
|
| 317 |
+
type: 'quota',
|
| 318 |
+
// Convert milliseconds to seconds for display
|
| 319 |
+
retryAfterSeconds: Math.ceil(error.retryAfter / 1000),
|
| 320 |
+
};
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
// Default to general error
|
| 324 |
+
return { type: 'general' };
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
/**
|
| 328 |
+
* ChatContainer Component
|
| 329 |
+
*
|
| 330 |
+
* The main chat interface component following true Claude-style minimal design.
|
| 331 |
+
* This component orchestrates all chat functionality while achieving a seamless,
|
| 332 |
+
* boundary-free aesthetic where messages flow naturally on the page background.
|
| 333 |
+
*
|
| 334 |
+
* @remarks
|
| 335 |
+
* ## Architecture
|
| 336 |
+
*
|
| 337 |
+
* The ChatContainer is composed of several sub-components:
|
| 338 |
+
*
|
| 339 |
+
* 1. **ChatHeader**: Minimal header with icon, title, and provider toggle (very subtle border)
|
| 340 |
+
* 2. **Message List**: Spacious scrollable area with generous message spacing
|
| 341 |
+
* 3. **EmptyState**: Clean welcome state with example questions
|
| 342 |
+
* 4. **ChatInput**: Minimal input area with very subtle top separator
|
| 343 |
+
* 5. **ErrorBanner**: Unobtrusive error display above the input
|
| 344 |
+
* 6. **LoadingOverlay**: Purple-themed loading indicator
|
| 345 |
+
*
|
| 346 |
+
* ## Design Philosophy
|
| 347 |
+
*
|
| 348 |
+
* This component implements true Claude-style minimal design:
|
| 349 |
+
*
|
| 350 |
+
* - **No visible boundaries**: Main container has no borders, shadows, or rounded corners
|
| 351 |
+
* - **Seamless integration**: Chat interface blends directly into the page background
|
| 352 |
+
* - **Minimal chrome**: Headers and inputs use very subtle separators (20% opacity borders)
|
| 353 |
+
* - **Content-first**: Messages flow naturally without visual containment
|
| 354 |
+
* - **Zero shadows**: Completely flat design with no elevation effects
|
| 355 |
+
* - **Generous spacing**: gap-6 between messages for content breathing room
|
| 356 |
+
* - **Purple accents**: Brand color used sparingly for icons and spinners
|
| 357 |
+
*
|
| 358 |
+
* ## State Management
|
| 359 |
+
*
|
| 360 |
+
* Uses the `useChat` hook for all state management:
|
| 361 |
+
* - `messages`: Array of all chat messages
|
| 362 |
+
* - `isLoading`: Whether a response is being generated
|
| 363 |
+
* - `error`: Current error message (if any)
|
| 364 |
+
*
|
| 365 |
+
* ## Auto-Scroll Behavior
|
| 366 |
+
*
|
| 367 |
+
* The message list automatically scrolls to the bottom when:
|
| 368 |
+
* - A new message is added
|
| 369 |
+
* - The assistant response is updated (streaming)
|
| 370 |
+
*
|
| 371 |
+
* Smooth scroll behavior is used for a polished feel. The scroll
|
| 372 |
+
* is triggered via a `useEffect` that watches the messages array.
|
| 373 |
+
*
|
| 374 |
+
* ## SSE Streaming Integration
|
| 375 |
+
*
|
| 376 |
+
* The submit handler uses the useSSE hook to stream responses from the
|
| 377 |
+
* backend. Tokens are appended to the assistant message in real-time,
|
| 378 |
+
* and the final response includes source citations from RAG retrieval.
|
| 379 |
+
*
|
| 380 |
+
* ## Performance Optimizations
|
| 381 |
+
*
|
| 382 |
+
* - Uses `MemoizedChatMessage` to prevent unnecessary re-renders
|
| 383 |
+
* - Callbacks are memoized with `useCallback`
|
| 384 |
+
* - Refs are used for direct DOM manipulation (auto-scroll)
|
| 385 |
+
* - Lazy loading patterns preserved for heavy dependencies
|
| 386 |
+
*
|
| 387 |
+
* ## Accessibility
|
| 388 |
+
*
|
| 389 |
+
* - Main region is marked with `role="region"` and proper aria-label
|
| 390 |
+
* - Error messages use `role="alert"` for screen reader announcements
|
| 391 |
+
* - Loading state is announced via `aria-live` region
|
| 392 |
+
* - All interactive elements are keyboard accessible
|
| 393 |
+
*
|
| 394 |
+
* ## Responsive Design
|
| 395 |
+
*
|
| 396 |
+
* The chat container is designed to work across all screen sizes:
|
| 397 |
+
* - Full viewport height on mobile
|
| 398 |
+
* - Constrained max-width on larger screens
|
| 399 |
+
* - Proper padding and spacing at all breakpoints
|
| 400 |
+
*
|
| 401 |
+
* @param props - ChatContainer component props
|
| 402 |
+
* @returns React element containing the complete chat interface
|
| 403 |
+
*
|
| 404 |
+
* @see {@link ChatContainerProps} for full prop documentation
|
| 405 |
+
* @see {@link useChat} for state management details
|
| 406 |
+
*/
|
| 407 |
+
const ChatContainer = forwardRef<HTMLDivElement, ChatContainerProps>(
|
| 408 |
+
(
|
| 409 |
+
{
|
| 410 |
+
title = 'pythermalcomfort Chat',
|
| 411 |
+
showHeader = true,
|
| 412 |
+
initialMessages = [],
|
| 413 |
+
onMessagesChange,
|
| 414 |
+
className,
|
| 415 |
+
...props
|
| 416 |
+
},
|
| 417 |
+
ref
|
| 418 |
+
) => {
|
| 419 |
+
/**
|
| 420 |
+
* Initialize the useChat hook for state management.
|
| 421 |
+
*
|
| 422 |
+
* This hook provides all message state and actions needed
|
| 423 |
+
* for the complete chat functionality.
|
| 424 |
+
*/
|
| 425 |
+
const {
|
| 426 |
+
messages,
|
| 427 |
+
isLoading,
|
| 428 |
+
error,
|
| 429 |
+
addMessage,
|
| 430 |
+
updateLastMessage,
|
| 431 |
+
setIsLoading,
|
| 432 |
+
setError,
|
| 433 |
+
clearError,
|
| 434 |
+
} = useChat({
|
| 435 |
+
initialMessages,
|
| 436 |
+
onMessagesChange,
|
| 437 |
+
});
|
| 438 |
+
|
| 439 |
+
/**
|
| 440 |
+
* Initialize the useProviders hook for provider selection.
|
| 441 |
+
*
|
| 442 |
+
* This hook provides the selected provider to pass to the SSE stream.
|
| 443 |
+
* When selectedProvider is null, auto mode is active and the backend
|
| 444 |
+
* will select the best available provider.
|
| 445 |
+
*/
|
| 446 |
+
const { selectedProvider } = useProviders();
|
| 447 |
+
|
| 448 |
+
/**
|
| 449 |
+
* Error type state for determining which error UI to show.
|
| 450 |
+
*
|
| 451 |
+
* - 'quota': HTTP 503 errors with retry-after header
|
| 452 |
+
* - 'network': Connection/fetch failures
|
| 453 |
+
* - 'general': All other errors
|
| 454 |
+
*
|
| 455 |
+
* Used to show appropriate ErrorState styling and behavior.
|
| 456 |
+
*/
|
| 457 |
+
const [errorType, setErrorType] = useState<ErrorType | null>(null);
|
| 458 |
+
|
| 459 |
+
/**
|
| 460 |
+
* Seconds until retry is allowed (for quota errors).
|
| 461 |
+
*
|
| 462 |
+
* Populated when the server returns a 503 with Retry-After header.
|
| 463 |
+
* The ErrorState component uses this to show a countdown timer.
|
| 464 |
+
*/
|
| 465 |
+
const [retryAfterSeconds, setRetryAfterSeconds] = useState<number | null>(
|
| 466 |
+
null
|
| 467 |
+
);
|
| 468 |
+
|
| 469 |
+
/**
|
| 470 |
+
* Ref to track if the user manually aborted the stream.
|
| 471 |
+
* Used to distinguish user cancellation from error states.
|
| 472 |
+
*/
|
| 473 |
+
const userAbortedRef = useRef<boolean>(false);
|
| 474 |
+
|
| 475 |
+
/**
|
| 476 |
+
* Ref to store the last submitted query for retry functionality.
|
| 477 |
+
*
|
| 478 |
+
* When an error occurs and the user clicks retry, we need to re-submit
|
| 479 |
+
* the same query. This ref preserves the query across renders.
|
| 480 |
+
*/
|
| 481 |
+
const lastQueryRef = useRef<string | null>(null);
|
| 482 |
+
|
| 483 |
+
/**
|
| 484 |
+
* Initialize the useSSE hook for streaming responses.
|
| 485 |
+
*
|
| 486 |
+
* Callbacks are wired to update the chat state as tokens
|
| 487 |
+
* arrive and when the stream completes or errors.
|
| 488 |
+
*/
|
| 489 |
+
const { startStream, abort, isStreaming } = useSSE({
|
| 490 |
+
/**
|
| 491 |
+
* Handle incoming tokens during streaming.
|
| 492 |
+
* Appends each token to the last message content.
|
| 493 |
+
*/
|
| 494 |
+
onToken: useCallback(
|
| 495 |
+
(content: string) => {
|
| 496 |
+
updateLastMessage((prev) => prev + content);
|
| 497 |
+
},
|
| 498 |
+
[updateLastMessage]
|
| 499 |
+
),
|
| 500 |
+
|
| 501 |
+
/**
|
| 502 |
+
* Handle successful stream completion.
|
| 503 |
+
* Updates the message with the final response and sources.
|
| 504 |
+
*/
|
| 505 |
+
onDone: useCallback(
|
| 506 |
+
(result: SSEDoneResult) => {
|
| 507 |
+
updateLastMessage(result.response, {
|
| 508 |
+
isStreaming: false,
|
| 509 |
+
sources: result.sources,
|
| 510 |
+
});
|
| 511 |
+
setIsLoading(false);
|
| 512 |
+
},
|
| 513 |
+
[updateLastMessage, setIsLoading]
|
| 514 |
+
),
|
| 515 |
+
|
| 516 |
+
/**
|
| 517 |
+
* Handle stream errors.
|
| 518 |
+
*
|
| 519 |
+
* This callback processes errors from the SSE stream and updates the UI
|
| 520 |
+
* appropriately based on the error type:
|
| 521 |
+
*
|
| 522 |
+
* 1. If user manually aborted, skip error handling entirely
|
| 523 |
+
* 2. Parse the error to determine type (network, quota, or general)
|
| 524 |
+
* 3. Update error state (type, message, retry delay)
|
| 525 |
+
* 4. If there are existing messages, update the last assistant message
|
| 526 |
+
* to show an error occurred. The ErrorBanner will handle display.
|
| 527 |
+
* 5. If no messages exist, the ErrorState component will be shown
|
| 528 |
+
* in place of the EmptyState (handled in render logic)
|
| 529 |
+
*/
|
| 530 |
+
onError: useCallback(
|
| 531 |
+
(error: SSEError) => {
|
| 532 |
+
// Don't show error if user manually aborted
|
| 533 |
+
if (userAbortedRef.current) {
|
| 534 |
+
userAbortedRef.current = false;
|
| 535 |
+
return;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
// Parse the error to determine type and retry behavior
|
| 539 |
+
const parsed = parseErrorType(error);
|
| 540 |
+
|
| 541 |
+
// Update error state for UI rendering decisions
|
| 542 |
+
setErrorType(parsed.type);
|
| 543 |
+
setRetryAfterSeconds(parsed.retryAfterSeconds ?? null);
|
| 544 |
+
setError(error.message);
|
| 545 |
+
setIsLoading(false);
|
| 546 |
+
|
| 547 |
+
// Only update the assistant message if we have messages in the chat.
|
| 548 |
+
// For empty state errors, we show the full ErrorState component instead.
|
| 549 |
+
if (messages.length > 0) {
|
| 550 |
+
updateLastMessage(
|
| 551 |
+
'Sorry, I encountered an error. Please try again.',
|
| 552 |
+
{
|
| 553 |
+
isStreaming: false,
|
| 554 |
+
}
|
| 555 |
+
);
|
| 556 |
+
}
|
| 557 |
+
},
|
| 558 |
+
[setError, setIsLoading, updateLastMessage, messages.length]
|
| 559 |
+
),
|
| 560 |
+
});
|
| 561 |
+
|
| 562 |
+
/**
|
| 563 |
+
* Ref to the messages container for auto-scroll functionality.
|
| 564 |
+
* We scroll this container to the bottom when new messages arrive.
|
| 565 |
+
*/
|
| 566 |
+
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
| 567 |
+
|
| 568 |
+
/**
|
| 569 |
+
* Ref to the ChatInput component for programmatic control.
|
| 570 |
+
* Used to populate the input when example questions are clicked.
|
| 571 |
+
*/
|
| 572 |
+
const chatInputRef = useRef<ChatInputHandle>(null);
|
| 573 |
+
|
| 574 |
+
/**
|
| 575 |
+
* Ref to track the end of the messages list.
|
| 576 |
+
* Used as the target for scroll-into-view behavior.
|
| 577 |
+
*/
|
| 578 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 579 |
+
|
| 580 |
+
/**
|
| 581 |
+
* Auto-scroll effect that triggers when messages change.
|
| 582 |
+
*
|
| 583 |
+
* Scrolls the message list to the bottom with smooth behavior
|
| 584 |
+
* whenever the messages array is updated. This ensures users
|
| 585 |
+
* always see the latest message without manual scrolling.
|
| 586 |
+
*
|
| 587 |
+
* Implementation note: We use a slight delay to ensure the DOM
|
| 588 |
+
* has updated before scrolling. This prevents scroll jitter
|
| 589 |
+
* during rapid streaming updates.
|
| 590 |
+
*/
|
| 591 |
+
useEffect(() => {
|
| 592 |
+
// Only scroll if there are messages
|
| 593 |
+
if (messages.length === 0) return;
|
| 594 |
+
|
| 595 |
+
// Use requestAnimationFrame for smooth scroll after DOM update
|
| 596 |
+
const timeoutId = requestAnimationFrame(() => {
|
| 597 |
+
messagesEndRef.current?.scrollIntoView({
|
| 598 |
+
behavior: 'smooth',
|
| 599 |
+
block: 'end',
|
| 600 |
+
});
|
| 601 |
+
});
|
| 602 |
+
|
| 603 |
+
return () => cancelAnimationFrame(timeoutId);
|
| 604 |
+
}, [messages]);
|
| 605 |
+
|
| 606 |
+
/**
|
| 607 |
+
* Handle example question clicks from EmptyState.
|
| 608 |
+
*
|
| 609 |
+
* Populates the chat input with the clicked question,
|
| 610 |
+
* allowing users to submit it or modify before sending.
|
| 611 |
+
*/
|
| 612 |
+
const handleExampleClick = useCallback((question: string) => {
|
| 613 |
+
chatInputRef.current?.setValue(question);
|
| 614 |
+
chatInputRef.current?.focus();
|
| 615 |
+
}, []);
|
| 616 |
+
|
| 617 |
+
/**
|
| 618 |
+
* Clear all error state including type and retry information.
|
| 619 |
+
*
|
| 620 |
+
* This is an enhanced version of clearError that also resets
|
| 621 |
+
* the errorType and retryAfterSeconds state. Used when:
|
| 622 |
+
* - User dismisses an error
|
| 623 |
+
* - A new query is submitted
|
| 624 |
+
* - Retry is initiated
|
| 625 |
+
*/
|
| 626 |
+
const handleClearError = useCallback(() => {
|
| 627 |
+
clearError();
|
| 628 |
+
setErrorType(null);
|
| 629 |
+
setRetryAfterSeconds(null);
|
| 630 |
+
}, [clearError]);
|
| 631 |
+
|
| 632 |
+
/**
|
| 633 |
+
* Handle aborting the current stream.
|
| 634 |
+
*
|
| 635 |
+
* Called when the user wants to cancel an in-progress response.
|
| 636 |
+
* Marks the abort as user-initiated to prevent error display.
|
| 637 |
+
*
|
| 638 |
+
* Note: This function is available for future use when a cancel button
|
| 639 |
+
* is added to the UI. Currently prefixed with underscore to satisfy
|
| 640 |
+
* the linter, but the functionality is fully implemented.
|
| 641 |
+
*
|
| 642 |
+
* @example
|
| 643 |
+
* // Usage with a cancel button:
|
| 644 |
+
* <Button onClick={_handleAbort}>Cancel</Button>
|
| 645 |
+
*/
|
| 646 |
+
const _handleAbort = useCallback(() => {
|
| 647 |
+
userAbortedRef.current = true;
|
| 648 |
+
abort();
|
| 649 |
+
setIsLoading(false);
|
| 650 |
+
// Update the last message to show it was cancelled
|
| 651 |
+
updateLastMessage((prev) => prev || 'Response cancelled.', {
|
| 652 |
+
isStreaming: false,
|
| 653 |
+
});
|
| 654 |
+
}, [abort, setIsLoading, updateLastMessage]);
|
| 655 |
+
|
| 656 |
+
/**
|
| 657 |
+
* Handle message submission from ChatInput.
|
| 658 |
+
*
|
| 659 |
+
* Initiates an SSE stream to the backend:
|
| 660 |
+
* 1. Clears any existing errors (including type and retry info)
|
| 661 |
+
* 2. Stores the query for potential retry
|
| 662 |
+
* 3. Aborts any in-progress stream
|
| 663 |
+
* 4. Adds the user message to the conversation
|
| 664 |
+
* 5. Creates a placeholder assistant message
|
| 665 |
+
* 6. Starts the SSE stream with the selected provider
|
| 666 |
+
*
|
| 667 |
+
* @param content - The user's message content
|
| 668 |
+
*/
|
| 669 |
+
const handleSubmit = useCallback(
|
| 670 |
+
(content: string) => {
|
| 671 |
+
// Guard against empty submissions
|
| 672 |
+
if (!content.trim()) return;
|
| 673 |
+
|
| 674 |
+
// Clear any existing errors (including error type and retry info)
|
| 675 |
+
handleClearError();
|
| 676 |
+
|
| 677 |
+
// Store the query for retry functionality
|
| 678 |
+
// This allows us to re-submit the same query if an error occurs
|
| 679 |
+
lastQueryRef.current = content;
|
| 680 |
+
|
| 681 |
+
// If already streaming, abort the previous stream
|
| 682 |
+
if (isStreaming) {
|
| 683 |
+
userAbortedRef.current = true;
|
| 684 |
+
abort();
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
// =====================================================================
|
| 688 |
+
// Build conversation history for multi-turn context
|
| 689 |
+
// =====================================================================
|
| 690 |
+
// Extract the most recent messages (before adding the new user message)
|
| 691 |
+
// Filter out incomplete streaming messages and limit to maxHistoryForAPI
|
| 692 |
+
// This provides context to the LLM for follow-up questions
|
| 693 |
+
const history: HistoryMessage[] = messages
|
| 694 |
+
.filter((msg) => !msg.isStreaming && msg.content.trim())
|
| 695 |
+
.slice(-CHAT_CONFIG.maxHistoryForAPI)
|
| 696 |
+
.map((msg) => ({
|
| 697 |
+
role: msg.role,
|
| 698 |
+
content: msg.content,
|
| 699 |
+
}));
|
| 700 |
+
|
| 701 |
+
// Add the user's message to the conversation
|
| 702 |
+
addMessage('user', content);
|
| 703 |
+
|
| 704 |
+
// Add a placeholder for the assistant's response
|
| 705 |
+
// Mark it as streaming so it shows the loading cursor
|
| 706 |
+
addMessage('assistant', '', { isStreaming: true });
|
| 707 |
+
|
| 708 |
+
// Set loading state
|
| 709 |
+
setIsLoading(true);
|
| 710 |
+
|
| 711 |
+
// Reset the abort flag before starting new stream
|
| 712 |
+
userAbortedRef.current = false;
|
| 713 |
+
|
| 714 |
+
// Start the SSE stream with the selected provider and conversation history
|
| 715 |
+
// Pass undefined instead of null for auto mode
|
| 716 |
+
startStream(content, selectedProvider ?? undefined, history);
|
| 717 |
+
},
|
| 718 |
+
[
|
| 719 |
+
addMessage,
|
| 720 |
+
setIsLoading,
|
| 721 |
+
handleClearError,
|
| 722 |
+
isStreaming,
|
| 723 |
+
abort,
|
| 724 |
+
startStream,
|
| 725 |
+
selectedProvider,
|
| 726 |
+
messages,
|
| 727 |
+
]
|
| 728 |
+
);
|
| 729 |
+
|
| 730 |
+
/**
|
| 731 |
+
* Handle retry after an error occurs.
|
| 732 |
+
*
|
| 733 |
+
* Clears the error state and re-submits the last query.
|
| 734 |
+
* For empty state errors (no messages), this will submit the query fresh.
|
| 735 |
+
* For mid-conversation errors, this will add a new message pair.
|
| 736 |
+
*
|
| 737 |
+
* Note: For network errors where the query never reached the server,
|
| 738 |
+
* this effectively gives the user a second chance to submit.
|
| 739 |
+
*/
|
| 740 |
+
const handleRetry = useCallback(() => {
|
| 741 |
+
handleClearError();
|
| 742 |
+
if (lastQueryRef.current) {
|
| 743 |
+
handleSubmit(lastQueryRef.current);
|
| 744 |
+
}
|
| 745 |
+
}, [handleClearError, handleSubmit]);
|
| 746 |
+
|
| 747 |
+
/**
|
| 748 |
+
* Determine if we should show the empty state.
|
| 749 |
+
* Only show when there are no messages in the conversation.
|
| 750 |
+
*/
|
| 751 |
+
const showEmptyState = messages.length === 0;
|
| 752 |
+
|
| 753 |
+
return (
|
| 754 |
+
<div
|
| 755 |
+
ref={ref}
|
| 756 |
+
className={cn(
|
| 757 |
+
/* Full height flex layout - fills available space */
|
| 758 |
+
'flex flex-col',
|
| 759 |
+
'h-full',
|
| 760 |
+
/* Container constraints - max-width for readability on large screens */
|
| 761 |
+
'mx-auto w-full max-w-4xl',
|
| 762 |
+
/*
|
| 763 |
+
* TRUE CLAUDE-STYLE MINIMAL DESIGN:
|
| 764 |
+
* No borders, no shadows, no rounded corners.
|
| 765 |
+
* The chat interface blends seamlessly into the page background.
|
| 766 |
+
* Messages flow naturally without visual containment.
|
| 767 |
+
* This creates a sense that the chat is part of the page itself,
|
| 768 |
+
* not a separate contained widget.
|
| 769 |
+
*/
|
| 770 |
+
/* Overflow handling for scroll containment */
|
| 771 |
+
'overflow-hidden',
|
| 772 |
+
className
|
| 773 |
+
)}
|
| 774 |
+
role="region"
|
| 775 |
+
aria-label="Chat interface"
|
| 776 |
+
{...props}
|
| 777 |
+
>
|
| 778 |
+
{/* Optional internal header - can be hidden when title is at page level */}
|
| 779 |
+
{showHeader && <ChatHeader title={title} />}
|
| 780 |
+
|
| 781 |
+
{/*
|
| 782 |
+
* Messages Area - Scrollable container with generous spacing.
|
| 783 |
+
* Increased padding and gap for content breathing room.
|
| 784 |
+
*/}
|
| 785 |
+
<div
|
| 786 |
+
ref={messagesContainerRef}
|
| 787 |
+
className={cn(
|
| 788 |
+
/* Flex grow to fill available space */
|
| 789 |
+
'flex-1',
|
| 790 |
+
/* Scroll behavior for overflow content */
|
| 791 |
+
'overflow-y-auto',
|
| 792 |
+
/*
|
| 793 |
+
* Generous padding - increased from px-4 py-4 to px-6 py-6.
|
| 794 |
+
* This provides more breathing room around the content,
|
| 795 |
+
* making the interface feel more spacious and open.
|
| 796 |
+
*/
|
| 797 |
+
'px-6 py-6',
|
| 798 |
+
/* Flex column for vertical message layout */
|
| 799 |
+
'flex flex-col',
|
| 800 |
+
/*
|
| 801 |
+
* Increased gap between messages - gap-6 instead of gap-4.
|
| 802 |
+
* This gives each message more vertical breathing room,
|
| 803 |
+
* improving readability and reducing visual clutter.
|
| 804 |
+
* For empty/error states, use justify-center for vertical centering.
|
| 805 |
+
*/
|
| 806 |
+
showEmptyState ? 'justify-center' : 'gap-6'
|
| 807 |
+
)}
|
| 808 |
+
>
|
| 809 |
+
{/*
|
| 810 |
+
* Content rendering logic:
|
| 811 |
+
*
|
| 812 |
+
* 1. Empty state WITH error -> Show full-page ErrorState component
|
| 813 |
+
* This provides a prominent error display when the user hasn't
|
| 814 |
+
* started a conversation yet (e.g., first query fails).
|
| 815 |
+
*
|
| 816 |
+
* 2. Empty state WITHOUT error -> Show EmptyState with examples
|
| 817 |
+
* The normal welcome state with example questions.
|
| 818 |
+
*
|
| 819 |
+
* 3. Messages exist -> Show message list
|
| 820 |
+
* The ErrorBanner handles errors during conversation.
|
| 821 |
+
*/}
|
| 822 |
+
{showEmptyState && error ? (
|
| 823 |
+
/* Full-page error state for empty chat with error */
|
| 824 |
+
<ErrorState
|
| 825 |
+
type={errorType || 'general'}
|
| 826 |
+
message={error}
|
| 827 |
+
retryAfter={retryAfterSeconds ?? undefined}
|
| 828 |
+
onRetry={handleRetry}
|
| 829 |
+
onDismiss={handleClearError}
|
| 830 |
+
/>
|
| 831 |
+
) : showEmptyState ? (
|
| 832 |
+
/* Normal empty state with example questions */
|
| 833 |
+
<EmptyState onExampleClick={handleExampleClick} />
|
| 834 |
+
) : (
|
| 835 |
+
<>
|
| 836 |
+
{/* Render all messages with increased spacing (gap-6) */}
|
| 837 |
+
{messages.map((message) => (
|
| 838 |
+
<MemoizedChatMessage
|
| 839 |
+
key={message.id}
|
| 840 |
+
message={message}
|
| 841 |
+
showTimestamp={!message.isStreaming}
|
| 842 |
+
/>
|
| 843 |
+
))}
|
| 844 |
+
|
| 845 |
+
{/* Minimal loading indicator with purple spinner */}
|
| 846 |
+
{isLoading && <LoadingOverlay />}
|
| 847 |
+
|
| 848 |
+
{/* Scroll anchor - used for auto-scroll functionality */}
|
| 849 |
+
<div ref={messagesEndRef} aria-hidden="true" />
|
| 850 |
+
</>
|
| 851 |
+
)}
|
| 852 |
+
</div>
|
| 853 |
+
|
| 854 |
+
{/*
|
| 855 |
+
* Error Banner - Shown above input for mid-conversation errors.
|
| 856 |
+
*
|
| 857 |
+
* Only display the ErrorBanner when:
|
| 858 |
+
* - There IS an error
|
| 859 |
+
* - There ARE messages in the chat (not empty state)
|
| 860 |
+
*
|
| 861 |
+
* For empty state errors, we show the full ErrorState component
|
| 862 |
+
* instead (handled above), so we hide the banner in that case.
|
| 863 |
+
*/}
|
| 864 |
+
{error && !showEmptyState && (
|
| 865 |
+
<ErrorBanner message={error} onDismiss={handleClearError} />
|
| 866 |
+
)}
|
| 867 |
+
|
| 868 |
+
{/*
|
| 869 |
+
* Input Area - Fixed at bottom with minimal visual separation.
|
| 870 |
+
* True Claude-style: very subtle top border, no heavy containers.
|
| 871 |
+
* The input blends naturally into the page.
|
| 872 |
+
*/}
|
| 873 |
+
<div
|
| 874 |
+
className={cn(
|
| 875 |
+
/* Fixed positioning - prevents shrinking */
|
| 876 |
+
'shrink-0',
|
| 877 |
+
/*
|
| 878 |
+
* Generous padding - provides breathing room without
|
| 879 |
+
* relying on heavy borders or backgrounds.
|
| 880 |
+
*/
|
| 881 |
+
'px-6 pt-4 pb-4',
|
| 882 |
+
/*
|
| 883 |
+
* Very subtle top border - minimal opacity for seamless design.
|
| 884 |
+
* Just enough visual separation to indicate the input area
|
| 885 |
+
* without creating a heavy boundary.
|
| 886 |
+
*/
|
| 887 |
+
'border-t border-[var(--border)]/20'
|
| 888 |
+
)}
|
| 889 |
+
>
|
| 890 |
+
<ChatInput
|
| 891 |
+
ref={chatInputRef}
|
| 892 |
+
onSubmit={handleSubmit}
|
| 893 |
+
isLoading={isLoading}
|
| 894 |
+
autoFocus={true}
|
| 895 |
+
/>
|
| 896 |
+
</div>
|
| 897 |
+
</div>
|
| 898 |
+
);
|
| 899 |
+
}
|
| 900 |
+
);
|
| 901 |
+
|
| 902 |
+
/* Display name for React DevTools debugging */
|
| 903 |
+
ChatContainer.displayName = 'ChatContainer';
|
| 904 |
+
|
| 905 |
+
export { ChatContainer };
|
frontend/src/components/chat/chat-input.tsx
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ChatInput Component
|
| 3 |
+
*
|
| 4 |
+
* A refined, modern chat input component inspired by Claude's design language.
|
| 5 |
+
* Features a clean, minimal aesthetic with subtle purple accents and smooth
|
| 6 |
+
* transitions for a premium user experience.
|
| 7 |
+
*
|
| 8 |
+
* @module components/chat/chat-input
|
| 9 |
+
* @since 1.0.0
|
| 10 |
+
*
|
| 11 |
+
* ## Design Philosophy
|
| 12 |
+
*
|
| 13 |
+
* This component follows Claude's design principles:
|
| 14 |
+
* - **Minimal and clean**: Reduced visual noise with purposeful whitespace
|
| 15 |
+
* - **Subtle interactions**: Gentle hover states and smooth transitions
|
| 16 |
+
* - **Focus on content**: The user's input is the hero, not the UI chrome
|
| 17 |
+
* - **Accessible by default**: High contrast ratios and clear focus states
|
| 18 |
+
*
|
| 19 |
+
* ## Visual Design Details
|
| 20 |
+
*
|
| 21 |
+
* ### Container
|
| 22 |
+
* - Clean white background with subtle border
|
| 23 |
+
* - Rounded corners (12px) for a friendly, modern feel
|
| 24 |
+
* - Soft shadow on focus for depth without being distracting
|
| 25 |
+
* - Purple glow effect on focus using box-shadow (not ring)
|
| 26 |
+
*
|
| 27 |
+
* ### Textarea
|
| 28 |
+
* - Borderless design that blends with the container
|
| 29 |
+
* - Muted placeholder text for visual hierarchy
|
| 30 |
+
* - Smooth auto-grow behavior without jarring size changes
|
| 31 |
+
*
|
| 32 |
+
* ### Send Button
|
| 33 |
+
* - Circular shape for visual distinction
|
| 34 |
+
* - Purple gradient background matching brand colors
|
| 35 |
+
* - Subtle scale animation on hover for tactile feedback
|
| 36 |
+
* - Clear disabled state without being visually jarring
|
| 37 |
+
*
|
| 38 |
+
* ### Supporting Elements
|
| 39 |
+
* - Character counter in subtle, smaller text
|
| 40 |
+
* - Keyboard hints in lighter color, smaller size
|
| 41 |
+
* - All supporting text uses muted colors to not compete with main input
|
| 42 |
+
*
|
| 43 |
+
* @example
|
| 44 |
+
* // Basic usage
|
| 45 |
+
* <ChatInput onSubmit={(message) => handleSubmit(message)} />
|
| 46 |
+
*
|
| 47 |
+
* @example
|
| 48 |
+
* // With loading state
|
| 49 |
+
* <ChatInput
|
| 50 |
+
* onSubmit={handleSubmit}
|
| 51 |
+
* isLoading={isProcessing}
|
| 52 |
+
* disabled={!isConnected}
|
| 53 |
+
* />
|
| 54 |
+
*
|
| 55 |
+
* @example
|
| 56 |
+
* // Custom placeholder and max length
|
| 57 |
+
* <ChatInput
|
| 58 |
+
* onSubmit={handleSubmit}
|
| 59 |
+
* placeholder="Type your question here..."
|
| 60 |
+
* maxLength={500}
|
| 61 |
+
* />
|
| 62 |
+
*/
|
| 63 |
+
|
| 64 |
+
'use client';
|
| 65 |
+
|
| 66 |
+
import {
|
| 67 |
+
forwardRef,
|
| 68 |
+
useCallback,
|
| 69 |
+
useEffect,
|
| 70 |
+
useImperativeHandle,
|
| 71 |
+
useRef,
|
| 72 |
+
useState,
|
| 73 |
+
type FormEvent,
|
| 74 |
+
type KeyboardEvent,
|
| 75 |
+
type ChangeEvent,
|
| 76 |
+
} from 'react';
|
| 77 |
+
import { Send } from 'lucide-react';
|
| 78 |
+
import { cn } from '@/lib/utils';
|
| 79 |
+
import { Spinner } from '@/components/ui/spinner';
|
| 80 |
+
import { ProviderSelector } from './provider-selector';
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Maximum height for the auto-growing textarea in pixels.
|
| 84 |
+
* After reaching this height, the textarea becomes scrollable.
|
| 85 |
+
* This value ensures the input doesn't dominate the viewport.
|
| 86 |
+
*/
|
| 87 |
+
const MAX_TEXTAREA_HEIGHT = 200;
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Minimum height for the textarea in pixels.
|
| 91 |
+
* Ensures consistent appearance even with empty content.
|
| 92 |
+
* Matches standard single-line input height for visual consistency.
|
| 93 |
+
*/
|
| 94 |
+
const MIN_TEXTAREA_HEIGHT = 56;
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Default character limit for messages.
|
| 98 |
+
* Can be overridden via the maxLength prop.
|
| 99 |
+
* 1000 characters balances detailed questions with API limits.
|
| 100 |
+
*/
|
| 101 |
+
const DEFAULT_MAX_LENGTH = 1000;
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* Default placeholder text for the input.
|
| 105 |
+
* Encourages users to ask questions in a friendly tone.
|
| 106 |
+
*/
|
| 107 |
+
const DEFAULT_PLACEHOLDER = 'Ask your question...';
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* Ref handle exposed by ChatInput for imperative control.
|
| 111 |
+
*
|
| 112 |
+
* Allows parent components to programmatically control the input,
|
| 113 |
+
* useful for accessibility features or external form management.
|
| 114 |
+
*/
|
| 115 |
+
export interface ChatInputHandle {
|
| 116 |
+
/** Focus the textarea input */
|
| 117 |
+
focus: () => void;
|
| 118 |
+
/** Clear the input value */
|
| 119 |
+
clear: () => void;
|
| 120 |
+
/** Get the current input value */
|
| 121 |
+
getValue: () => string;
|
| 122 |
+
/** Set the input value programmatically */
|
| 123 |
+
setValue: (value: string) => void;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* Props for the ChatInput component.
|
| 128 |
+
*
|
| 129 |
+
* Designed for flexibility with sensible defaults, supporting
|
| 130 |
+
* both controlled and uncontrolled usage patterns.
|
| 131 |
+
*/
|
| 132 |
+
export interface ChatInputProps {
|
| 133 |
+
/**
|
| 134 |
+
* Callback fired when the user submits a message.
|
| 135 |
+
* Called with the trimmed message content.
|
| 136 |
+
* The input is automatically cleared after submission.
|
| 137 |
+
*
|
| 138 |
+
* @param message - The trimmed message text
|
| 139 |
+
*/
|
| 140 |
+
onSubmit: (message: string) => void;
|
| 141 |
+
|
| 142 |
+
/**
|
| 143 |
+
* Whether the input is in a loading/processing state.
|
| 144 |
+
* Disables the input and shows a loading spinner in the submit button.
|
| 145 |
+
*
|
| 146 |
+
* @default false
|
| 147 |
+
*/
|
| 148 |
+
isLoading?: boolean;
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Whether the input is disabled.
|
| 152 |
+
* Separate from isLoading to allow disabling without loading indicator.
|
| 153 |
+
*
|
| 154 |
+
* @default false
|
| 155 |
+
*/
|
| 156 |
+
disabled?: boolean;
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Placeholder text for the textarea.
|
| 160 |
+
*
|
| 161 |
+
* @default "Ask a question about pythermalcomfort..."
|
| 162 |
+
*/
|
| 163 |
+
placeholder?: string;
|
| 164 |
+
|
| 165 |
+
/**
|
| 166 |
+
* Maximum character length for the input.
|
| 167 |
+
* Shows a character counter when approaching the limit.
|
| 168 |
+
*
|
| 169 |
+
* @default 1000
|
| 170 |
+
*/
|
| 171 |
+
maxLength?: number;
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* Whether to show the character count indicator.
|
| 175 |
+
* Shows when the user has typed more than 80% of maxLength.
|
| 176 |
+
*
|
| 177 |
+
* @default true
|
| 178 |
+
*/
|
| 179 |
+
showCharacterCount?: boolean;
|
| 180 |
+
|
| 181 |
+
/**
|
| 182 |
+
* Whether to auto-focus the input on mount.
|
| 183 |
+
* Useful for immediate input availability.
|
| 184 |
+
*
|
| 185 |
+
* @default true
|
| 186 |
+
*/
|
| 187 |
+
autoFocus?: boolean;
|
| 188 |
+
|
| 189 |
+
/**
|
| 190 |
+
* Additional CSS classes for the container.
|
| 191 |
+
*/
|
| 192 |
+
className?: string;
|
| 193 |
+
|
| 194 |
+
/**
|
| 195 |
+
* Additional CSS classes for the textarea element.
|
| 196 |
+
*/
|
| 197 |
+
textareaClassName?: string;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* ChatInput Component
|
| 202 |
+
*
|
| 203 |
+
* A feature-rich chat input component optimized for conversational interfaces
|
| 204 |
+
* with a refined, Claude-inspired visual design.
|
| 205 |
+
*
|
| 206 |
+
* @remarks
|
| 207 |
+
* ## Features
|
| 208 |
+
*
|
| 209 |
+
* ### Auto-Growing Textarea
|
| 210 |
+
* The textarea automatically grows as the user types, up to a maximum height
|
| 211 |
+
* of 200px. After reaching the max height, the content becomes scrollable.
|
| 212 |
+
* This provides a seamless typing experience without manual resizing.
|
| 213 |
+
*
|
| 214 |
+
* ### Keyboard Support
|
| 215 |
+
* - **Enter**: Submits the message (when not empty and not loading)
|
| 216 |
+
* - **Shift+Enter**: Inserts a new line (for multi-line messages)
|
| 217 |
+
* - Submission is blocked when the input is empty or whitespace-only
|
| 218 |
+
*
|
| 219 |
+
* ### Character Limit
|
| 220 |
+
* - Configurable maximum character limit (default: 1000)
|
| 221 |
+
* - Visual counter appears when approaching the limit (>80%)
|
| 222 |
+
* - Counter turns red when at or over the limit
|
| 223 |
+
* - Submission is blocked when over the limit
|
| 224 |
+
*
|
| 225 |
+
* ### Loading State
|
| 226 |
+
* - When isLoading is true, the textarea and button are disabled
|
| 227 |
+
* - The submit button shows a loading spinner instead of the send icon
|
| 228 |
+
* - Prevents accidental double-submission
|
| 229 |
+
*
|
| 230 |
+
* ### Accessibility
|
| 231 |
+
* - Proper labeling via aria-label on the textarea
|
| 232 |
+
* - Submit button has aria-label for screen readers
|
| 233 |
+
* - Disabled state is communicated via aria-disabled
|
| 234 |
+
* - Character counter is announced to screen readers
|
| 235 |
+
*
|
| 236 |
+
* ## Performance
|
| 237 |
+
* - Uses requestAnimationFrame for smooth height calculations
|
| 238 |
+
* - Memoized callbacks to prevent unnecessary re-renders
|
| 239 |
+
* - Auto-focus uses useEffect to avoid hydration mismatches
|
| 240 |
+
*
|
| 241 |
+
* @param props - ChatInput component props
|
| 242 |
+
* @returns React element containing the chat input form
|
| 243 |
+
*
|
| 244 |
+
* @see {@link ChatInputProps} for full prop documentation
|
| 245 |
+
* @see {@link ChatInputHandle} for imperative API
|
| 246 |
+
*/
|
| 247 |
+
const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
| 248 |
+
(
|
| 249 |
+
{
|
| 250 |
+
onSubmit,
|
| 251 |
+
isLoading = false,
|
| 252 |
+
disabled = false,
|
| 253 |
+
placeholder = DEFAULT_PLACEHOLDER,
|
| 254 |
+
maxLength = DEFAULT_MAX_LENGTH,
|
| 255 |
+
showCharacterCount = true,
|
| 256 |
+
autoFocus = true,
|
| 257 |
+
className,
|
| 258 |
+
textareaClassName,
|
| 259 |
+
},
|
| 260 |
+
ref
|
| 261 |
+
) => {
|
| 262 |
+
/**
|
| 263 |
+
* Internal state for the input value.
|
| 264 |
+
* Controlled internally with external access via ref handle.
|
| 265 |
+
*/
|
| 266 |
+
const [value, setValue] = useState('');
|
| 267 |
+
|
| 268 |
+
/**
|
| 269 |
+
* Track focus state for container styling.
|
| 270 |
+
* Used to apply the purple glow effect on focus.
|
| 271 |
+
*/
|
| 272 |
+
const [isFocused, setIsFocused] = useState(false);
|
| 273 |
+
|
| 274 |
+
/**
|
| 275 |
+
* Ref to the textarea element for height calculations and focus.
|
| 276 |
+
*/
|
| 277 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 278 |
+
|
| 279 |
+
/**
|
| 280 |
+
* Computed values for UI state
|
| 281 |
+
*/
|
| 282 |
+
const characterCount = value.length;
|
| 283 |
+
const isOverLimit = characterCount > maxLength;
|
| 284 |
+
const isNearLimit = characterCount > maxLength * 0.8;
|
| 285 |
+
const isEmpty = value.trim().length === 0;
|
| 286 |
+
const isDisabled = disabled || isLoading;
|
| 287 |
+
const canSubmit = !isEmpty && !isOverLimit && !isDisabled;
|
| 288 |
+
|
| 289 |
+
/**
|
| 290 |
+
* Reset textarea height to minimum.
|
| 291 |
+
* Called after submission or clearing.
|
| 292 |
+
*
|
| 293 |
+
* Declared before useImperativeHandle since it's used in the handle.
|
| 294 |
+
*/
|
| 295 |
+
const resetTextareaHeight = useCallback(() => {
|
| 296 |
+
if (textareaRef.current) {
|
| 297 |
+
textareaRef.current.style.height = `${MIN_TEXTAREA_HEIGHT}px`;
|
| 298 |
+
}
|
| 299 |
+
}, []);
|
| 300 |
+
|
| 301 |
+
/**
|
| 302 |
+
* Adjust textarea height based on content.
|
| 303 |
+
*
|
| 304 |
+
* Algorithm:
|
| 305 |
+
* 1. Reset height to auto to get accurate scrollHeight
|
| 306 |
+
* 2. Set height to scrollHeight, clamped to min/max bounds
|
| 307 |
+
*
|
| 308 |
+
* Uses direct style manipulation for performance (avoids re-render).
|
| 309 |
+
* Declared before useImperativeHandle since it's used in the handle.
|
| 310 |
+
*/
|
| 311 |
+
const adjustTextareaHeight = useCallback(() => {
|
| 312 |
+
const textarea = textareaRef.current;
|
| 313 |
+
if (!textarea) return;
|
| 314 |
+
|
| 315 |
+
// Reset height to recalculate scrollHeight accurately
|
| 316 |
+
textarea.style.height = 'auto';
|
| 317 |
+
|
| 318 |
+
// Calculate new height within bounds
|
| 319 |
+
const newHeight = Math.min(
|
| 320 |
+
Math.max(textarea.scrollHeight, MIN_TEXTAREA_HEIGHT),
|
| 321 |
+
MAX_TEXTAREA_HEIGHT
|
| 322 |
+
);
|
| 323 |
+
|
| 324 |
+
textarea.style.height = `${newHeight}px`;
|
| 325 |
+
}, []);
|
| 326 |
+
|
| 327 |
+
/**
|
| 328 |
+
* Expose imperative handle for parent component control.
|
| 329 |
+
*
|
| 330 |
+
* This allows parent components to:
|
| 331 |
+
* - Focus the input (e.g., after closing a modal)
|
| 332 |
+
* - Clear the input (e.g., on conversation reset)
|
| 333 |
+
* - Get/set value (e.g., for draft restoration)
|
| 334 |
+
*/
|
| 335 |
+
useImperativeHandle(
|
| 336 |
+
ref,
|
| 337 |
+
() => ({
|
| 338 |
+
focus: () => {
|
| 339 |
+
textareaRef.current?.focus();
|
| 340 |
+
},
|
| 341 |
+
clear: () => {
|
| 342 |
+
setValue('');
|
| 343 |
+
resetTextareaHeight();
|
| 344 |
+
},
|
| 345 |
+
getValue: () => value,
|
| 346 |
+
setValue: (newValue: string) => {
|
| 347 |
+
setValue(newValue);
|
| 348 |
+
// Defer height calculation to after state update
|
| 349 |
+
requestAnimationFrame(adjustTextareaHeight);
|
| 350 |
+
},
|
| 351 |
+
}),
|
| 352 |
+
[value, resetTextareaHeight, adjustTextareaHeight]
|
| 353 |
+
);
|
| 354 |
+
|
| 355 |
+
/**
|
| 356 |
+
* Handle input value changes.
|
| 357 |
+
* Updates state and adjusts textarea height.
|
| 358 |
+
*/
|
| 359 |
+
const handleChange = useCallback(
|
| 360 |
+
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
| 361 |
+
setValue(event.target.value);
|
| 362 |
+
// Defer to next frame for accurate height calculation
|
| 363 |
+
requestAnimationFrame(adjustTextareaHeight);
|
| 364 |
+
},
|
| 365 |
+
[adjustTextareaHeight]
|
| 366 |
+
);
|
| 367 |
+
|
| 368 |
+
/**
|
| 369 |
+
* Handle form submission.
|
| 370 |
+
* Validates input, calls onSubmit, and clears the input.
|
| 371 |
+
*/
|
| 372 |
+
const handleSubmit = useCallback(
|
| 373 |
+
(event?: FormEvent) => {
|
| 374 |
+
event?.preventDefault();
|
| 375 |
+
|
| 376 |
+
// Validate submission conditions
|
| 377 |
+
if (!canSubmit) return;
|
| 378 |
+
|
| 379 |
+
// Get trimmed value for submission
|
| 380 |
+
const trimmedValue = value.trim();
|
| 381 |
+
|
| 382 |
+
// Call parent callback
|
| 383 |
+
onSubmit(trimmedValue);
|
| 384 |
+
|
| 385 |
+
// Clear input and reset height
|
| 386 |
+
setValue('');
|
| 387 |
+
resetTextareaHeight();
|
| 388 |
+
|
| 389 |
+
// Refocus textarea for continued conversation
|
| 390 |
+
requestAnimationFrame(() => {
|
| 391 |
+
textareaRef.current?.focus();
|
| 392 |
+
});
|
| 393 |
+
},
|
| 394 |
+
[canSubmit, value, onSubmit, resetTextareaHeight]
|
| 395 |
+
);
|
| 396 |
+
|
| 397 |
+
/**
|
| 398 |
+
* Handle keyboard events for submission.
|
| 399 |
+
*
|
| 400 |
+
* - Enter without Shift: Submit the message
|
| 401 |
+
* - Shift+Enter: Insert new line (default behavior)
|
| 402 |
+
*/
|
| 403 |
+
const handleKeyDown = useCallback(
|
| 404 |
+
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
| 405 |
+
// Only handle Enter key
|
| 406 |
+
if (event.key !== 'Enter') return;
|
| 407 |
+
|
| 408 |
+
// Shift+Enter: Allow default new line behavior
|
| 409 |
+
if (event.shiftKey) return;
|
| 410 |
+
|
| 411 |
+
// Prevent default to avoid new line insertion
|
| 412 |
+
event.preventDefault();
|
| 413 |
+
|
| 414 |
+
// Submit if conditions are met
|
| 415 |
+
handleSubmit();
|
| 416 |
+
},
|
| 417 |
+
[handleSubmit]
|
| 418 |
+
);
|
| 419 |
+
|
| 420 |
+
/**
|
| 421 |
+
* Auto-focus effect on mount.
|
| 422 |
+
* Wrapped in useEffect to avoid hydration mismatches
|
| 423 |
+
* and respect the autoFocus prop.
|
| 424 |
+
*/
|
| 425 |
+
useEffect(() => {
|
| 426 |
+
if (autoFocus && textareaRef.current) {
|
| 427 |
+
// Small delay to ensure DOM is ready
|
| 428 |
+
const timeoutId = setTimeout(() => {
|
| 429 |
+
textareaRef.current?.focus();
|
| 430 |
+
}, 100);
|
| 431 |
+
|
| 432 |
+
return () => clearTimeout(timeoutId);
|
| 433 |
+
}
|
| 434 |
+
}, [autoFocus]);
|
| 435 |
+
|
| 436 |
+
/**
|
| 437 |
+
* Calculate character count display text.
|
| 438 |
+
* Only shown when approaching or exceeding the limit.
|
| 439 |
+
*/
|
| 440 |
+
const characterCountText = `${characterCount}/${maxLength}`;
|
| 441 |
+
|
| 442 |
+
return (
|
| 443 |
+
<form
|
| 444 |
+
onSubmit={handleSubmit}
|
| 445 |
+
className={cn(
|
| 446 |
+
/* Container styling - vertical stack with tight spacing */
|
| 447 |
+
'flex flex-col gap-1.5',
|
| 448 |
+
className
|
| 449 |
+
)}
|
| 450 |
+
>
|
| 451 |
+
{/*
|
| 452 |
+
* Main Input Container
|
| 453 |
+
*
|
| 454 |
+
* Design: A prominent, refined container that serves as the visual
|
| 455 |
+
* anchor for the input area. Uses subtle rounded borders and
|
| 456 |
+
* a clean background that elevates slightly on focus.
|
| 457 |
+
*
|
| 458 |
+
* Focus State: Instead of the typical focus ring, we use a soft
|
| 459 |
+
* purple glow effect via box-shadow. This creates a more refined,
|
| 460 |
+
* less jarring focus indicator that feels premium.
|
| 461 |
+
*/}
|
| 462 |
+
<div
|
| 463 |
+
className={cn(
|
| 464 |
+
/* Base layout - flex with bottom alignment for button */
|
| 465 |
+
'flex items-end gap-3',
|
| 466 |
+
/* Padding - comfortable internal spacing */
|
| 467 |
+
'p-3',
|
| 468 |
+
/* Background - clean white/background color */
|
| 469 |
+
'bg-white dark:bg-[var(--background)]',
|
| 470 |
+
/* Border - subtle rounded border */
|
| 471 |
+
'border border-gray-200 dark:border-gray-700',
|
| 472 |
+
'rounded-xl',
|
| 473 |
+
/* Base shadow - subtle elevation */
|
| 474 |
+
'shadow-sm',
|
| 475 |
+
/*
|
| 476 |
+
* Focus state styles
|
| 477 |
+
*
|
| 478 |
+
* Uses box-shadow for the purple glow effect instead of ring.
|
| 479 |
+
* The shadow has two layers:
|
| 480 |
+
* 1. Outer glow: Purple tint with low opacity for the glow effect
|
| 481 |
+
* 2. Inner shadow: Subtle shadow for depth
|
| 482 |
+
*
|
| 483 |
+
* Border transitions to a purple tint on focus.
|
| 484 |
+
*/
|
| 485 |
+
isFocused && [
|
| 486 |
+
'border-purple-400 dark:border-purple-500',
|
| 487 |
+
'shadow-[0_0_0_3px_rgba(168,85,247,0.1),0_1px_2px_rgba(0,0,0,0.05)]',
|
| 488 |
+
'dark:shadow-[0_0_0_3px_rgba(168,85,247,0.15),0_1px_2px_rgba(0,0,0,0.1)]',
|
| 489 |
+
],
|
| 490 |
+
/* Smooth transition for all interactive states */
|
| 491 |
+
'transition-all duration-200 ease-out',
|
| 492 |
+
/* Disabled state - reduced opacity but not jarring */
|
| 493 |
+
isDisabled && 'opacity-60 cursor-not-allowed'
|
| 494 |
+
)}
|
| 495 |
+
>
|
| 496 |
+
{/*
|
| 497 |
+
* Textarea
|
| 498 |
+
*
|
| 499 |
+
* Design: Clean, minimal textarea without internal borders.
|
| 500 |
+
* Blends seamlessly with the container to feel like one unit.
|
| 501 |
+
* Placeholder uses muted color for visual hierarchy.
|
| 502 |
+
*/}
|
| 503 |
+
<textarea
|
| 504 |
+
ref={textareaRef}
|
| 505 |
+
value={value}
|
| 506 |
+
onChange={handleChange}
|
| 507 |
+
onKeyDown={handleKeyDown}
|
| 508 |
+
onFocus={() => setIsFocused(true)}
|
| 509 |
+
onBlur={() => setIsFocused(false)}
|
| 510 |
+
placeholder={placeholder}
|
| 511 |
+
disabled={isDisabled}
|
| 512 |
+
aria-label="Message input"
|
| 513 |
+
aria-describedby={
|
| 514 |
+
showCharacterCount && isNearLimit ? 'char-count' : undefined
|
| 515 |
+
}
|
| 516 |
+
aria-invalid={isOverLimit}
|
| 517 |
+
rows={1}
|
| 518 |
+
className={cn(
|
| 519 |
+
/* Reset default textarea styles completely */
|
| 520 |
+
'resize-none',
|
| 521 |
+
'appearance-none',
|
| 522 |
+
'border-0 border-none',
|
| 523 |
+
'bg-transparent',
|
| 524 |
+
'outline-none outline-0 outline-transparent',
|
| 525 |
+
'shadow-none',
|
| 526 |
+
'ring-0 ring-transparent ring-offset-0',
|
| 527 |
+
/* Remove ALL focus indicators - container handles focus styling */
|
| 528 |
+
'focus:ring-0 focus:ring-transparent focus:ring-offset-0',
|
| 529 |
+
'focus:outline-none focus:outline-0 focus:outline-transparent',
|
| 530 |
+
'focus:border-0 focus:border-none focus:border-transparent',
|
| 531 |
+
'focus:shadow-none',
|
| 532 |
+
'focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0',
|
| 533 |
+
'focus-visible:outline-none focus-visible:outline-0 focus-visible:outline-transparent',
|
| 534 |
+
'focus-visible:border-0 focus-visible:border-none',
|
| 535 |
+
/* WebKit-specific resets */
|
| 536 |
+
'[&:focus]:outline-none [&:focus]:ring-0 [&:focus]:border-none',
|
| 537 |
+
/* Sizing - flex to fill available space */
|
| 538 |
+
'flex-1',
|
| 539 |
+
'min-h-[56px]',
|
| 540 |
+
'max-h-[200px]',
|
| 541 |
+
/* Padding - comfortable typing area */
|
| 542 |
+
'py-2',
|
| 543 |
+
'px-1',
|
| 544 |
+
/* Typography - clean, readable text */
|
| 545 |
+
'text-[15px]',
|
| 546 |
+
'leading-relaxed',
|
| 547 |
+
'text-gray-900 dark:text-gray-100',
|
| 548 |
+
/* Placeholder - muted color for visual hierarchy */
|
| 549 |
+
'placeholder:text-gray-400 dark:placeholder:text-gray-500',
|
| 550 |
+
/* Scrollbar for overflow content */
|
| 551 |
+
'overflow-y-auto',
|
| 552 |
+
/* Disabled state */
|
| 553 |
+
'disabled:cursor-not-allowed',
|
| 554 |
+
textareaClassName
|
| 555 |
+
)}
|
| 556 |
+
style={{
|
| 557 |
+
height: `${MIN_TEXTAREA_HEIGHT}px`,
|
| 558 |
+
outline: 'none',
|
| 559 |
+
boxShadow: 'none',
|
| 560 |
+
}}
|
| 561 |
+
/>
|
| 562 |
+
|
| 563 |
+
{/*
|
| 564 |
+
* Send Button
|
| 565 |
+
*
|
| 566 |
+
* Design: Circular button with purple gradient background.
|
| 567 |
+
* Stands out as the primary action without being visually heavy.
|
| 568 |
+
*
|
| 569 |
+
* Hover: Subtle scale effect (105%) with soft shadow for
|
| 570 |
+
* tactile feedback that doesn't feel like a 90s web button.
|
| 571 |
+
*
|
| 572 |
+
* Disabled: Clearly visible but not jarring - reduced opacity
|
| 573 |
+
* and no pointer events.
|
| 574 |
+
*/}
|
| 575 |
+
<button
|
| 576 |
+
type="submit"
|
| 577 |
+
disabled={!canSubmit}
|
| 578 |
+
aria-label={isLoading ? 'Sending message...' : 'Send message'}
|
| 579 |
+
className={cn(
|
| 580 |
+
/* Base sizing - circular button */
|
| 581 |
+
'shrink-0',
|
| 582 |
+
'h-10 w-10',
|
| 583 |
+
'rounded-full',
|
| 584 |
+
/* Flexbox centering for icon */
|
| 585 |
+
'flex items-center justify-center',
|
| 586 |
+
/* Background - purple gradient for brand consistency */
|
| 587 |
+
'bg-purple-600',
|
| 588 |
+
'hover:bg-purple-700',
|
| 589 |
+
/* Text/icon color */
|
| 590 |
+
'text-white',
|
| 591 |
+
/*
|
| 592 |
+
* Hover effect
|
| 593 |
+
*
|
| 594 |
+
* Subtle scale (105%) creates tactile feedback.
|
| 595 |
+
* Soft shadow adds depth without being heavy.
|
| 596 |
+
* Active state scales down slightly for press feedback.
|
| 597 |
+
*/
|
| 598 |
+
!isDisabled && [
|
| 599 |
+
'hover:scale-105',
|
| 600 |
+
'hover:shadow-md',
|
| 601 |
+
'hover:shadow-purple-500/25',
|
| 602 |
+
'active:scale-95',
|
| 603 |
+
],
|
| 604 |
+
/* Smooth transition for all states */
|
| 605 |
+
'transition-all duration-200 ease-out',
|
| 606 |
+
/*
|
| 607 |
+
* Disabled state
|
| 608 |
+
*
|
| 609 |
+
* Reduced opacity makes it clearly disabled.
|
| 610 |
+
* Gray background instead of purple to indicate inactive.
|
| 611 |
+
* Cursor changes to indicate non-interactive.
|
| 612 |
+
*/
|
| 613 |
+
!canSubmit && [
|
| 614 |
+
'opacity-40',
|
| 615 |
+
'bg-gray-400 dark:bg-gray-600',
|
| 616 |
+
'cursor-not-allowed',
|
| 617 |
+
'hover:scale-100',
|
| 618 |
+
'hover:shadow-none',
|
| 619 |
+
],
|
| 620 |
+
/* Focus visible for keyboard navigation */
|
| 621 |
+
'focus:outline-none',
|
| 622 |
+
'focus-visible:ring-2',
|
| 623 |
+
'focus-visible:ring-purple-500',
|
| 624 |
+
'focus-visible:ring-offset-2'
|
| 625 |
+
)}
|
| 626 |
+
>
|
| 627 |
+
{isLoading ? (
|
| 628 |
+
/* Loading spinner when processing */
|
| 629 |
+
<Spinner
|
| 630 |
+
size="sm"
|
| 631 |
+
color="white"
|
| 632 |
+
spinnerStyle="ring"
|
| 633 |
+
label="Sending..."
|
| 634 |
+
/>
|
| 635 |
+
) : (
|
| 636 |
+
/* Send icon - slightly rotated for visual interest */
|
| 637 |
+
<Send className="h-4 w-4" strokeWidth={2} />
|
| 638 |
+
)}
|
| 639 |
+
</button>
|
| 640 |
+
</div>
|
| 641 |
+
|
| 642 |
+
{/* Footer row - provider selector, keyboard hints, and character count */}
|
| 643 |
+
<div className="flex items-center justify-between px-1">
|
| 644 |
+
{/* Left side: Provider selector and keyboard hints */}
|
| 645 |
+
<div className="flex items-center gap-3">
|
| 646 |
+
{/*
|
| 647 |
+
* Provider Selector - Claude-style model dropdown
|
| 648 |
+
*
|
| 649 |
+
* Positioned on the left side of the footer, similar to
|
| 650 |
+
* how Claude shows the model selector in the input area.
|
| 651 |
+
*/}
|
| 652 |
+
<ProviderSelector />
|
| 653 |
+
|
| 654 |
+
{/*
|
| 655 |
+
* Keyboard Hints
|
| 656 |
+
*
|
| 657 |
+
* Design: Small, subtle text that doesn't compete with the
|
| 658 |
+
* main input. Uses lighter color and smaller font size.
|
| 659 |
+
* Hidden on mobile to save space.
|
| 660 |
+
*/}
|
| 661 |
+
<div
|
| 662 |
+
className={cn(
|
| 663 |
+
/* Typography - smaller and lighter */
|
| 664 |
+
'text-[11px]',
|
| 665 |
+
'text-gray-400 dark:text-gray-500',
|
| 666 |
+
/* Hide on small screens - not essential info */
|
| 667 |
+
'hidden sm:block'
|
| 668 |
+
)}
|
| 669 |
+
>
|
| 670 |
+
<kbd className="rounded bg-gray-100 dark:bg-gray-800 px-1 py-0.5 font-mono text-[10px] text-gray-500 dark:text-gray-400">
|
| 671 |
+
Enter
|
| 672 |
+
</kbd>
|
| 673 |
+
<span className="mx-1">to send</span>
|
| 674 |
+
<kbd className="rounded bg-gray-100 dark:bg-gray-800 px-1 py-0.5 font-mono text-[10px] text-gray-500 dark:text-gray-400">
|
| 675 |
+
Shift+Enter
|
| 676 |
+
</kbd>
|
| 677 |
+
<span className="ml-1">for new line</span>
|
| 678 |
+
</div>
|
| 679 |
+
</div>
|
| 680 |
+
|
| 681 |
+
{/*
|
| 682 |
+
* Character Counter (right side)
|
| 683 |
+
*
|
| 684 |
+
* Design: Subtle indicator that appears when approaching
|
| 685 |
+
* the character limit. Uses smaller text and lighter color
|
| 686 |
+
* to not distract from the main input.
|
| 687 |
+
*
|
| 688 |
+
* Over limit: Turns red to clearly indicate the error state.
|
| 689 |
+
*/}
|
| 690 |
+
{showCharacterCount && isNearLimit && (
|
| 691 |
+
<div
|
| 692 |
+
id="char-count"
|
| 693 |
+
role="status"
|
| 694 |
+
aria-live="polite"
|
| 695 |
+
className={cn(
|
| 696 |
+
/* Typography - small and subtle */
|
| 697 |
+
'text-[11px]',
|
| 698 |
+
/* Color based on limit status */
|
| 699 |
+
isOverLimit
|
| 700 |
+
? 'text-red-500 dark:text-red-400'
|
| 701 |
+
: 'text-gray-400 dark:text-gray-500',
|
| 702 |
+
/* Smooth fade-in animation */
|
| 703 |
+
'animate-fade-in'
|
| 704 |
+
)}
|
| 705 |
+
>
|
| 706 |
+
{characterCountText}
|
| 707 |
+
{isOverLimit && (
|
| 708 |
+
<span className="ml-1 font-medium">(exceeds limit)</span>
|
| 709 |
+
)}
|
| 710 |
+
</div>
|
| 711 |
+
)}
|
| 712 |
+
</div>
|
| 713 |
+
</form>
|
| 714 |
+
);
|
| 715 |
+
}
|
| 716 |
+
);
|
| 717 |
+
|
| 718 |
+
/* Display name for React DevTools */
|
| 719 |
+
ChatInput.displayName = 'ChatInput';
|
| 720 |
+
|
| 721 |
+
export { ChatInput };
|
frontend/src/components/chat/chat-message.tsx
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ChatMessage Component
|
| 3 |
+
*
|
| 4 |
+
* A Claude-inspired chat message component that displays messages in a
|
| 5 |
+
* conversational format with proper alignment:
|
| 6 |
+
* - User messages: Right-aligned with contained width (bubble style)
|
| 7 |
+
* - Assistant messages: Left-aligned, full-width, minimal styling
|
| 8 |
+
*
|
| 9 |
+
* Design Philosophy (Claude UI Principles):
|
| 10 |
+
* =========================================
|
| 11 |
+
* 1. Right-aligned user messages - Creates natural conversation flow
|
| 12 |
+
* 2. Left-aligned assistant messages - Full-width for content focus
|
| 13 |
+
* 3. Clean typography - Focus on readability with proper line-height
|
| 14 |
+
* 4. Minimal chrome - Let the content speak for itself
|
| 15 |
+
*
|
| 16 |
+
* @module components/chat/chat-message
|
| 17 |
+
* @since 1.0.0
|
| 18 |
+
*
|
| 19 |
+
* @example
|
| 20 |
+
* // User message (right-aligned with purple background)
|
| 21 |
+
* <ChatMessage
|
| 22 |
+
* message={{
|
| 23 |
+
* id: 'msg-1',
|
| 24 |
+
* role: 'user',
|
| 25 |
+
* content: 'What is PMV?',
|
| 26 |
+
* timestamp: new Date(),
|
| 27 |
+
* }}
|
| 28 |
+
* />
|
| 29 |
+
*
|
| 30 |
+
* @example
|
| 31 |
+
* // Assistant message with streaming cursor (left-aligned, clean)
|
| 32 |
+
* <ChatMessage
|
| 33 |
+
* message={{
|
| 34 |
+
* id: 'msg-2',
|
| 35 |
+
* role: 'assistant',
|
| 36 |
+
* content: 'PMV stands for Predicted Mean Vote...',
|
| 37 |
+
* timestamp: new Date(),
|
| 38 |
+
* isStreaming: true,
|
| 39 |
+
* }}
|
| 40 |
+
* />
|
| 41 |
+
*
|
| 42 |
+
* @example
|
| 43 |
+
* // Assistant message with sources
|
| 44 |
+
* <ChatMessage
|
| 45 |
+
* message={{
|
| 46 |
+
* id: 'msg-3',
|
| 47 |
+
* role: 'assistant',
|
| 48 |
+
* content: 'The comfort zone is defined as...',
|
| 49 |
+
* timestamp: new Date(),
|
| 50 |
+
* sources: [{ id: '1', headingPath: 'Chapter 1', page: 5, text: '...' }],
|
| 51 |
+
* }}
|
| 52 |
+
* />
|
| 53 |
+
*/
|
| 54 |
+
|
| 55 |
+
'use client';
|
| 56 |
+
|
| 57 |
+
import { forwardRef, type HTMLAttributes, memo } from 'react';
|
| 58 |
+
import { User, Sparkles } from 'lucide-react';
|
| 59 |
+
import ReactMarkdown from 'react-markdown';
|
| 60 |
+
import remarkGfm from 'remark-gfm';
|
| 61 |
+
import rehypeHighlight from 'rehype-highlight';
|
| 62 |
+
import { cn } from '@/lib/utils';
|
| 63 |
+
import { formatRelativeTime } from '@/lib/utils';
|
| 64 |
+
import type { Message } from '@/types';
|
| 65 |
+
import { MemoizedSourceCitations } from './source-citations';
|
| 66 |
+
import { CodeBlock } from './code-block';
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Props for the ChatMessage component.
|
| 70 |
+
*
|
| 71 |
+
* Extends standard HTML div attributes for flexibility in styling
|
| 72 |
+
* and event handling, while requiring the core message data.
|
| 73 |
+
*/
|
| 74 |
+
export interface ChatMessageProps extends Omit<
|
| 75 |
+
HTMLAttributes<HTMLDivElement>,
|
| 76 |
+
'content'
|
| 77 |
+
> {
|
| 78 |
+
/**
|
| 79 |
+
* The message object containing all message data.
|
| 80 |
+
* Includes role, content, timestamp, and optional sources/streaming state.
|
| 81 |
+
*/
|
| 82 |
+
message: Message;
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Whether to show the timestamp below the message.
|
| 86 |
+
* Useful to hide timestamps in rapid streaming scenarios.
|
| 87 |
+
*
|
| 88 |
+
* @default true
|
| 89 |
+
*/
|
| 90 |
+
showTimestamp?: boolean;
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Custom CSS class for the message content area.
|
| 94 |
+
* Separate from the container className for precise styling.
|
| 95 |
+
*/
|
| 96 |
+
bubbleClassName?: string;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* StreamingCursor Component
|
| 101 |
+
*
|
| 102 |
+
* Renders an animated blinking cursor to indicate that the assistant
|
| 103 |
+
* is still generating content. Uses CSS animation for smooth blinking.
|
| 104 |
+
* The cursor uses a purple tint to match the brand identity.
|
| 105 |
+
*
|
| 106 |
+
* @internal
|
| 107 |
+
*/
|
| 108 |
+
function StreamingCursor(): React.ReactElement {
|
| 109 |
+
return (
|
| 110 |
+
<span
|
| 111 |
+
className="ml-0.5 inline-block h-4 w-2 animate-[cursor-blink_1s_ease-in-out_infinite] bg-[var(--color-primary-500)] align-middle"
|
| 112 |
+
aria-hidden="true"
|
| 113 |
+
>
|
| 114 |
+
<style>{`
|
| 115 |
+
@keyframes cursor-blink {
|
| 116 |
+
0%, 50% { opacity: 1; }
|
| 117 |
+
51%, 100% { opacity: 0; }
|
| 118 |
+
}
|
| 119 |
+
`}</style>
|
| 120 |
+
</span>
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* MessageAvatar Component
|
| 126 |
+
*
|
| 127 |
+
* Renders the avatar icon for either user or assistant messages.
|
| 128 |
+
* Uses a minimal design approach:
|
| 129 |
+
* - User: Small purple circle with user icon (maintains brand identity)
|
| 130 |
+
* - Assistant: Simple sparkle icon without circle background (minimal chrome)
|
| 131 |
+
*
|
| 132 |
+
* Avatars are smaller (h-6 w-6) and positioned at the top-left of messages
|
| 133 |
+
* for a cleaner, more document-like reading experience.
|
| 134 |
+
*
|
| 135 |
+
* @param role - The role of the message sender ('user' or 'assistant')
|
| 136 |
+
* @returns Avatar icon element
|
| 137 |
+
*
|
| 138 |
+
* @internal
|
| 139 |
+
*/
|
| 140 |
+
function MessageAvatar({
|
| 141 |
+
role,
|
| 142 |
+
}: {
|
| 143 |
+
role: Message['role'];
|
| 144 |
+
}): React.ReactElement {
|
| 145 |
+
const isUser = role === 'user';
|
| 146 |
+
|
| 147 |
+
return (
|
| 148 |
+
<div
|
| 149 |
+
className={cn(
|
| 150 |
+
/* Base avatar styles - smaller for minimal design */
|
| 151 |
+
'flex items-center justify-center shrink-0',
|
| 152 |
+
/* Size: smaller h-6 w-6 for subtlety */
|
| 153 |
+
'h-6 w-6',
|
| 154 |
+
/* User gets a purple circle background; assistant has no background */
|
| 155 |
+
isUser
|
| 156 |
+
? 'rounded-full bg-[var(--color-primary-500)]'
|
| 157 |
+
: '' /* No background for assistant - minimal chrome */
|
| 158 |
+
)}
|
| 159 |
+
aria-hidden="true"
|
| 160 |
+
>
|
| 161 |
+
{isUser ? (
|
| 162 |
+
<User className="h-3.5 w-3.5 text-white" strokeWidth={2.5} />
|
| 163 |
+
) : (
|
| 164 |
+
<Sparkles
|
| 165 |
+
className="h-5 w-5 text-[var(--color-primary-500)]"
|
| 166 |
+
strokeWidth={2}
|
| 167 |
+
/>
|
| 168 |
+
)}
|
| 169 |
+
</div>
|
| 170 |
+
);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* MessageContent Component
|
| 175 |
+
*
|
| 176 |
+
* Renders the message text content using React Markdown with enhanced
|
| 177 |
+
* typography for better readability. Supports GFM (tables, lists, etc.)
|
| 178 |
+
* and syntax highlighting.
|
| 179 |
+
*
|
| 180 |
+
* Typography enhancements:
|
| 181 |
+
* - Slightly larger prose size for comfortable reading
|
| 182 |
+
* - Proper line-height for long-form content
|
| 183 |
+
* - Purple-colored links for brand consistency
|
| 184 |
+
*
|
| 185 |
+
* @param content - The text content of the message
|
| 186 |
+
* @param isStreaming - Whether the message is still being streamed
|
| 187 |
+
* @param isUser - Whether this is a user message (affects link styling)
|
| 188 |
+
* @returns Formatted message content element
|
| 189 |
+
*
|
| 190 |
+
* @internal
|
| 191 |
+
*/
|
| 192 |
+
function MessageContent({
|
| 193 |
+
content,
|
| 194 |
+
isStreaming = false,
|
| 195 |
+
isUser = false,
|
| 196 |
+
}: {
|
| 197 |
+
content: string;
|
| 198 |
+
isStreaming?: boolean;
|
| 199 |
+
isUser?: boolean;
|
| 200 |
+
}): React.ReactElement {
|
| 201 |
+
return (
|
| 202 |
+
<div
|
| 203 |
+
className={cn(
|
| 204 |
+
'relative',
|
| 205 |
+
/* Enhanced prose typography - slightly larger for readability */
|
| 206 |
+
'prose prose-base max-w-none',
|
| 207 |
+
/* User messages: light text on purple; Assistant: dark prose with invert */
|
| 208 |
+
isUser ? 'prose-invert' : 'dark:prose-invert',
|
| 209 |
+
/* Proper line-height for long-form content */
|
| 210 |
+
'leading-relaxed',
|
| 211 |
+
/* Color overrides to inherit from parent (important for user white text) */
|
| 212 |
+
'prose-p:text-[inherit]',
|
| 213 |
+
'prose-headings:text-[inherit]',
|
| 214 |
+
'prose-strong:text-[inherit]',
|
| 215 |
+
'prose-ul:text-[inherit]',
|
| 216 |
+
'prose-ol:text-[inherit]',
|
| 217 |
+
'prose-code:text-[inherit]',
|
| 218 |
+
/* Spacing adjustments */
|
| 219 |
+
'prose-p:my-2',
|
| 220 |
+
'prose-headings:my-3',
|
| 221 |
+
'prose-ul:my-2',
|
| 222 |
+
'prose-ol:my-2',
|
| 223 |
+
'prose-li:my-0.5',
|
| 224 |
+
/* Break words */
|
| 225 |
+
'break-words'
|
| 226 |
+
)}
|
| 227 |
+
>
|
| 228 |
+
<ReactMarkdown
|
| 229 |
+
remarkPlugins={[remarkGfm]}
|
| 230 |
+
rehypePlugins={[rehypeHighlight]}
|
| 231 |
+
components={{
|
| 232 |
+
// Use our custom CodeBlock component for code blocks
|
| 233 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
|
| 234 |
+
code: ({ node, inline, className, children, ...props }: any) => {
|
| 235 |
+
return (
|
| 236 |
+
<CodeBlock
|
| 237 |
+
inline={inline}
|
| 238 |
+
className={className}
|
| 239 |
+
{...props}
|
| 240 |
+
>
|
| 241 |
+
{children}
|
| 242 |
+
</CodeBlock>
|
| 243 |
+
);
|
| 244 |
+
},
|
| 245 |
+
// Custom styling for links
|
| 246 |
+
// User messages: light underlined links on purple background
|
| 247 |
+
// Assistant messages: purple links for brand consistency
|
| 248 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
|
| 249 |
+
a: ({ node, className, children, ...props }: any) => (
|
| 250 |
+
<a
|
| 251 |
+
className={cn(
|
| 252 |
+
isUser
|
| 253 |
+
? /* User: light links that stand out on purple background */
|
| 254 |
+
'text-white/90 underline underline-offset-2 hover:text-white font-medium'
|
| 255 |
+
: /* Assistant: purple links for brand consistency */
|
| 256 |
+
'text-[var(--color-primary-600)] dark:text-[var(--color-primary-400)] hover:underline font-medium',
|
| 257 |
+
className
|
| 258 |
+
)}
|
| 259 |
+
target="_blank"
|
| 260 |
+
rel="noopener noreferrer"
|
| 261 |
+
{...props}
|
| 262 |
+
>
|
| 263 |
+
{children}
|
| 264 |
+
</a>
|
| 265 |
+
),
|
| 266 |
+
}}
|
| 267 |
+
>
|
| 268 |
+
{content}
|
| 269 |
+
</ReactMarkdown>
|
| 270 |
+
|
| 271 |
+
{/* Show purple-tinted blinking cursor when message is still streaming */}
|
| 272 |
+
{isStreaming && <StreamingCursor />}
|
| 273 |
+
</div>
|
| 274 |
+
);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/**
|
| 278 |
+
* ChatMessage Component
|
| 279 |
+
*
|
| 280 |
+
* A Claude-inspired chat message component with proper message alignment:
|
| 281 |
+
* - User messages: Right-aligned with contained width
|
| 282 |
+
* - Assistant messages: Left-aligned, full-width
|
| 283 |
+
*
|
| 284 |
+
* @remarks
|
| 285 |
+
* ## Visual Design (Claude-Inspired)
|
| 286 |
+
* - **User messages**: Right-aligned with purple gradient background,
|
| 287 |
+
* max-width constrained for natural conversation flow. No avatar
|
| 288 |
+
* shown for cleaner appearance.
|
| 289 |
+
* - **Assistant messages**: Left-aligned, full-width with avatar icon.
|
| 290 |
+
* Clean text directly on the page background.
|
| 291 |
+
*
|
| 292 |
+
* ## Layout (Claude-Style)
|
| 293 |
+
* - User messages right-aligned with max-width (80%)
|
| 294 |
+
* - Assistant messages left-aligned, full-width
|
| 295 |
+
* - Smaller, more subtle avatars for assistant only
|
| 296 |
+
* - No bubble borders for assistant messages
|
| 297 |
+
*
|
| 298 |
+
* ## Accessibility Features
|
| 299 |
+
* - Uses `role="article"` for semantic grouping of message content
|
| 300 |
+
* - Includes `aria-label` describing the message author and time
|
| 301 |
+
* - Avatars are hidden from screen readers (decorative)
|
| 302 |
+
* - Streaming cursor is hidden from screen readers
|
| 303 |
+
*
|
| 304 |
+
* ## Streaming Support
|
| 305 |
+
* - When `isStreaming` is true on the message, displays an animated
|
| 306 |
+
* purple-tinted blinking cursor at the end of the content
|
| 307 |
+
* - Useful for real-time SSE/WebSocket message updates
|
| 308 |
+
*
|
| 309 |
+
* ## Animation
|
| 310 |
+
* - Messages fade in and slide up on initial render
|
| 311 |
+
* - Uses CSS animations that respect `prefers-reduced-motion`
|
| 312 |
+
*
|
| 313 |
+
* @param props - ChatMessage component props
|
| 314 |
+
* @returns React element containing the styled chat message
|
| 315 |
+
*
|
| 316 |
+
* @see {@link ChatMessageProps} for full prop documentation
|
| 317 |
+
* @see {@link Message} for the message data structure
|
| 318 |
+
*/
|
| 319 |
+
const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
| 320 |
+
(
|
| 321 |
+
{ message, showTimestamp = true, bubbleClassName, className, ...props },
|
| 322 |
+
ref
|
| 323 |
+
) => {
|
| 324 |
+
const isUser = message.role === 'user';
|
| 325 |
+
|
| 326 |
+
/**
|
| 327 |
+
* Format the timestamp for screen reader accessibility.
|
| 328 |
+
* Provides context like "User message from 2 minutes ago"
|
| 329 |
+
*/
|
| 330 |
+
const timeLabel = formatRelativeTime(message.timestamp);
|
| 331 |
+
const ariaLabel = `${isUser ? 'Your' : 'Assistant'} message from ${timeLabel}`;
|
| 332 |
+
|
| 333 |
+
return (
|
| 334 |
+
<article
|
| 335 |
+
ref={ref}
|
| 336 |
+
role="article"
|
| 337 |
+
aria-label={ariaLabel}
|
| 338 |
+
className={cn(
|
| 339 |
+
/* Full-width container */
|
| 340 |
+
'w-full',
|
| 341 |
+
/* Animation on appearance */
|
| 342 |
+
'animate-slide-up',
|
| 343 |
+
/*
|
| 344 |
+
* User messages: flex container to enable right-alignment
|
| 345 |
+
* Assistant messages: normal flow (left-aligned)
|
| 346 |
+
*/
|
| 347 |
+
isUser ? 'flex justify-end' : '',
|
| 348 |
+
className
|
| 349 |
+
)}
|
| 350 |
+
{...props}
|
| 351 |
+
>
|
| 352 |
+
{/*
|
| 353 |
+
* Message container with conditional styling:
|
| 354 |
+
* - User: Right-aligned purple bubble with constrained width
|
| 355 |
+
* - Assistant: Left-aligned, full-width, minimal styling
|
| 356 |
+
*/}
|
| 357 |
+
<div
|
| 358 |
+
className={cn(
|
| 359 |
+
/* Base padding and layout */
|
| 360 |
+
'px-4 py-3 rounded-2xl',
|
| 361 |
+
/* Role-based width and styling */
|
| 362 |
+
isUser
|
| 363 |
+
? [
|
| 364 |
+
/* User: Constrained width, right-aligned bubble */
|
| 365 |
+
'max-w-[85%] sm:max-w-[75%]',
|
| 366 |
+
/* Purple gradient background for user messages */
|
| 367 |
+
'bg-gradient-to-br from-[var(--color-primary-600)] to-[var(--color-primary-700)]',
|
| 368 |
+
'text-white',
|
| 369 |
+
/* Subtle shadow for depth */
|
| 370 |
+
'shadow-sm',
|
| 371 |
+
]
|
| 372 |
+
: [
|
| 373 |
+
/* Assistant: Full-width, no background */
|
| 374 |
+
'w-full',
|
| 375 |
+
'bg-transparent',
|
| 376 |
+
'text-[var(--foreground)]',
|
| 377 |
+
],
|
| 378 |
+
bubbleClassName
|
| 379 |
+
)}
|
| 380 |
+
>
|
| 381 |
+
{/* Flex container for avatar and content */}
|
| 382 |
+
<div className={cn(
|
| 383 |
+
'flex gap-3 items-start',
|
| 384 |
+
/* User messages: no avatar, just content */
|
| 385 |
+
isUser && 'justify-end'
|
| 386 |
+
)}>
|
| 387 |
+
{/* Avatar - only show for assistant messages */}
|
| 388 |
+
{!isUser && <MessageAvatar role={message.role} />}
|
| 389 |
+
|
| 390 |
+
{/* Message content container */}
|
| 391 |
+
<div className={cn(
|
| 392 |
+
'min-w-0',
|
| 393 |
+
/* User messages don't need flex-1 since they're right-aligned */
|
| 394 |
+
!isUser && 'flex-1'
|
| 395 |
+
)}>
|
| 396 |
+
{/* Message text content */}
|
| 397 |
+
<div
|
| 398 |
+
className={cn(
|
| 399 |
+
/* Text styling - inherit color from parent */
|
| 400 |
+
'text-base leading-relaxed'
|
| 401 |
+
)}
|
| 402 |
+
>
|
| 403 |
+
<MessageContent
|
| 404 |
+
content={message.content}
|
| 405 |
+
isStreaming={message.isStreaming}
|
| 406 |
+
isUser={isUser}
|
| 407 |
+
/>
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
{/* Source Citations - Only show for completed assistant messages with sources */}
|
| 411 |
+
{message.role === 'assistant' &&
|
| 412 |
+
message.sources &&
|
| 413 |
+
message.sources.length > 0 &&
|
| 414 |
+
!message.isStreaming && (
|
| 415 |
+
<div
|
| 416 |
+
className="mt-4"
|
| 417 |
+
aria-label={`${message.sources.length} source${message.sources.length === 1 ? '' : 's'} referenced in this response`}
|
| 418 |
+
>
|
| 419 |
+
<MemoizedSourceCitations sources={message.sources} />
|
| 420 |
+
</div>
|
| 421 |
+
)}
|
| 422 |
+
|
| 423 |
+
{/* Timestamp - shown below content */}
|
| 424 |
+
{showTimestamp && (
|
| 425 |
+
<time
|
| 426 |
+
dateTime={message.timestamp.toISOString()}
|
| 427 |
+
className={cn(
|
| 428 |
+
/* Timestamp styling */
|
| 429 |
+
'block mt-2',
|
| 430 |
+
'text-xs',
|
| 431 |
+
/* User: lighter text on purple; Assistant: muted */
|
| 432 |
+
isUser
|
| 433 |
+
? 'text-white/70'
|
| 434 |
+
: 'text-[var(--foreground-muted)]'
|
| 435 |
+
)}
|
| 436 |
+
>
|
| 437 |
+
{timeLabel}
|
| 438 |
+
</time>
|
| 439 |
+
)}
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
</div>
|
| 443 |
+
</article>
|
| 444 |
+
);
|
| 445 |
+
}
|
| 446 |
+
);
|
| 447 |
+
|
| 448 |
+
/* Display name for React DevTools */
|
| 449 |
+
ChatMessage.displayName = 'ChatMessage';
|
| 450 |
+
|
| 451 |
+
/**
|
| 452 |
+
* Memoized ChatMessage for performance optimization.
|
| 453 |
+
*
|
| 454 |
+
* Since chat messages are typically rendered in a list and don't change
|
| 455 |
+
* frequently (except during streaming), memoization prevents unnecessary
|
| 456 |
+
* re-renders when sibling messages update.
|
| 457 |
+
*
|
| 458 |
+
* The component re-renders when:
|
| 459 |
+
* - message.id changes (new message)
|
| 460 |
+
* - message.content changes (streaming updates)
|
| 461 |
+
* - message.isStreaming changes (streaming state)
|
| 462 |
+
* - showTimestamp or className props change
|
| 463 |
+
*/
|
| 464 |
+
const MemoizedChatMessage = memo(ChatMessage);
|
| 465 |
+
|
| 466 |
+
/* Export both versions - use Memoized for lists, regular for single messages */
|
| 467 |
+
export { ChatMessage, MemoizedChatMessage };
|
frontend/src/components/chat/code-block.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { Check, Copy, Terminal } from 'lucide-react';
|
| 4 |
+
import { memo, useRef, useState, useCallback } from 'react';
|
| 5 |
+
import { cn } from '@/lib/utils';
|
| 6 |
+
import { Button } from '@/components/ui/button';
|
| 7 |
+
|
| 8 |
+
interface CodeBlockProps extends React.HTMLAttributes<HTMLElement> {
|
| 9 |
+
inline?: boolean;
|
| 10 |
+
className?: string;
|
| 11 |
+
children?: React.ReactNode;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* CodeBlock Component
|
| 16 |
+
*
|
| 17 |
+
* Renders a code block with syntax highlighting (via rehype-highlight classes),
|
| 18 |
+
* a language label, and a copy-to-clipboard button.
|
| 19 |
+
* Designed to work as a custom component for react-markdown.
|
| 20 |
+
*/
|
| 21 |
+
export const CodeBlock = memo(
|
| 22 |
+
({ inline, className, children, ...props }: CodeBlockProps) => {
|
| 23 |
+
// specific ref to the code element for copying
|
| 24 |
+
const codeRef = useRef<HTMLElement>(null);
|
| 25 |
+
const [isCopied, setIsCopied] = useState(false);
|
| 26 |
+
|
| 27 |
+
// Extract language from className (format: "language-xyz")
|
| 28 |
+
// react-markdown (via rehype-highlight) passes "language-xyz" in className
|
| 29 |
+
const match = /language-(\w+)/.exec(className || '');
|
| 30 |
+
const language = match ? match[1] : '';
|
| 31 |
+
|
| 32 |
+
// Handle copy functionality
|
| 33 |
+
const handleCopy = useCallback(() => {
|
| 34 |
+
if (codeRef.current && codeRef.current.textContent) {
|
| 35 |
+
navigator.clipboard.writeText(codeRef.current.textContent);
|
| 36 |
+
setIsCopied(true);
|
| 37 |
+
setTimeout(() => setIsCopied(false), 2000);
|
| 38 |
+
}
|
| 39 |
+
}, []);
|
| 40 |
+
|
| 41 |
+
// If inline code, render simple styled code tag
|
| 42 |
+
if (inline) {
|
| 43 |
+
return (
|
| 44 |
+
<code
|
| 45 |
+
className={cn(
|
| 46 |
+
'bg-[var(--background-secondary)]',
|
| 47 |
+
'px-1.5 py-0.5 rounded-md',
|
| 48 |
+
'text-sm font-mono text-[var(--color-primary-700)] dark:text-[var(--color-primary-300)]',
|
| 49 |
+
className
|
| 50 |
+
)}
|
| 51 |
+
{...props}
|
| 52 |
+
>
|
| 53 |
+
{children}
|
| 54 |
+
</code>
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="group relative my-4 overflow-hidden rounded-xl border border-[var(--border)] bg-[#1e1e1e] shadow-md dark:border-[var(--border-ui)]">
|
| 60 |
+
{/* Header with language and actions */}
|
| 61 |
+
<div className="flex items-center justify-between border-b border-white/10 bg-[#2d2d2d] px-4 py-2 text-xs text-gray-400">
|
| 62 |
+
<div className="flex items-center gap-2">
|
| 63 |
+
<Terminal className="h-4 w-4" />
|
| 64 |
+
<span className="font-medium uppercase">{language || 'text'}</span>
|
| 65 |
+
</div>
|
| 66 |
+
<Button
|
| 67 |
+
variant="ghost"
|
| 68 |
+
size="icon"
|
| 69 |
+
className="h-6 w-6 text-gray-400 hover:bg-white/10 hover:text-white"
|
| 70 |
+
onClick={handleCopy}
|
| 71 |
+
aria-label="Copy code"
|
| 72 |
+
>
|
| 73 |
+
{isCopied ? (
|
| 74 |
+
<Check className="h-3.5 w-3.5 text-green-400" />
|
| 75 |
+
) : (
|
| 76 |
+
<Copy className="h-3.5 w-3.5" />
|
| 77 |
+
)}
|
| 78 |
+
</Button>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
{/* Code content */}
|
| 82 |
+
<div className="overflow-x-auto p-4">
|
| 83 |
+
<pre {...props} className={cn('text-sm font-mono leading-relaxed', className)}>
|
| 84 |
+
<code ref={codeRef} className={className}>
|
| 85 |
+
{children}
|
| 86 |
+
</code>
|
| 87 |
+
</pre>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
);
|
| 93 |
+
|
| 94 |
+
CodeBlock.displayName = 'CodeBlock';
|
frontend/src/components/chat/empty-state.tsx
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* EmptyState Component - Claude-Style Minimal Design
|
| 3 |
+
*
|
| 4 |
+
* A welcoming empty state component displayed when the chat has no messages.
|
| 5 |
+
* Provides a friendly introduction to the RAG chatbot with example questions
|
| 6 |
+
* that users can click to quickly start a conversation.
|
| 7 |
+
*
|
| 8 |
+
* Features a premium custom SVG illustration representing thermal comfort concepts
|
| 9 |
+
* with subtle animations that respect user motion preferences. The design follows
|
| 10 |
+
* Claude's minimal aesthetic - content flows naturally on the page background
|
| 11 |
+
* without visible card boundaries or container styling.
|
| 12 |
+
*
|
| 13 |
+
* ## Design Philosophy
|
| 14 |
+
* - No glassmorphism or card containers
|
| 15 |
+
* - Content floats naturally on the page background
|
| 16 |
+
* - Suggestion buttons are lightweight and feel native to the page
|
| 17 |
+
* - Clean, distraction-free interface that focuses on content
|
| 18 |
+
*
|
| 19 |
+
* ## Purple Theme Colors
|
| 20 |
+
* The component uses the following purple color palette via CSS custom properties:
|
| 21 |
+
* - purple-100 (#f3e8ff): Light backgrounds, window fills
|
| 22 |
+
* - purple-200 (#e9d5ff): Secondary elements, doors
|
| 23 |
+
* - purple-300 (#d8b4fe): Decorative elements, medium accents
|
| 24 |
+
* - purple-400 (#c084fc): Icons, gradient starts
|
| 25 |
+
* - purple-500 (#a855f7): Primary accent color
|
| 26 |
+
* - purple-600 (#9333ea): Gradient ends, active states
|
| 27 |
+
* - purple-700 (#7c3aed): Strokes, borders
|
| 28 |
+
*
|
| 29 |
+
* @module components/chat/empty-state
|
| 30 |
+
* @since 1.0.0
|
| 31 |
+
*
|
| 32 |
+
* @example
|
| 33 |
+
* // Basic usage in ChatContainer
|
| 34 |
+
* <EmptyState onExampleClick={(question) => handleQuestionClick(question)} />
|
| 35 |
+
*
|
| 36 |
+
* @example
|
| 37 |
+
* // With custom styling
|
| 38 |
+
* <EmptyState
|
| 39 |
+
* onExampleClick={handleClick}
|
| 40 |
+
* className="my-8"
|
| 41 |
+
* />
|
| 42 |
+
*/
|
| 43 |
+
|
| 44 |
+
'use client';
|
| 45 |
+
|
| 46 |
+
import { memo, useCallback, type HTMLAttributes } from 'react';
|
| 47 |
+
import { MessageSquare } from 'lucide-react';
|
| 48 |
+
import { cn } from '@/lib/utils';
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* ThermalComfortIllustration Component - Purple Theme
|
| 52 |
+
*
|
| 53 |
+
* A premium, custom SVG illustration representing thermal comfort concepts.
|
| 54 |
+
* Features a stylized building with temperature waves and comfort indicators,
|
| 55 |
+
* all rendered with a cohesive purple color scheme.
|
| 56 |
+
*
|
| 57 |
+
* ## Design Elements (Purple Theme)
|
| 58 |
+
* - Central building silhouette with purple-400 to purple-600 gradient
|
| 59 |
+
* - Thermometer element with purple-300 to purple-600 gradient fill
|
| 60 |
+
* - Flowing wave lines with purple-300 to purple-400 gradient
|
| 61 |
+
* - Circular comfort zone with purple-200 to purple-400 radial gradient
|
| 62 |
+
* - Building windows in purple-100, door in purple-200
|
| 63 |
+
* - Strokes in purple-700 for definition
|
| 64 |
+
* - Comfort indicator dots in purple-300, purple-400, purple-500
|
| 65 |
+
*
|
| 66 |
+
* ## Animation Features
|
| 67 |
+
* - Gentle floating animation on the main container
|
| 68 |
+
* - Subtle pulse effect on the comfort zone circle
|
| 69 |
+
* - Wave lines with staggered opacity animations
|
| 70 |
+
* - All animations respect `prefers-reduced-motion`
|
| 71 |
+
*
|
| 72 |
+
* @param className - Optional CSS classes to apply
|
| 73 |
+
* @returns SVG element with thermal comfort illustration in purple theme
|
| 74 |
+
*
|
| 75 |
+
* @internal
|
| 76 |
+
*/
|
| 77 |
+
function ThermalComfortIllustration({
|
| 78 |
+
className,
|
| 79 |
+
}: {
|
| 80 |
+
className?: string;
|
| 81 |
+
}): React.ReactElement {
|
| 82 |
+
return (
|
| 83 |
+
<svg
|
| 84 |
+
viewBox="0 0 100 100"
|
| 85 |
+
fill="none"
|
| 86 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 87 |
+
className={cn(
|
| 88 |
+
/* Base size for the illustration */
|
| 89 |
+
'h-20 w-20',
|
| 90 |
+
/* Float animation - creates gentle vertical movement
|
| 91 |
+
Uses motion-safe: prefix to respect prefers-reduced-motion */
|
| 92 |
+
'motion-safe:animate-[thermalFloat_4s_ease-in-out_infinite]',
|
| 93 |
+
className
|
| 94 |
+
)}
|
| 95 |
+
/* Decorative illustration, hidden from screen readers */
|
| 96 |
+
aria-hidden="true"
|
| 97 |
+
role="img"
|
| 98 |
+
>
|
| 99 |
+
{/* ============================================
|
| 100 |
+
SVG Definitions: Purple Gradients and Filters
|
| 101 |
+
All gradients use purple color scheme for cohesive design
|
| 102 |
+
============================================ */}
|
| 103 |
+
<defs>
|
| 104 |
+
{/*
|
| 105 |
+
Building Gradient - Purple Tones
|
| 106 |
+
Creates depth on the main building structure
|
| 107 |
+
Uses purple-400 (#c084fc) to purple-600 (#9333ea)
|
| 108 |
+
*/}
|
| 109 |
+
<linearGradient
|
| 110 |
+
id="buildingGradient"
|
| 111 |
+
x1="0%"
|
| 112 |
+
y1="0%"
|
| 113 |
+
x2="100%"
|
| 114 |
+
y2="100%"
|
| 115 |
+
>
|
| 116 |
+
{/* Start: Medium purple - creates highlight on top-left */}
|
| 117 |
+
<stop offset="0%" stopColor="var(--color-primary-400)" />
|
| 118 |
+
{/* End: Rich purple - creates shadow on bottom-right */}
|
| 119 |
+
<stop offset="100%" stopColor="var(--color-primary-600)" />
|
| 120 |
+
</linearGradient>
|
| 121 |
+
|
| 122 |
+
{/*
|
| 123 |
+
Comfort Zone Gradient - Soft Purple Radial
|
| 124 |
+
Creates a glowing orb effect behind the illustration
|
| 125 |
+
Uses purple-200 (#e9d5ff) to purple-400 (#c084fc)
|
| 126 |
+
*/}
|
| 127 |
+
<radialGradient
|
| 128 |
+
id="comfortZoneGradient"
|
| 129 |
+
cx="50%"
|
| 130 |
+
cy="50%"
|
| 131 |
+
r="50%"
|
| 132 |
+
fx="30%" /* Offset focal point for 3D effect */
|
| 133 |
+
fy="30%"
|
| 134 |
+
>
|
| 135 |
+
{/* Center: Soft purple with high opacity */}
|
| 136 |
+
<stop offset="0%" stopColor="var(--color-primary-200)" stopOpacity="0.8" />
|
| 137 |
+
{/* Middle: Medium-light purple, fading */}
|
| 138 |
+
<stop offset="70%" stopColor="var(--color-primary-400)" stopOpacity="0.4" />
|
| 139 |
+
{/* Edge: Medium purple, nearly transparent */}
|
| 140 |
+
<stop offset="100%" stopColor="var(--color-primary-400)" stopOpacity="0.1" />
|
| 141 |
+
</radialGradient>
|
| 142 |
+
|
| 143 |
+
{/*
|
| 144 |
+
Thermometer Fill Gradient - Purple Temperature Indicator
|
| 145 |
+
Vertical gradient simulating mercury/temperature level
|
| 146 |
+
Uses purple-300 (#d8b4fe) to purple-600 (#9333ea)
|
| 147 |
+
*/}
|
| 148 |
+
<linearGradient
|
| 149 |
+
id="thermoGradient"
|
| 150 |
+
x1="0%"
|
| 151 |
+
y1="100%" /* Start from bottom */
|
| 152 |
+
x2="0%"
|
| 153 |
+
y2="0%" /* End at top */
|
| 154 |
+
>
|
| 155 |
+
{/* Bottom: Lighter purple - cooler indication */}
|
| 156 |
+
<stop offset="0%" stopColor="var(--color-primary-300)" />
|
| 157 |
+
{/* Middle: Main purple - neutral zone */}
|
| 158 |
+
<stop offset="50%" stopColor="var(--color-primary-600)" />
|
| 159 |
+
{/* Top: Rich purple - warmer indication */}
|
| 160 |
+
<stop offset="100%" stopColor="var(--color-primary-600)" />
|
| 161 |
+
</linearGradient>
|
| 162 |
+
|
| 163 |
+
{/*
|
| 164 |
+
Wave Gradient - Air Flow Representation
|
| 165 |
+
Horizontal gradient for flowing wave lines
|
| 166 |
+
Uses purple-300 (#d8b4fe) to purple-400 (#c084fc)
|
| 167 |
+
*/}
|
| 168 |
+
<linearGradient
|
| 169 |
+
id="waveGradient"
|
| 170 |
+
x1="0%"
|
| 171 |
+
y1="0%"
|
| 172 |
+
x2="100%"
|
| 173 |
+
y2="0%"
|
| 174 |
+
>
|
| 175 |
+
{/* Start: Faded purple - creates trailing edge effect */}
|
| 176 |
+
<stop offset="0%" stopColor="var(--color-primary-300)" stopOpacity="0.2" />
|
| 177 |
+
{/* Center: Visible purple - wave peak */}
|
| 178 |
+
<stop offset="50%" stopColor="var(--color-primary-400)" stopOpacity="0.6" />
|
| 179 |
+
{/* End: Faded purple - creates leading edge effect */}
|
| 180 |
+
<stop offset="100%" stopColor="var(--color-primary-300)" stopOpacity="0.2" />
|
| 181 |
+
</linearGradient>
|
| 182 |
+
</defs>
|
| 183 |
+
|
| 184 |
+
{/* ============================================
|
| 185 |
+
Background: Comfort Zone Circle
|
| 186 |
+
A soft radial purple element suggesting optimal thermal conditions
|
| 187 |
+
Animates with subtle pulse for organic feel
|
| 188 |
+
============================================ */}
|
| 189 |
+
<circle
|
| 190 |
+
cx="50"
|
| 191 |
+
cy="50"
|
| 192 |
+
r="42"
|
| 193 |
+
fill="url(#comfortZoneGradient)"
|
| 194 |
+
/* Pulse animation: creates breathing effect
|
| 195 |
+
3s duration for calm, professional feel */
|
| 196 |
+
className="motion-safe:animate-[thermalPulse_3s_ease-in-out_infinite]"
|
| 197 |
+
/>
|
| 198 |
+
|
| 199 |
+
{/* ============================================
|
| 200 |
+
Building Silhouette
|
| 201 |
+
Central element representing the built environment
|
| 202 |
+
Uses purple gradient with purple-700 strokes for definition
|
| 203 |
+
Features windows (purple-100) and door (purple-200)
|
| 204 |
+
============================================ */}
|
| 205 |
+
<g transform="translate(25, 28)">
|
| 206 |
+
{/* Main building body - pitched roof style
|
| 207 |
+
Filled with purple gradient for depth */}
|
| 208 |
+
<path
|
| 209 |
+
d="M5 45 L5 18 L25 8 L45 18 L45 45 Z"
|
| 210 |
+
fill="url(#buildingGradient)"
|
| 211 |
+
/* Purple-700 stroke for crisp definition */
|
| 212 |
+
stroke="var(--color-primary-700)"
|
| 213 |
+
strokeWidth="1"
|
| 214 |
+
strokeLinejoin="round"
|
| 215 |
+
/>
|
| 216 |
+
|
| 217 |
+
{/* Roof accent line - emphasizes the pitched roof
|
| 218 |
+
Thicker stroke for visual hierarchy */}
|
| 219 |
+
<path
|
| 220 |
+
d="M5 18 L25 8 L45 18"
|
| 221 |
+
fill="none"
|
| 222 |
+
stroke="var(--color-primary-700)"
|
| 223 |
+
strokeWidth="1.5"
|
| 224 |
+
strokeLinecap="round"
|
| 225 |
+
strokeLinejoin="round"
|
| 226 |
+
/>
|
| 227 |
+
|
| 228 |
+
{/* Windows - grid pattern suggesting occupancy
|
| 229 |
+
Uses purple-100 for bright, welcoming appearance */}
|
| 230 |
+
{/* Left column of windows */}
|
| 231 |
+
<rect x="10" y="22" width="6" height="6" rx="1" fill="var(--color-primary-100)" opacity="0.9" />
|
| 232 |
+
<rect x="10" y="32" width="6" height="6" rx="1" fill="var(--color-primary-100)" opacity="0.9" />
|
| 233 |
+
|
| 234 |
+
{/* Right column of windows */}
|
| 235 |
+
<rect x="34" y="22" width="6" height="6" rx="1" fill="var(--color-primary-100)" opacity="0.9" />
|
| 236 |
+
<rect x="34" y="32" width="6" height="6" rx="1" fill="var(--color-primary-100)" opacity="0.9" />
|
| 237 |
+
|
| 238 |
+
{/* Door - central entrance
|
| 239 |
+
Uses purple-200 for subtle differentiation from windows */}
|
| 240 |
+
<rect x="20" y="32" width="10" height="13" rx="1" fill="var(--color-primary-200)" />
|
| 241 |
+
{/* Door handle - small detail in purple-600 */}
|
| 242 |
+
<circle cx="27" cy="39" r="1" fill="var(--color-primary-600)" />
|
| 243 |
+
</g>
|
| 244 |
+
|
| 245 |
+
{/* ============================================
|
| 246 |
+
Thermometer Element
|
| 247 |
+
Positioned to the right, indicating temperature measurement
|
| 248 |
+
Uses purple theme: outline in purple-100, fill with purple gradient
|
| 249 |
+
============================================ */}
|
| 250 |
+
<g transform="translate(72, 25)">
|
| 251 |
+
{/* Thermometer outline - bulb-style design
|
| 252 |
+
Light purple-100 background, purple-400 border */}
|
| 253 |
+
<path
|
| 254 |
+
d="M6 0 C2.7 0 0 2.7 0 6 L0 35 C-2 37 -3 40 -3 43 C-3 49 2 54 8 54 C14 54 19 49 19 43 C19 40 18 37 16 35 L16 6 C16 2.7 13.3 0 10 0 Z"
|
| 255 |
+
fill="var(--color-primary-100)"
|
| 256 |
+
stroke="var(--color-primary-400)"
|
| 257 |
+
strokeWidth="1.5"
|
| 258 |
+
/>
|
| 259 |
+
|
| 260 |
+
{/* Thermometer fill - animated warmth level
|
| 261 |
+
Purple gradient creates temperature indication
|
| 262 |
+
Pulses to suggest active measurement */}
|
| 263 |
+
<path
|
| 264 |
+
d="M6 38 L6 10 C6 8 7 7 8 7 C9 7 10 8 10 10 L10 38 C12 39 14 41 14 43 C14 47 11 50 8 50 C5 50 2 47 2 43 C2 41 4 39 6 38 Z"
|
| 265 |
+
fill="url(#thermoGradient)"
|
| 266 |
+
/* Pulse animation with 0.5s delay for staggered effect */
|
| 267 |
+
className="motion-safe:animate-[thermalPulse_3s_ease-in-out_infinite_0.5s]"
|
| 268 |
+
/>
|
| 269 |
+
|
| 270 |
+
{/* Temperature measurement marks - tick marks on thermometer
|
| 271 |
+
Purple-400 for subtle visibility */}
|
| 272 |
+
<line x1="12" y1="15" x2="14" y2="15" stroke="var(--color-primary-400)" strokeWidth="1" />
|
| 273 |
+
<line x1="12" y1="22" x2="14" y2="22" stroke="var(--color-primary-400)" strokeWidth="1" />
|
| 274 |
+
<line x1="12" y1="29" x2="14" y2="29" stroke="var(--color-primary-400)" strokeWidth="1" />
|
| 275 |
+
</g>
|
| 276 |
+
|
| 277 |
+
{/* ============================================
|
| 278 |
+
Wave Lines - Air Flow Representation
|
| 279 |
+
Animated purple curves suggesting thermal circulation
|
| 280 |
+
Staggered timing creates flowing, organic effect
|
| 281 |
+
============================================ */}
|
| 282 |
+
{/* Top wave - highest opacity for visual hierarchy */}
|
| 283 |
+
<g className="motion-safe:animate-[thermalWave_2s_ease-in-out_infinite]">
|
| 284 |
+
<path
|
| 285 |
+
d="M12 30 Q22 26 32 30 Q42 34 52 30"
|
| 286 |
+
fill="none"
|
| 287 |
+
stroke="url(#waveGradient)"
|
| 288 |
+
strokeWidth="2"
|
| 289 |
+
strokeLinecap="round"
|
| 290 |
+
opacity="0.7"
|
| 291 |
+
/>
|
| 292 |
+
</g>
|
| 293 |
+
|
| 294 |
+
{/* Middle wave - 0.3s delay for staggered motion */}
|
| 295 |
+
<g className="motion-safe:animate-[thermalWave_2s_ease-in-out_infinite_0.3s]">
|
| 296 |
+
<path
|
| 297 |
+
d="M8 42 Q18 38 28 42 Q38 46 48 42"
|
| 298 |
+
fill="none"
|
| 299 |
+
stroke="url(#waveGradient)"
|
| 300 |
+
strokeWidth="2"
|
| 301 |
+
strokeLinecap="round"
|
| 302 |
+
opacity="0.5"
|
| 303 |
+
/>
|
| 304 |
+
</g>
|
| 305 |
+
|
| 306 |
+
{/* Bottom wave - 0.6s delay completes the flowing sequence */}
|
| 307 |
+
<g className="motion-safe:animate-[thermalWave_2s_ease-in-out_infinite_0.6s]">
|
| 308 |
+
<path
|
| 309 |
+
d="M15 55 Q25 51 35 55 Q45 59 55 55"
|
| 310 |
+
fill="none"
|
| 311 |
+
stroke="url(#waveGradient)"
|
| 312 |
+
strokeWidth="2"
|
| 313 |
+
strokeLinecap="round"
|
| 314 |
+
opacity="0.6"
|
| 315 |
+
/>
|
| 316 |
+
</g>
|
| 317 |
+
|
| 318 |
+
{/* ============================================
|
| 319 |
+
Comfort Indicator Dots - Purple Accents
|
| 320 |
+
Small circular elements suggesting optimal conditions
|
| 321 |
+
Each dot uses different purple shade for visual interest:
|
| 322 |
+
- Top-left: purple-400 (medium purple)
|
| 323 |
+
- Bottom-right: purple-300 (lighter purple)
|
| 324 |
+
- Bottom-left: purple-500 (main accent purple)
|
| 325 |
+
============================================ */}
|
| 326 |
+
{/* Top-left indicator dot - purple-400 */}
|
| 327 |
+
<circle
|
| 328 |
+
cx="18"
|
| 329 |
+
cy="22"
|
| 330 |
+
r="2"
|
| 331 |
+
fill="var(--color-primary-400)"
|
| 332 |
+
opacity="0.6"
|
| 333 |
+
/* Faster pulse with 0.2s delay */
|
| 334 |
+
className="motion-safe:animate-[thermalPulse_2s_ease-in-out_infinite_0.2s]"
|
| 335 |
+
/>
|
| 336 |
+
{/* Bottom-right indicator dot - purple-300 (lighter) */}
|
| 337 |
+
<circle
|
| 338 |
+
cx="82"
|
| 339 |
+
cy="72"
|
| 340 |
+
r="2.5"
|
| 341 |
+
fill="var(--color-primary-300)"
|
| 342 |
+
opacity="0.5"
|
| 343 |
+
/* Medium delay for staggered effect */
|
| 344 |
+
className="motion-safe:animate-[thermalPulse_2s_ease-in-out_infinite_0.8s]"
|
| 345 |
+
/>
|
| 346 |
+
{/* Bottom-left indicator dot - purple-500 (main accent) */}
|
| 347 |
+
<circle
|
| 348 |
+
cx="15"
|
| 349 |
+
cy="75"
|
| 350 |
+
r="1.5"
|
| 351 |
+
fill="var(--color-primary-500)"
|
| 352 |
+
opacity="0.4"
|
| 353 |
+
/* Longest delay completes the sequence */
|
| 354 |
+
className="motion-safe:animate-[thermalPulse_2s_ease-in-out_infinite_1.2s]"
|
| 355 |
+
/>
|
| 356 |
+
</svg>
|
| 357 |
+
);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
/**
|
| 361 |
+
* Example questions displayed in the empty state.
|
| 362 |
+
*
|
| 363 |
+
* These are pre-defined questions that demonstrate the types of queries
|
| 364 |
+
* the RAG chatbot can answer about pythermalcomfort. Limited to 2 questions
|
| 365 |
+
* for a cleaner, less overwhelming interface:
|
| 366 |
+
* 1. PMV model - core thermal comfort calculation
|
| 367 |
+
* 2. Adaptive comfort - alternative model for naturally ventilated spaces
|
| 368 |
+
*
|
| 369 |
+
* @internal
|
| 370 |
+
*/
|
| 371 |
+
const EXAMPLE_QUESTIONS = [
|
| 372 |
+
'What is the PMV model and how do I calculate it?',
|
| 373 |
+
'How do I use the adaptive comfort model in pythermalcomfort?',
|
| 374 |
+
] as const;
|
| 375 |
+
|
| 376 |
+
/**
|
| 377 |
+
* Props for the EmptyState component.
|
| 378 |
+
*
|
| 379 |
+
* Extends standard HTML div attributes for flexibility in styling
|
| 380 |
+
* and event handling. The onClick prop is omitted to prevent conflicts
|
| 381 |
+
* with the internal example click handling.
|
| 382 |
+
*/
|
| 383 |
+
export interface EmptyStateProps extends Omit<
|
| 384 |
+
HTMLAttributes<HTMLDivElement>,
|
| 385 |
+
'onClick'
|
| 386 |
+
> {
|
| 387 |
+
/**
|
| 388 |
+
* Callback fired when a user clicks an example question.
|
| 389 |
+
* The clicked question text is passed as the argument.
|
| 390 |
+
* This should typically populate the chat input with the question.
|
| 391 |
+
*
|
| 392 |
+
* @param question - The text of the clicked example question
|
| 393 |
+
*/
|
| 394 |
+
onExampleClick: (question: string) => void;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
/**
|
| 398 |
+
* ExampleQuestionButton Component - Claude-Style Minimal Design
|
| 399 |
+
*
|
| 400 |
+
* An individual example question rendered as a clickable button.
|
| 401 |
+
* Uses a lightweight, text-focused design that feels native to the page
|
| 402 |
+
* without heavy container styling.
|
| 403 |
+
*
|
| 404 |
+
* ## Design Philosophy
|
| 405 |
+
* - Minimal visual weight - no heavy borders or shadows
|
| 406 |
+
* - Subtle hover state using background color change
|
| 407 |
+
* - Feels like a natural page element, not a contained widget
|
| 408 |
+
* - Purple icon accent maintains brand consistency
|
| 409 |
+
*
|
| 410 |
+
* @param question - The question text to display
|
| 411 |
+
* @param onClick - Callback fired when the button is clicked
|
| 412 |
+
*
|
| 413 |
+
* @internal
|
| 414 |
+
*/
|
| 415 |
+
function ExampleQuestionButton({
|
| 416 |
+
question,
|
| 417 |
+
onClick,
|
| 418 |
+
}: {
|
| 419 |
+
question: string;
|
| 420 |
+
onClick: () => void;
|
| 421 |
+
}): React.ReactElement {
|
| 422 |
+
return (
|
| 423 |
+
<button
|
| 424 |
+
type="button"
|
| 425 |
+
onClick={onClick}
|
| 426 |
+
className={cn(
|
| 427 |
+
/* Base button styles - full width layout */
|
| 428 |
+
'w-full text-left',
|
| 429 |
+
'px-4 py-3',
|
| 430 |
+
/* Minimal styling - transparent background by default */
|
| 431 |
+
'bg-transparent',
|
| 432 |
+
/* Very subtle border for definition without heaviness */
|
| 433 |
+
'border border-transparent',
|
| 434 |
+
/* Rounded corners for softer feel */
|
| 435 |
+
'rounded-lg',
|
| 436 |
+
/* Text styling - small size for secondary content */
|
| 437 |
+
'text-sm text-[var(--foreground)]',
|
| 438 |
+
/* Flexbox aligns icon and text horizontally */
|
| 439 |
+
'flex items-start gap-3',
|
| 440 |
+
/* Hover state - subtle background highlight
|
| 441 |
+
No border or shadow changes for minimal effect */
|
| 442 |
+
'hover:bg-[var(--foreground)]/5',
|
| 443 |
+
/* Smooth transition */
|
| 444 |
+
'transition-colors duration-150',
|
| 445 |
+
/* Focus state for keyboard accessibility
|
| 446 |
+
Ring color uses focus variable (purple-themed) */
|
| 447 |
+
'focus:outline-none',
|
| 448 |
+
'focus-visible:ring-2',
|
| 449 |
+
'focus-visible:ring-[var(--border-focus)]',
|
| 450 |
+
'focus-visible:ring-offset-2',
|
| 451 |
+
/* Cursor indicates interactivity */
|
| 452 |
+
'cursor-pointer'
|
| 453 |
+
)}
|
| 454 |
+
>
|
| 455 |
+
{/* Question mark icon - purple-500 for visual accent
|
| 456 |
+
Aligned to top of text for multi-line questions */}
|
| 457 |
+
<MessageSquare
|
| 458 |
+
className={cn(
|
| 459 |
+
'mt-0.5 h-4 w-4 shrink-0',
|
| 460 |
+
/* Purple-500 (#a855f7) - main accent color */
|
| 461 |
+
'text-[var(--color-primary-500)]'
|
| 462 |
+
)}
|
| 463 |
+
strokeWidth={2}
|
| 464 |
+
/* Icon is decorative, text provides meaning */
|
| 465 |
+
aria-hidden="true"
|
| 466 |
+
/>
|
| 467 |
+
{/* Question text - inherits foreground color */}
|
| 468 |
+
<span>{question}</span>
|
| 469 |
+
</button>
|
| 470 |
+
);
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/**
|
| 474 |
+
* EmptyState Component - Claude-Style Minimal Design
|
| 475 |
+
*
|
| 476 |
+
* Displays a welcoming interface when the chat has no messages. This component
|
| 477 |
+
* serves as the initial state of the chat, providing users with context about
|
| 478 |
+
* what the chatbot can do and offering quick-start example questions.
|
| 479 |
+
*
|
| 480 |
+
* @remarks
|
| 481 |
+
* ## Visual Design - Claude-Style Minimal
|
| 482 |
+
*
|
| 483 |
+
* The component follows Claude's minimal design philosophy:
|
| 484 |
+
* - No glassmorphism, card containers, or visible boundaries
|
| 485 |
+
* - Content flows naturally on the page background
|
| 486 |
+
* - Lightweight suggestion buttons without heavy styling
|
| 487 |
+
* - Clean typography with proper hierarchy
|
| 488 |
+
* - Focus on content, not decoration
|
| 489 |
+
*
|
| 490 |
+
* ## Thermal Comfort Illustration (Purple Theme)
|
| 491 |
+
*
|
| 492 |
+
* The illustration (`ThermalComfortIllustration`) includes:
|
| 493 |
+
* - Stylized building with purple-400 to purple-600 gradient
|
| 494 |
+
* - Thermometer with purple-300 to purple-600 gradient fill
|
| 495 |
+
* - Wave lines in purple-300 to purple-400 gradient
|
| 496 |
+
* - Comfort zone circle with purple-200 to purple-400 radial gradient
|
| 497 |
+
* - Building windows in purple-100, door in purple-200
|
| 498 |
+
* - Strokes in purple-700 for definition
|
| 499 |
+
* - Comfort dots in purple-300, purple-400, purple-500
|
| 500 |
+
* - Subtle animations (float, pulse, wave) respecting `prefers-reduced-motion`
|
| 501 |
+
*
|
| 502 |
+
* ## Interaction Pattern
|
| 503 |
+
*
|
| 504 |
+
* When users click an example question:
|
| 505 |
+
* 1. The `onExampleClick` callback is fired with the question text
|
| 506 |
+
* 2. The parent component (ChatContainer) should populate the input
|
| 507 |
+
* 3. Optionally, the parent can auto-submit the question
|
| 508 |
+
*
|
| 509 |
+
* ## Accessibility
|
| 510 |
+
*
|
| 511 |
+
* - All example questions are focusable buttons
|
| 512 |
+
* - Focus states are clearly visible with purple ring
|
| 513 |
+
* - Illustration is hidden from screen readers (decorative, aria-hidden="true")
|
| 514 |
+
* - Uses semantic heading hierarchy
|
| 515 |
+
* - Animations respect `prefers-reduced-motion` via `motion-safe:` prefix
|
| 516 |
+
*
|
| 517 |
+
* ## Animation
|
| 518 |
+
*
|
| 519 |
+
* The component uses the slide-up animation for a smooth entrance,
|
| 520 |
+
* creating a polished feel when the chat interface first loads.
|
| 521 |
+
* The illustration adds additional subtle animations:
|
| 522 |
+
* - `thermalFloat`: Gentle vertical float (4s cycle)
|
| 523 |
+
* - `thermalPulse`: Soft breathing effect (2-3s cycles)
|
| 524 |
+
* - `thermalWave`: Horizontal wave motion (2s cycles with stagger)
|
| 525 |
+
*
|
| 526 |
+
* @param props - EmptyState component props
|
| 527 |
+
* @returns React element containing the welcome interface
|
| 528 |
+
*
|
| 529 |
+
* @see {@link EmptyStateProps} for full prop documentation
|
| 530 |
+
* @see {@link ThermalComfortIllustration} for illustration details
|
| 531 |
+
*/
|
| 532 |
+
function EmptyStateComponent({
|
| 533 |
+
onExampleClick,
|
| 534 |
+
className,
|
| 535 |
+
...props
|
| 536 |
+
}: EmptyStateProps): React.ReactElement {
|
| 537 |
+
/**
|
| 538 |
+
* Create memoized click handlers for each example question.
|
| 539 |
+
* This prevents creating new function references on each render,
|
| 540 |
+
* optimizing performance when the component re-renders.
|
| 541 |
+
*/
|
| 542 |
+
const handleExampleClick = useCallback(
|
| 543 |
+
(question: string) => {
|
| 544 |
+
onExampleClick(question);
|
| 545 |
+
},
|
| 546 |
+
[onExampleClick]
|
| 547 |
+
);
|
| 548 |
+
|
| 549 |
+
return (
|
| 550 |
+
<div
|
| 551 |
+
className={cn(
|
| 552 |
+
/* Full width container with max width for readability */
|
| 553 |
+
'mx-auto w-full max-w-2xl',
|
| 554 |
+
/* Comfortable spacing - content flows on page background */
|
| 555 |
+
'px-4 py-8',
|
| 556 |
+
/* Smooth entrance animation */
|
| 557 |
+
'animate-slide-up',
|
| 558 |
+
className
|
| 559 |
+
)}
|
| 560 |
+
{...props}
|
| 561 |
+
>
|
| 562 |
+
{/* Content flows directly on page background - no card container
|
| 563 |
+
Claude-style minimal design with natural spacing */}
|
| 564 |
+
|
| 565 |
+
{/* Illustration section - Premium thermal comfort SVG
|
| 566 |
+
Centered with proper vertical spacing */}
|
| 567 |
+
<div className="mb-6 flex justify-center">
|
| 568 |
+
<div
|
| 569 |
+
className={cn(
|
| 570 |
+
/* Container provides consistent sizing for illustration */
|
| 571 |
+
'h-24 w-24',
|
| 572 |
+
/* Flex centering ensures illustration is perfectly positioned */
|
| 573 |
+
'flex items-center justify-center'
|
| 574 |
+
)}
|
| 575 |
+
>
|
| 576 |
+
{/* Purple-themed thermal comfort illustration */}
|
| 577 |
+
<ThermalComfortIllustration />
|
| 578 |
+
</div>
|
| 579 |
+
</div>
|
| 580 |
+
|
| 581 |
+
{/* Title section - Clear hierarchy with heading and description */}
|
| 582 |
+
<div className="mb-8 text-center">
|
| 583 |
+
{/* Main heading - bold and prominent */}
|
| 584 |
+
<h2
|
| 585 |
+
className={cn(
|
| 586 |
+
/* Large, bold text for primary heading */
|
| 587 |
+
'text-2xl font-bold',
|
| 588 |
+
/* Uses foreground color for maximum contrast */
|
| 589 |
+
'text-[var(--foreground)]',
|
| 590 |
+
/* Tight spacing before description */
|
| 591 |
+
'mb-2'
|
| 592 |
+
)}
|
| 593 |
+
>
|
| 594 |
+
Ask about thermal comfort and pythermalcomfort
|
| 595 |
+
</h2>
|
| 596 |
+
{/* Descriptive subtitle - provides context */}
|
| 597 |
+
<p
|
| 598 |
+
className={cn(
|
| 599 |
+
/* Secondary color for less emphasis than heading */
|
| 600 |
+
'text-[var(--foreground-secondary)]',
|
| 601 |
+
/* Smaller text size with relaxed line height for readability */
|
| 602 |
+
'text-sm leading-relaxed',
|
| 603 |
+
/* Constrained width prevents overly long lines */
|
| 604 |
+
'mx-auto max-w-md'
|
| 605 |
+
)}
|
| 606 |
+
>
|
| 607 |
+
Get answers about thermal comfort models, concepts, standards and the
|
| 608 |
+
pythermalcomfort library. Your questions are answered using
|
| 609 |
+
scientific sources and official documentations.
|
| 610 |
+
</p>
|
| 611 |
+
</div>
|
| 612 |
+
|
| 613 |
+
{/* Example questions section - Quick-start options */}
|
| 614 |
+
<div className="space-y-3">
|
| 615 |
+
{/* Section label - small caps style */}
|
| 616 |
+
<p
|
| 617 |
+
className={cn(
|
| 618 |
+
/* Small, uppercase text for label styling */
|
| 619 |
+
'text-xs font-medium tracking-wide uppercase',
|
| 620 |
+
/* Muted color to not compete with questions */
|
| 621 |
+
'text-[var(--foreground-muted)]',
|
| 622 |
+
/* Spacing positions label above grid */
|
| 623 |
+
'mb-3 px-1'
|
| 624 |
+
)}
|
| 625 |
+
>
|
| 626 |
+
Try asking
|
| 627 |
+
</p>
|
| 628 |
+
|
| 629 |
+
{/* Questions grid - responsive 1 or 2 column layout
|
| 630 |
+
Single column on mobile, two columns on larger screens */}
|
| 631 |
+
<div className="grid gap-3 sm:grid-cols-2">
|
| 632 |
+
{EXAMPLE_QUESTIONS.map((question) => (
|
| 633 |
+
<ExampleQuestionButton
|
| 634 |
+
key={question}
|
| 635 |
+
question={question}
|
| 636 |
+
onClick={() => handleExampleClick(question)}
|
| 637 |
+
/>
|
| 638 |
+
))}
|
| 639 |
+
</div>
|
| 640 |
+
</div>
|
| 641 |
+
</div>
|
| 642 |
+
);
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
/* Display name for React DevTools debugging */
|
| 646 |
+
EmptyStateComponent.displayName = 'EmptyState';
|
| 647 |
+
|
| 648 |
+
/**
|
| 649 |
+
* Memoized EmptyState for performance optimization.
|
| 650 |
+
*
|
| 651 |
+
* The EmptyState component doesn't change frequently, so memoization
|
| 652 |
+
* prevents unnecessary re-renders when the parent component updates.
|
| 653 |
+
* Only re-renders when onExampleClick callback changes (which should
|
| 654 |
+
* be stable if defined with useCallback in the parent).
|
| 655 |
+
*/
|
| 656 |
+
const EmptyState = memo(EmptyStateComponent);
|
| 657 |
+
|
| 658 |
+
export { EmptyState };
|
frontend/src/components/chat/error-state.tsx
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ErrorState Component
|
| 3 |
+
*
|
| 4 |
+
* A minimal error display component for the RAG chatbot interface. Handles
|
| 5 |
+
* various error scenarios including quota exceeded (503), network failures,
|
| 6 |
+
* and general errors with distinct visual styling and user-friendly messaging.
|
| 7 |
+
*
|
| 8 |
+
* Features:
|
| 9 |
+
* - Type-specific visual styling using the design system's color palette:
|
| 10 |
+
* - Quota errors: Purple accent (via --color-primary-* CSS variables)
|
| 11 |
+
* - Network errors: Blue semantic color
|
| 12 |
+
* - General errors: Red semantic color
|
| 13 |
+
* - Live countdown timer for quota errors with auto-retry
|
| 14 |
+
* - Retry functionality with disabled state during countdown
|
| 15 |
+
* - Dismiss capability for clearing errors
|
| 16 |
+
* - Clean, minimal design with subtle shadows
|
| 17 |
+
* - Full WCAG AA accessibility compliance
|
| 18 |
+
* - Smooth entrance animations
|
| 19 |
+
*
|
| 20 |
+
* @module components/chat/error-state
|
| 21 |
+
* @since 1.0.0
|
| 22 |
+
*
|
| 23 |
+
* @example
|
| 24 |
+
* // Basic quota error with countdown (displays with purple theme)
|
| 25 |
+
* <ErrorState
|
| 26 |
+
* type="quota"
|
| 27 |
+
* retryAfter={60}
|
| 28 |
+
* onRetry={() => handleRetry()}
|
| 29 |
+
* onDismiss={() => clearError()}
|
| 30 |
+
* />
|
| 31 |
+
*
|
| 32 |
+
* @example
|
| 33 |
+
* // Network error with retry button (displays with blue theme)
|
| 34 |
+
* <ErrorState
|
| 35 |
+
* type="network"
|
| 36 |
+
* onRetry={() => handleRetry()}
|
| 37 |
+
* />
|
| 38 |
+
*
|
| 39 |
+
* @example
|
| 40 |
+
* // General error with custom message (displays with red theme)
|
| 41 |
+
* <ErrorState
|
| 42 |
+
* type="general"
|
| 43 |
+
* message="Failed to process your request. Please try again."
|
| 44 |
+
* onRetry={() => handleRetry()}
|
| 45 |
+
* onDismiss={() => clearError()}
|
| 46 |
+
* />
|
| 47 |
+
*/
|
| 48 |
+
|
| 49 |
+
'use client';
|
| 50 |
+
|
| 51 |
+
import {
|
| 52 |
+
memo,
|
| 53 |
+
useCallback,
|
| 54 |
+
useEffect,
|
| 55 |
+
useState,
|
| 56 |
+
type HTMLAttributes,
|
| 57 |
+
} from 'react';
|
| 58 |
+
import { AlertTriangle, WifiOff, RefreshCw, Clock, X } from 'lucide-react';
|
| 59 |
+
import { cn } from '@/lib/utils';
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Error type definitions for visual and content differentiation.
|
| 63 |
+
*
|
| 64 |
+
* - `quota`: Service temporarily unavailable due to rate limiting (HTTP 503)
|
| 65 |
+
* - `network`: Connection issues preventing communication with the server
|
| 66 |
+
* - `general`: Catch-all for other error types
|
| 67 |
+
*
|
| 68 |
+
* @public
|
| 69 |
+
*/
|
| 70 |
+
export type ErrorType = 'quota' | 'network' | 'general';
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Props for the ErrorState component.
|
| 74 |
+
*
|
| 75 |
+
* Extends standard HTML div attributes for flexibility in styling
|
| 76 |
+
* and event handling while providing error-specific configuration.
|
| 77 |
+
*
|
| 78 |
+
* @public
|
| 79 |
+
*/
|
| 80 |
+
export interface ErrorStateProps
|
| 81 |
+
extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
| 82 |
+
/**
|
| 83 |
+
* Error type for visual distinction and default messaging.
|
| 84 |
+
* Each type has its own icon, color scheme, and default message.
|
| 85 |
+
*
|
| 86 |
+
* - `quota`: Purple styling with clock icon (uses design system's primary color)
|
| 87 |
+
* - `network`: Blue styling with wifi-off icon (semantic network/connection color)
|
| 88 |
+
* - `general`: Red styling with alert triangle icon (semantic error color)
|
| 89 |
+
*/
|
| 90 |
+
type: ErrorType;
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* User-friendly error message to display.
|
| 94 |
+
* If not provided, a default message based on the error type is shown.
|
| 95 |
+
*
|
| 96 |
+
* @example
|
| 97 |
+
* message="Unable to connect to the server. Please try again later."
|
| 98 |
+
*/
|
| 99 |
+
message?: string;
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Seconds until retry is allowed (primarily for quota errors).
|
| 103 |
+
* When provided, displays a live countdown timer and disables
|
| 104 |
+
* the retry button until the countdown reaches zero.
|
| 105 |
+
*
|
| 106 |
+
* @example
|
| 107 |
+
* retryAfter={45} // Shows "Try again in 45s" and counts down
|
| 108 |
+
*/
|
| 109 |
+
retryAfter?: number;
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Callback fired when the retry button is clicked.
|
| 113 |
+
* The retry button is only shown when this callback is provided.
|
| 114 |
+
* For quota errors with retryAfter, the button is disabled during countdown.
|
| 115 |
+
*
|
| 116 |
+
* @param event - The click event from the retry button
|
| 117 |
+
*/
|
| 118 |
+
onRetry?: () => void;
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* Callback fired when the dismiss button is clicked.
|
| 122 |
+
* The dismiss button is only shown when this callback is provided.
|
| 123 |
+
* Use this to clear the error state in the parent component.
|
| 124 |
+
*/
|
| 125 |
+
onDismiss?: () => void;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* Configuration object for error type-specific styling and content.
|
| 130 |
+
*
|
| 131 |
+
* @internal
|
| 132 |
+
*/
|
| 133 |
+
interface ErrorTypeConfig {
|
| 134 |
+
/** Lucide React icon component */
|
| 135 |
+
icon: typeof AlertTriangle;
|
| 136 |
+
/** Default message when no custom message is provided */
|
| 137 |
+
defaultMessage: string;
|
| 138 |
+
/** Tailwind classes for the icon container background */
|
| 139 |
+
iconBgClass: string;
|
| 140 |
+
/** Tailwind classes for the icon color */
|
| 141 |
+
iconColorClass: string;
|
| 142 |
+
/** Tailwind classes for the card border accent */
|
| 143 |
+
borderAccentClass: string;
|
| 144 |
+
/** Tailwind classes for action button styling */
|
| 145 |
+
buttonClass: string;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/**
|
| 149 |
+
* Error type configuration mapping.
|
| 150 |
+
*
|
| 151 |
+
* Defines the visual appearance and default content for each error type.
|
| 152 |
+
* Uses CSS variables from globals.css for consistent theming.
|
| 153 |
+
*
|
| 154 |
+
* Color scheme:
|
| 155 |
+
* - quota: Purple (via --color-primary-* CSS variables) - indicates temporary unavailability
|
| 156 |
+
* - network: Blue - semantic color for connectivity issues
|
| 157 |
+
* - general: Red - semantic color for errors/failures
|
| 158 |
+
*
|
| 159 |
+
* @internal
|
| 160 |
+
*/
|
| 161 |
+
const ERROR_TYPE_CONFIGS: Record<ErrorType, ErrorTypeConfig> = {
|
| 162 |
+
/**
|
| 163 |
+
* Quota error configuration - Purple theme
|
| 164 |
+
*
|
| 165 |
+
* Uses the design system's primary color (purple) via CSS variables.
|
| 166 |
+
* This ensures the quota error styling automatically follows any
|
| 167 |
+
* theme changes to the primary color palette.
|
| 168 |
+
*/
|
| 169 |
+
quota: {
|
| 170 |
+
icon: Clock,
|
| 171 |
+
defaultMessage:
|
| 172 |
+
'Our service is currently at capacity. Please wait a moment.',
|
| 173 |
+
/* Purple gradient background for icon circle */
|
| 174 |
+
iconBgClass:
|
| 175 |
+
'bg-gradient-to-br from-[var(--color-primary-100)] to-[var(--color-primary-200)]',
|
| 176 |
+
/* Purple icon color */
|
| 177 |
+
iconColorClass: 'text-[var(--color-primary-600)]',
|
| 178 |
+
/* Purple border accent */
|
| 179 |
+
borderAccentClass: 'border-[var(--color-primary-300)]',
|
| 180 |
+
/* Purple button with proper contrast for text */
|
| 181 |
+
buttonClass:
|
| 182 |
+
'bg-[var(--color-primary-500)] text-[var(--color-primary-button-text)] hover:bg-[var(--color-primary-600)] disabled:bg-[var(--color-primary-200)] disabled:text-[var(--foreground-muted)]',
|
| 183 |
+
},
|
| 184 |
+
/**
|
| 185 |
+
* Network error configuration - Blue theme
|
| 186 |
+
*
|
| 187 |
+
* Uses semantic blue color for connectivity/network issues.
|
| 188 |
+
* Blue is commonly associated with information and connection states.
|
| 189 |
+
*/
|
| 190 |
+
network: {
|
| 191 |
+
icon: WifiOff,
|
| 192 |
+
defaultMessage: 'Unable to connect. Please check your internet connection.',
|
| 193 |
+
/* Blue gradient background for icon circle */
|
| 194 |
+
iconBgClass: 'bg-gradient-to-br from-blue-100 to-blue-200',
|
| 195 |
+
/* Blue icon color */
|
| 196 |
+
iconColorClass: 'text-blue-600',
|
| 197 |
+
/* Blue border accent */
|
| 198 |
+
borderAccentClass: 'border-blue-300',
|
| 199 |
+
/* Blue button styling */
|
| 200 |
+
buttonClass:
|
| 201 |
+
'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-blue-200 disabled:text-blue-400',
|
| 202 |
+
},
|
| 203 |
+
/**
|
| 204 |
+
* General error configuration - Red theme
|
| 205 |
+
*
|
| 206 |
+
* Uses semantic red color for general errors and failures.
|
| 207 |
+
* Red is universally recognized as an error/warning indicator.
|
| 208 |
+
*/
|
| 209 |
+
general: {
|
| 210 |
+
icon: AlertTriangle,
|
| 211 |
+
defaultMessage: 'Something went wrong. Please try again.',
|
| 212 |
+
/* Red gradient background for icon circle */
|
| 213 |
+
iconBgClass: 'bg-gradient-to-br from-red-100 to-red-200',
|
| 214 |
+
/* Red icon color */
|
| 215 |
+
iconColorClass: 'text-red-600',
|
| 216 |
+
/* Red border accent */
|
| 217 |
+
borderAccentClass: 'border-red-300',
|
| 218 |
+
/* Red button styling */
|
| 219 |
+
buttonClass:
|
| 220 |
+
'bg-red-500 text-white hover:bg-red-600 disabled:bg-red-200 disabled:text-red-400',
|
| 221 |
+
},
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
/**
|
| 225 |
+
* Formats seconds into a human-readable countdown string.
|
| 226 |
+
*
|
| 227 |
+
* Converts raw seconds into minutes:seconds format when appropriate,
|
| 228 |
+
* or displays just seconds for shorter durations.
|
| 229 |
+
*
|
| 230 |
+
* @param seconds - Number of seconds remaining
|
| 231 |
+
* @returns Formatted string (e.g., "45s", "1:30")
|
| 232 |
+
*
|
| 233 |
+
* @internal
|
| 234 |
+
*/
|
| 235 |
+
function formatCountdown(seconds: number): string {
|
| 236 |
+
if (seconds <= 0) return '0s';
|
| 237 |
+
|
| 238 |
+
if (seconds < 60) {
|
| 239 |
+
return `${seconds}s`;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const minutes = Math.floor(seconds / 60);
|
| 243 |
+
const remainingSeconds = seconds % 60;
|
| 244 |
+
|
| 245 |
+
// Format with leading zero for seconds when showing minutes
|
| 246 |
+
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/**
|
| 250 |
+
* Custom hook for managing the countdown timer.
|
| 251 |
+
*
|
| 252 |
+
* Handles the countdown logic with proper cleanup on unmount.
|
| 253 |
+
* Returns the current countdown value and whether the countdown is active.
|
| 254 |
+
*
|
| 255 |
+
* @param initialSeconds - Starting value for the countdown
|
| 256 |
+
* @returns Object containing current seconds and active state
|
| 257 |
+
*
|
| 258 |
+
* @internal
|
| 259 |
+
*/
|
| 260 |
+
function useCountdown(initialSeconds: number | undefined): {
|
| 261 |
+
secondsRemaining: number;
|
| 262 |
+
isCountdownActive: boolean;
|
| 263 |
+
} {
|
| 264 |
+
// Initialize state with the provided value or 0
|
| 265 |
+
const [secondsRemaining, setSecondsRemaining] = useState<number>(
|
| 266 |
+
initialSeconds ?? 0
|
| 267 |
+
);
|
| 268 |
+
|
| 269 |
+
// Determine if countdown is active (has time remaining)
|
| 270 |
+
const isCountdownActive = secondsRemaining > 0;
|
| 271 |
+
|
| 272 |
+
/**
|
| 273 |
+
* Effect to reset countdown when initialSeconds prop changes AND
|
| 274 |
+
* handle the countdown timer interval.
|
| 275 |
+
*
|
| 276 |
+
* This pattern of calling setState at the start of an effect to reset
|
| 277 |
+
* state when a prop changes is intentional - it's the standard React
|
| 278 |
+
* pattern for synchronizing state with props.
|
| 279 |
+
* See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
| 280 |
+
*
|
| 281 |
+
* Creates an interval that decrements the counter every second.
|
| 282 |
+
* Cleans up the interval when:
|
| 283 |
+
* - Component unmounts
|
| 284 |
+
* - Countdown reaches zero
|
| 285 |
+
* - initialSeconds prop changes
|
| 286 |
+
*/
|
| 287 |
+
useEffect(() => {
|
| 288 |
+
// Reset the countdown when initialSeconds changes
|
| 289 |
+
// This is the synchronization pattern - reset state when prop changes
|
| 290 |
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
| 291 |
+
setSecondsRemaining(initialSeconds ?? 0);
|
| 292 |
+
|
| 293 |
+
// Don't start timer if no initial value or zero
|
| 294 |
+
if (!initialSeconds || initialSeconds <= 0) {
|
| 295 |
+
return;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// Create interval to decrement countdown every second
|
| 299 |
+
const intervalId = setInterval(() => {
|
| 300 |
+
setSecondsRemaining((prev) => {
|
| 301 |
+
// Stop at zero
|
| 302 |
+
if (prev <= 1) {
|
| 303 |
+
clearInterval(intervalId);
|
| 304 |
+
return 0;
|
| 305 |
+
}
|
| 306 |
+
return prev - 1;
|
| 307 |
+
});
|
| 308 |
+
}, 1000);
|
| 309 |
+
|
| 310 |
+
// Cleanup interval on unmount or when initialSeconds changes
|
| 311 |
+
return () => {
|
| 312 |
+
clearInterval(intervalId);
|
| 313 |
+
};
|
| 314 |
+
}, [initialSeconds]);
|
| 315 |
+
|
| 316 |
+
return { secondsRemaining, isCountdownActive };
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* ErrorState Component
|
| 321 |
+
*
|
| 322 |
+
* Displays a minimal error state card with type-specific styling,
|
| 323 |
+
* optional countdown timer, and action buttons for retry/dismiss.
|
| 324 |
+
*
|
| 325 |
+
* @remarks
|
| 326 |
+
* ## Visual Design
|
| 327 |
+
*
|
| 328 |
+
* The component features a clean, minimal card design with:
|
| 329 |
+
* - Type-specific icon in a gradient circle
|
| 330 |
+
* - Clear heading and descriptive message
|
| 331 |
+
* - Optional countdown timer display (purple-themed for quota errors)
|
| 332 |
+
* - Action buttons (retry and dismiss)
|
| 333 |
+
* - Subtle shadow for depth without overwhelming the UI
|
| 334 |
+
*
|
| 335 |
+
* ## Color Theming
|
| 336 |
+
*
|
| 337 |
+
* Each error type uses a distinct color to provide semantic meaning:
|
| 338 |
+
* - Quota errors: Purple (via CSS variables --color-primary-*)
|
| 339 |
+
* - Network errors: Blue (semantic connectivity color)
|
| 340 |
+
* - General errors: Red (semantic error color)
|
| 341 |
+
*
|
| 342 |
+
* ## Countdown Timer
|
| 343 |
+
*
|
| 344 |
+
* For quota errors (503 responses), the component can display a live
|
| 345 |
+
* countdown timer that shows when the user can retry:
|
| 346 |
+
* - Timer updates every second with purple-themed styling
|
| 347 |
+
* - Retry button is disabled during countdown
|
| 348 |
+
* - Timer automatically enables retry when it reaches zero
|
| 349 |
+
* - Proper cleanup on component unmount
|
| 350 |
+
*
|
| 351 |
+
* ## Accessibility
|
| 352 |
+
*
|
| 353 |
+
* The component follows WCAG AA guidelines:
|
| 354 |
+
* - Uses `role="alert"` for screen reader announcements
|
| 355 |
+
* - `aria-live="assertive"` for immediate announcement of errors
|
| 356 |
+
* - Decorative icons are hidden from screen readers
|
| 357 |
+
* - All interactive elements are keyboard accessible
|
| 358 |
+
* - Focus states are clearly visible
|
| 359 |
+
*
|
| 360 |
+
* ## Animation
|
| 361 |
+
*
|
| 362 |
+
* Uses the slide-up animation for smooth entrance, consistent
|
| 363 |
+
* with other components in the application.
|
| 364 |
+
*
|
| 365 |
+
* @param props - ErrorState component props
|
| 366 |
+
* @returns React element containing the error display
|
| 367 |
+
*
|
| 368 |
+
* @see {@link ErrorStateProps} for full prop documentation
|
| 369 |
+
*/
|
| 370 |
+
function ErrorStateComponent({
|
| 371 |
+
type,
|
| 372 |
+
message,
|
| 373 |
+
retryAfter,
|
| 374 |
+
onRetry,
|
| 375 |
+
onDismiss,
|
| 376 |
+
className,
|
| 377 |
+
...props
|
| 378 |
+
}: ErrorStateProps): React.ReactElement {
|
| 379 |
+
/**
|
| 380 |
+
* Get configuration for the current error type.
|
| 381 |
+
* Includes icon, colors, and default message.
|
| 382 |
+
*/
|
| 383 |
+
const config = ERROR_TYPE_CONFIGS[type];
|
| 384 |
+
|
| 385 |
+
/**
|
| 386 |
+
* Use the countdown hook to manage timer state.
|
| 387 |
+
* Provides current seconds remaining and active state.
|
| 388 |
+
*/
|
| 389 |
+
const { secondsRemaining, isCountdownActive } = useCountdown(retryAfter);
|
| 390 |
+
|
| 391 |
+
/**
|
| 392 |
+
* Determine the message to display.
|
| 393 |
+
* Use custom message if provided, otherwise fall back to type default.
|
| 394 |
+
*/
|
| 395 |
+
const displayMessage = message || config.defaultMessage;
|
| 396 |
+
|
| 397 |
+
/**
|
| 398 |
+
* Determine if retry button should be disabled.
|
| 399 |
+
* Disabled during countdown for quota errors.
|
| 400 |
+
*/
|
| 401 |
+
const isRetryDisabled = type === 'quota' && isCountdownActive;
|
| 402 |
+
|
| 403 |
+
/**
|
| 404 |
+
* Memoized retry handler to prevent unnecessary re-renders.
|
| 405 |
+
* Only calls onRetry if provided and not disabled.
|
| 406 |
+
*/
|
| 407 |
+
const handleRetry = useCallback(() => {
|
| 408 |
+
if (onRetry && !isRetryDisabled) {
|
| 409 |
+
onRetry();
|
| 410 |
+
}
|
| 411 |
+
}, [onRetry, isRetryDisabled]);
|
| 412 |
+
|
| 413 |
+
/**
|
| 414 |
+
* Memoized dismiss handler to prevent unnecessary re-renders.
|
| 415 |
+
*/
|
| 416 |
+
const handleDismiss = useCallback(() => {
|
| 417 |
+
if (onDismiss) {
|
| 418 |
+
onDismiss();
|
| 419 |
+
}
|
| 420 |
+
}, [onDismiss]);
|
| 421 |
+
|
| 422 |
+
// Get the icon component from config
|
| 423 |
+
const IconComponent = config.icon;
|
| 424 |
+
|
| 425 |
+
return (
|
| 426 |
+
<div
|
| 427 |
+
role="alert"
|
| 428 |
+
aria-live="assertive"
|
| 429 |
+
className={cn(
|
| 430 |
+
/* Full width container with max width constraint */
|
| 431 |
+
'mx-auto w-full max-w-lg',
|
| 432 |
+
/* Spacing */
|
| 433 |
+
'px-4 py-6',
|
| 434 |
+
/* Animation on entrance */
|
| 435 |
+
'animate-slide-up',
|
| 436 |
+
className
|
| 437 |
+
)}
|
| 438 |
+
{...props}
|
| 439 |
+
>
|
| 440 |
+
{/* Card container with clean, minimal design */}
|
| 441 |
+
<div
|
| 442 |
+
className={cn(
|
| 443 |
+
/* Solid background for clean minimal aesthetic */
|
| 444 |
+
'bg-[var(--background-secondary)]',
|
| 445 |
+
/* Type-specific border accent for visual distinction */
|
| 446 |
+
'border',
|
| 447 |
+
config.borderAccentClass,
|
| 448 |
+
/* Rounded corners */
|
| 449 |
+
'rounded-2xl',
|
| 450 |
+
/* Subtle shadow for depth without overwhelming */
|
| 451 |
+
'shadow-[var(--shadow-md)]',
|
| 452 |
+
/* Padding */
|
| 453 |
+
'p-6',
|
| 454 |
+
/* Relative for dismiss button positioning */
|
| 455 |
+
'relative'
|
| 456 |
+
)}
|
| 457 |
+
>
|
| 458 |
+
{/* Dismiss button - positioned in top right corner */}
|
| 459 |
+
{onDismiss && (
|
| 460 |
+
<button
|
| 461 |
+
type="button"
|
| 462 |
+
onClick={handleDismiss}
|
| 463 |
+
aria-label="Dismiss error"
|
| 464 |
+
className={cn(
|
| 465 |
+
/* Positioning */
|
| 466 |
+
'absolute top-3 right-3',
|
| 467 |
+
/* Size and shape */
|
| 468 |
+
'h-8 w-8 rounded-full',
|
| 469 |
+
/* Flex for centering icon */
|
| 470 |
+
'flex items-center justify-center',
|
| 471 |
+
/* Colors and hover state */
|
| 472 |
+
'text-[var(--foreground-muted)]',
|
| 473 |
+
'hover:bg-[var(--background-tertiary)]',
|
| 474 |
+
'hover:text-[var(--foreground-secondary)]',
|
| 475 |
+
/* Transition */
|
| 476 |
+
'transition-colors duration-[var(--transition-fast)]',
|
| 477 |
+
/* Focus state for accessibility */
|
| 478 |
+
'focus:outline-none',
|
| 479 |
+
'focus-visible:ring-2',
|
| 480 |
+
'focus-visible:ring-[var(--border-focus)]',
|
| 481 |
+
'focus-visible:ring-offset-2'
|
| 482 |
+
)}
|
| 483 |
+
>
|
| 484 |
+
<X className="h-4 w-4" aria-hidden="true" />
|
| 485 |
+
</button>
|
| 486 |
+
)}
|
| 487 |
+
|
| 488 |
+
{/* Icon section */}
|
| 489 |
+
<div className="mb-4 flex justify-center">
|
| 490 |
+
<div
|
| 491 |
+
className={cn(
|
| 492 |
+
/* Circular container for icon */
|
| 493 |
+
'h-14 w-14 rounded-full',
|
| 494 |
+
/* Type-specific gradient background */
|
| 495 |
+
config.iconBgClass,
|
| 496 |
+
/* Flex for centering icon */
|
| 497 |
+
'flex items-center justify-center',
|
| 498 |
+
/* Shadow for depth */
|
| 499 |
+
'shadow-[var(--shadow-md)]'
|
| 500 |
+
)}
|
| 501 |
+
>
|
| 502 |
+
<IconComponent
|
| 503 |
+
className={cn('h-7 w-7', config.iconColorClass)}
|
| 504 |
+
strokeWidth={1.5}
|
| 505 |
+
aria-hidden="true"
|
| 506 |
+
/>
|
| 507 |
+
</div>
|
| 508 |
+
</div>
|
| 509 |
+
|
| 510 |
+
{/* Title section */}
|
| 511 |
+
<div className="mb-4 text-center">
|
| 512 |
+
<h3
|
| 513 |
+
className={cn(
|
| 514 |
+
/* Typography */
|
| 515 |
+
'text-lg font-semibold',
|
| 516 |
+
'text-[var(--foreground)]',
|
| 517 |
+
/* Spacing */
|
| 518 |
+
'mb-2'
|
| 519 |
+
)}
|
| 520 |
+
>
|
| 521 |
+
{type === 'quota' && 'Service Temporarily Unavailable'}
|
| 522 |
+
{type === 'network' && 'Connection Lost'}
|
| 523 |
+
{type === 'general' && 'Oops! Something Went Wrong'}
|
| 524 |
+
</h3>
|
| 525 |
+
<p
|
| 526 |
+
className={cn(
|
| 527 |
+
/* Typography */
|
| 528 |
+
'text-[var(--foreground-secondary)]',
|
| 529 |
+
'text-sm leading-relaxed',
|
| 530 |
+
/* Max width for readability */
|
| 531 |
+
'mx-auto max-w-sm'
|
| 532 |
+
)}
|
| 533 |
+
>
|
| 534 |
+
{displayMessage}
|
| 535 |
+
</p>
|
| 536 |
+
</div>
|
| 537 |
+
|
| 538 |
+
{/*
|
| 539 |
+
Countdown timer display (only for quota errors with retryAfter)
|
| 540 |
+
Uses purple theme via CSS variables (--color-primary-*)
|
| 541 |
+
to match the quota error styling
|
| 542 |
+
*/}
|
| 543 |
+
{type === 'quota' && retryAfter !== undefined && retryAfter > 0 && (
|
| 544 |
+
<div
|
| 545 |
+
className={cn(
|
| 546 |
+
/* Container styling with purple background tint */
|
| 547 |
+
'mb-4 mx-auto max-w-xs',
|
| 548 |
+
'px-4 py-2',
|
| 549 |
+
'bg-[var(--color-primary-50)]/50',
|
| 550 |
+
/* Purple border to match quota error theme */
|
| 551 |
+
'border border-[var(--color-primary-200)]',
|
| 552 |
+
'rounded-[var(--radius-lg)]',
|
| 553 |
+
/* Flex layout for icon and text alignment */
|
| 554 |
+
'flex items-center justify-center gap-2',
|
| 555 |
+
/* Smooth fade-in animation */
|
| 556 |
+
'animate-fade-in'
|
| 557 |
+
)}
|
| 558 |
+
aria-live="polite"
|
| 559 |
+
aria-atomic="true"
|
| 560 |
+
>
|
| 561 |
+
{/* Clock icon in purple */}
|
| 562 |
+
<Clock
|
| 563 |
+
className="h-4 w-4 text-[var(--color-primary-500)]"
|
| 564 |
+
strokeWidth={2}
|
| 565 |
+
aria-hidden="true"
|
| 566 |
+
/>
|
| 567 |
+
{/* Countdown text in purple for visual consistency */}
|
| 568 |
+
<span className="text-sm font-medium text-[var(--color-primary-700)]">
|
| 569 |
+
{isCountdownActive
|
| 570 |
+
? `Try again in ${formatCountdown(secondsRemaining)}`
|
| 571 |
+
: 'Ready to retry'}
|
| 572 |
+
</span>
|
| 573 |
+
</div>
|
| 574 |
+
)}
|
| 575 |
+
|
| 576 |
+
{/*
|
| 577 |
+
Action buttons section
|
| 578 |
+
Button color matches the error type for visual consistency:
|
| 579 |
+
- Quota: Purple button (via CSS variables)
|
| 580 |
+
- Network: Blue button (semantic connectivity color)
|
| 581 |
+
- General: Red button (semantic error color)
|
| 582 |
+
*/}
|
| 583 |
+
{onRetry && (
|
| 584 |
+
<div className="flex justify-center">
|
| 585 |
+
<button
|
| 586 |
+
type="button"
|
| 587 |
+
onClick={handleRetry}
|
| 588 |
+
disabled={isRetryDisabled}
|
| 589 |
+
className={cn(
|
| 590 |
+
/* Base button styles */
|
| 591 |
+
'px-5 py-2.5',
|
| 592 |
+
'rounded-[var(--radius-lg)]',
|
| 593 |
+
/* Font styling */
|
| 594 |
+
'text-sm font-medium',
|
| 595 |
+
/* Flex for icon alignment */
|
| 596 |
+
'inline-flex items-center gap-2',
|
| 597 |
+
/*
|
| 598 |
+
Type-specific button colors:
|
| 599 |
+
- quota: Purple (CSS variables)
|
| 600 |
+
- network: Blue
|
| 601 |
+
- general: Red
|
| 602 |
+
*/
|
| 603 |
+
config.buttonClass,
|
| 604 |
+
/* Subtle shadow */
|
| 605 |
+
'shadow-[var(--shadow-sm)]',
|
| 606 |
+
/* Smooth transitions */
|
| 607 |
+
'transition-all duration-[var(--transition-fast)]',
|
| 608 |
+
/* Hover shadow enhancement (when not disabled) */
|
| 609 |
+
'hover:shadow-[var(--shadow-md)]',
|
| 610 |
+
/* Focus state for accessibility */
|
| 611 |
+
'focus:outline-none',
|
| 612 |
+
'focus-visible:ring-2',
|
| 613 |
+
'focus-visible:ring-[var(--border-focus)]',
|
| 614 |
+
'focus-visible:ring-offset-2',
|
| 615 |
+
/* Disabled state styling */
|
| 616 |
+
'disabled:cursor-not-allowed',
|
| 617 |
+
'disabled:shadow-none'
|
| 618 |
+
)}
|
| 619 |
+
>
|
| 620 |
+
<RefreshCw
|
| 621 |
+
className={cn(
|
| 622 |
+
'h-4 w-4',
|
| 623 |
+
/* Animate icon when countdown is active to indicate waiting */
|
| 624 |
+
isCountdownActive && 'animate-pulse-custom'
|
| 625 |
+
)}
|
| 626 |
+
strokeWidth={2}
|
| 627 |
+
aria-hidden="true"
|
| 628 |
+
/>
|
| 629 |
+
<span>{isRetryDisabled ? 'Please wait...' : 'Try Again'}</span>
|
| 630 |
+
</button>
|
| 631 |
+
</div>
|
| 632 |
+
)}
|
| 633 |
+
</div>
|
| 634 |
+
</div>
|
| 635 |
+
);
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
/* Display name for React DevTools */
|
| 639 |
+
ErrorStateComponent.displayName = 'ErrorState';
|
| 640 |
+
|
| 641 |
+
/**
|
| 642 |
+
* Memoized ErrorState for performance optimization.
|
| 643 |
+
*
|
| 644 |
+
* The ErrorState component benefits from memoization as it may be
|
| 645 |
+
* rendered within frequently updating parent components. Only re-renders
|
| 646 |
+
* when props change, preventing unnecessary work during chat updates.
|
| 647 |
+
*
|
| 648 |
+
* @public
|
| 649 |
+
*/
|
| 650 |
+
const ErrorState = memo(ErrorStateComponent);
|
| 651 |
+
|
| 652 |
+
export { ErrorState };
|
frontend/src/components/chat/index.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Chat Components Barrel Export
|
| 3 |
+
*
|
| 4 |
+
* Re-exports all chat-specific components for convenient imports.
|
| 5 |
+
* These components work together to create the chat interface for
|
| 6 |
+
* the RAG chatbot application.
|
| 7 |
+
*
|
| 8 |
+
* @module components/chat
|
| 9 |
+
* @since 1.0.0
|
| 10 |
+
*
|
| 11 |
+
* @example
|
| 12 |
+
* // Import individual components
|
| 13 |
+
* import { ChatMessage, ChatInput, ChatContainer } from '@/components/chat';
|
| 14 |
+
*
|
| 15 |
+
* @example
|
| 16 |
+
* // Use components together in a page
|
| 17 |
+
* import { ChatContainer } from '@/components/chat';
|
| 18 |
+
*
|
| 19 |
+
* function ChatPage() {
|
| 20 |
+
* return <ChatContainer title="My Chat" />;
|
| 21 |
+
* }
|
| 22 |
+
*
|
| 23 |
+
* @example
|
| 24 |
+
* // Build a custom chat interface with lower-level components
|
| 25 |
+
* import {
|
| 26 |
+
* MemoizedChatMessage,
|
| 27 |
+
* ChatInput,
|
| 28 |
+
* EmptyState,
|
| 29 |
+
* } from '@/components/chat';
|
| 30 |
+
* import { useChat } from '@/hooks/use-chat';
|
| 31 |
+
*
|
| 32 |
+
* function CustomChatInterface() {
|
| 33 |
+
* const { messages, addMessage, isLoading } = useChat();
|
| 34 |
+
*
|
| 35 |
+
* return (
|
| 36 |
+
* <div>
|
| 37 |
+
* {messages.length === 0 ? (
|
| 38 |
+
* <EmptyState onExampleClick={(q) => addMessage('user', q)} />
|
| 39 |
+
* ) : (
|
| 40 |
+
* messages.map(msg => (
|
| 41 |
+
* <MemoizedChatMessage key={msg.id} message={msg} />
|
| 42 |
+
* ))
|
| 43 |
+
* )}
|
| 44 |
+
* <ChatInput onSubmit={(content) => addMessage('user', content)} isLoading={isLoading} />
|
| 45 |
+
* </div>
|
| 46 |
+
* );
|
| 47 |
+
* }
|
| 48 |
+
*/
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* ChatMessage - Display component for individual chat messages.
|
| 52 |
+
*
|
| 53 |
+
* Renders user and assistant messages with distinct styling:
|
| 54 |
+
* - User messages: Right-aligned, primary orange background
|
| 55 |
+
* - Assistant messages: Left-aligned, secondary gray background
|
| 56 |
+
*
|
| 57 |
+
* Supports streaming state with animated cursor.
|
| 58 |
+
*
|
| 59 |
+
* @see {@link ./chat-message} for full documentation
|
| 60 |
+
*/
|
| 61 |
+
export { ChatMessage, MemoizedChatMessage } from './chat-message';
|
| 62 |
+
export type { ChatMessageProps } from './chat-message';
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* ChatInput - Input component for composing chat messages.
|
| 66 |
+
*
|
| 67 |
+
* Features:
|
| 68 |
+
* - Auto-growing textarea (up to 200px)
|
| 69 |
+
* - Enter to submit, Shift+Enter for new line
|
| 70 |
+
* - Character limit indicator
|
| 71 |
+
* - Loading state with spinner
|
| 72 |
+
* - Imperative handle for programmatic control
|
| 73 |
+
*
|
| 74 |
+
* @see {@link ./chat-input} for full documentation
|
| 75 |
+
*/
|
| 76 |
+
export { ChatInput } from './chat-input';
|
| 77 |
+
export type { ChatInputProps, ChatInputHandle } from './chat-input';
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* ChatContainer - Main orchestrating component for the chat interface.
|
| 81 |
+
*
|
| 82 |
+
* The primary component for integrating a full chat experience.
|
| 83 |
+
* Combines header, message list, empty state, and input area.
|
| 84 |
+
*
|
| 85 |
+
* Features:
|
| 86 |
+
* - Header with title and optional subtitle
|
| 87 |
+
* - Scrollable message list with auto-scroll
|
| 88 |
+
* - Empty state with example questions
|
| 89 |
+
* - Fixed input area at bottom
|
| 90 |
+
* - Error banner for error display
|
| 91 |
+
* - Loading indicator during response generation
|
| 92 |
+
*
|
| 93 |
+
* @see {@link ./chat-container} for full documentation
|
| 94 |
+
*/
|
| 95 |
+
export { ChatContainer } from './chat-container';
|
| 96 |
+
export type { ChatContainerProps } from './chat-container';
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* EmptyState - Welcome component shown when chat has no messages.
|
| 100 |
+
*
|
| 101 |
+
* Displays a friendly introduction with example questions that
|
| 102 |
+
* users can click to quickly start a conversation.
|
| 103 |
+
*
|
| 104 |
+
* Features:
|
| 105 |
+
* - Welcoming icon and title
|
| 106 |
+
* - Descriptive subtitle
|
| 107 |
+
* - Clickable example question buttons
|
| 108 |
+
* - Modern card design with glassmorphism effect
|
| 109 |
+
*
|
| 110 |
+
* @see {@link ./empty-state} for full documentation
|
| 111 |
+
*/
|
| 112 |
+
export { EmptyState } from './empty-state';
|
| 113 |
+
export type { EmptyStateProps } from './empty-state';
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* ErrorState - Error display component for various error scenarios.
|
| 117 |
+
*
|
| 118 |
+
* A premium error state component that handles quota exceeded (503),
|
| 119 |
+
* network failures, and general errors with distinct visual styling.
|
| 120 |
+
*
|
| 121 |
+
* Features:
|
| 122 |
+
* - Type-specific visual styling (quota, network, general)
|
| 123 |
+
* - Live countdown timer for quota errors with auto-retry
|
| 124 |
+
* - Retry and dismiss functionality
|
| 125 |
+
* - Glassmorphism card design
|
| 126 |
+
* - Full WCAG AA accessibility compliance
|
| 127 |
+
*
|
| 128 |
+
* @see {@link ./error-state} for full documentation
|
| 129 |
+
*/
|
| 130 |
+
export { ErrorState } from './error-state';
|
| 131 |
+
export type { ErrorStateProps, ErrorType } from './error-state';
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* ProviderToggle - LLM provider selection component (pill layout).
|
| 135 |
+
*
|
| 136 |
+
* A premium horizontal pill/chip layout for selecting LLM providers
|
| 137 |
+
* with real-time status indicators and cooldown timers.
|
| 138 |
+
*
|
| 139 |
+
* Features:
|
| 140 |
+
* - Horizontal pill layout with "Auto" as first option
|
| 141 |
+
* - Green pulse dot for available providers
|
| 142 |
+
* - Red dot with countdown for providers in cooldown
|
| 143 |
+
* - Full keyboard navigation (arrow keys, Home/End)
|
| 144 |
+
* - Radio group accessibility semantics
|
| 145 |
+
* - Responsive: stacks vertically on mobile
|
| 146 |
+
*
|
| 147 |
+
* @see {@link ./provider-toggle} for full documentation
|
| 148 |
+
*/
|
| 149 |
+
export { ProviderToggle } from './provider-toggle';
|
| 150 |
+
export type { ProviderToggleProps } from './provider-toggle';
|
| 151 |
+
|
| 152 |
+
/**
|
| 153 |
+
* ProviderSelector - Claude-style dropdown for LLM provider selection.
|
| 154 |
+
*
|
| 155 |
+
* A minimal dropdown selector styled after Claude's model selector,
|
| 156 |
+
* designed to be placed in the input area near the send button.
|
| 157 |
+
*
|
| 158 |
+
* Features:
|
| 159 |
+
* - Compact dropdown trigger showing selected provider
|
| 160 |
+
* - Dropdown menu with all available providers
|
| 161 |
+
* - Status indicators (available, cooldown)
|
| 162 |
+
* - Cooldown timer display
|
| 163 |
+
* - Keyboard navigation support
|
| 164 |
+
*
|
| 165 |
+
* @see {@link ./provider-selector} for full documentation
|
| 166 |
+
*/
|
| 167 |
+
export { ProviderSelector } from './provider-selector';
|
| 168 |
+
export type { ProviderSelectorProps } from './provider-selector';
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* Sidebar - Collapsible left sidebar showing current provider/model.
|
| 172 |
+
*
|
| 173 |
+
* Displays the currently selected LLM provider and model name.
|
| 174 |
+
* Collapses to icon-only mode for a minimal footprint.
|
| 175 |
+
*
|
| 176 |
+
* Features:
|
| 177 |
+
* - Shows provider name and model name
|
| 178 |
+
* - Collapsible to icon-only mode
|
| 179 |
+
* - Persists collapse state to localStorage
|
| 180 |
+
* - Status indicators for availability
|
| 181 |
+
*
|
| 182 |
+
* @see {@link ./sidebar} for full documentation
|
| 183 |
+
*/
|
| 184 |
+
export { Sidebar } from './sidebar';
|
| 185 |
+
export type { SidebarProps } from './sidebar';
|
| 186 |
+
|
| 187 |
+
// Future exports (to be implemented in subsequent steps):
|
| 188 |
+
// export * from './source-card';
|
| 189 |
+
// export * from './message-list';
|
frontend/src/components/chat/provider-selector.tsx
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ProviderSelector Component - Claude-Style Model Dropdown
|
| 3 |
+
*
|
| 4 |
+
* A minimal dropdown selector for LLM providers, styled after Claude's
|
| 5 |
+
* model selector in the input area. Shows the currently selected provider
|
| 6 |
+
* with a chevron indicator, and opens a dropdown for selection.
|
| 7 |
+
*
|
| 8 |
+
* @module components/chat/provider-selector
|
| 9 |
+
* @since 1.0.0
|
| 10 |
+
*
|
| 11 |
+
* @example
|
| 12 |
+
* // Usage in ChatInput
|
| 13 |
+
* <ProviderSelector />
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
'use client';
|
| 17 |
+
|
| 18 |
+
import {
|
| 19 |
+
useCallback,
|
| 20 |
+
useEffect,
|
| 21 |
+
useMemo,
|
| 22 |
+
useRef,
|
| 23 |
+
useState,
|
| 24 |
+
type KeyboardEvent,
|
| 25 |
+
type ReactElement,
|
| 26 |
+
} from 'react';
|
| 27 |
+
import { ChevronDown, Zap, Check, Clock } from 'lucide-react';
|
| 28 |
+
import { cn } from '@/lib/utils';
|
| 29 |
+
import { useProviders, type ProviderStatus } from '@/hooks';
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Props for the ProviderSelector component.
|
| 33 |
+
*/
|
| 34 |
+
export interface ProviderSelectorProps {
|
| 35 |
+
/** Additional CSS classes */
|
| 36 |
+
className?: string;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Provider option type including Auto option.
|
| 41 |
+
* @internal
|
| 42 |
+
*/
|
| 43 |
+
interface ProviderOption {
|
| 44 |
+
id: string | null;
|
| 45 |
+
name: string;
|
| 46 |
+
isAvailable: boolean;
|
| 47 |
+
cooldownSeconds: number | null;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Auto option configuration.
|
| 52 |
+
* @internal
|
| 53 |
+
*/
|
| 54 |
+
const AUTO_OPTION: ProviderOption = {
|
| 55 |
+
id: null,
|
| 56 |
+
name: 'Auto',
|
| 57 |
+
isAvailable: true,
|
| 58 |
+
cooldownSeconds: null,
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Format cooldown seconds.
|
| 63 |
+
* @internal
|
| 64 |
+
*/
|
| 65 |
+
function formatCooldown(seconds: number): string {
|
| 66 |
+
if (seconds <= 0) return '';
|
| 67 |
+
const minutes = Math.floor(seconds / 60);
|
| 68 |
+
const remainingSeconds = seconds % 60;
|
| 69 |
+
if (minutes > 0) {
|
| 70 |
+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
| 71 |
+
}
|
| 72 |
+
return `${remainingSeconds}s`;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Transform providers to options.
|
| 77 |
+
* @internal
|
| 78 |
+
*/
|
| 79 |
+
function transformToOptions(providers: ProviderStatus[]): ProviderOption[] {
|
| 80 |
+
const providerOptions: ProviderOption[] = providers.map((provider) => ({
|
| 81 |
+
id: provider.id,
|
| 82 |
+
name: provider.name,
|
| 83 |
+
isAvailable: provider.isAvailable,
|
| 84 |
+
cooldownSeconds: provider.cooldownSeconds,
|
| 85 |
+
}));
|
| 86 |
+
return [AUTO_OPTION, ...providerOptions];
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Status dot indicator.
|
| 91 |
+
* @internal
|
| 92 |
+
*/
|
| 93 |
+
function StatusDot({ isAvailable, hasCooldown }: { isAvailable: boolean; hasCooldown: boolean }): ReactElement {
|
| 94 |
+
if (hasCooldown) {
|
| 95 |
+
return <span className="h-1.5 w-1.5 rounded-full bg-amber-500" />;
|
| 96 |
+
}
|
| 97 |
+
if (isAvailable) {
|
| 98 |
+
return <span className="h-1.5 w-1.5 rounded-full bg-green-500" />;
|
| 99 |
+
}
|
| 100 |
+
return <span className="h-1.5 w-1.5 rounded-full bg-gray-400" />;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* ProviderSelector Component
|
| 105 |
+
*
|
| 106 |
+
* A Claude-style dropdown for selecting LLM providers. Positioned in the
|
| 107 |
+
* input area, it shows the current selection with a minimal design that
|
| 108 |
+
* expands to show all available providers.
|
| 109 |
+
*/
|
| 110 |
+
export function ProviderSelector({ className }: ProviderSelectorProps): ReactElement {
|
| 111 |
+
const {
|
| 112 |
+
providers,
|
| 113 |
+
selectedProvider,
|
| 114 |
+
selectProvider,
|
| 115 |
+
isLoading,
|
| 116 |
+
} = useProviders();
|
| 117 |
+
|
| 118 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 119 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 120 |
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
| 121 |
+
|
| 122 |
+
/* Transform providers to options */
|
| 123 |
+
const options = useMemo(() => transformToOptions(providers), [providers]);
|
| 124 |
+
|
| 125 |
+
/* Find selected option */
|
| 126 |
+
const selectedOption = useMemo(() => {
|
| 127 |
+
return options.find((opt) => opt.id === selectedProvider) ?? AUTO_OPTION;
|
| 128 |
+
}, [options, selectedProvider]);
|
| 129 |
+
|
| 130 |
+
/* Handle outside click to close dropdown */
|
| 131 |
+
useEffect(() => {
|
| 132 |
+
function handleClickOutside(event: MouseEvent) {
|
| 133 |
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
| 134 |
+
setIsOpen(false);
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
if (isOpen) {
|
| 139 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 140 |
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 141 |
+
}
|
| 142 |
+
}, [isOpen]);
|
| 143 |
+
|
| 144 |
+
/* Handle escape key to close */
|
| 145 |
+
useEffect(() => {
|
| 146 |
+
function handleEscape(event: globalThis.KeyboardEvent) {
|
| 147 |
+
if (event.key === 'Escape' && isOpen) {
|
| 148 |
+
setIsOpen(false);
|
| 149 |
+
buttonRef.current?.focus();
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
document.addEventListener('keydown', handleEscape);
|
| 154 |
+
return () => document.removeEventListener('keydown', handleEscape);
|
| 155 |
+
}, [isOpen]);
|
| 156 |
+
|
| 157 |
+
/* Toggle dropdown */
|
| 158 |
+
const handleToggle = useCallback(() => {
|
| 159 |
+
setIsOpen((prev) => !prev);
|
| 160 |
+
}, []);
|
| 161 |
+
|
| 162 |
+
/* Select provider and close */
|
| 163 |
+
const handleSelect = useCallback(
|
| 164 |
+
(id: string | null) => {
|
| 165 |
+
selectProvider(id);
|
| 166 |
+
setIsOpen(false);
|
| 167 |
+
buttonRef.current?.focus();
|
| 168 |
+
},
|
| 169 |
+
[selectProvider]
|
| 170 |
+
);
|
| 171 |
+
|
| 172 |
+
/* Keyboard navigation in dropdown */
|
| 173 |
+
const handleKeyDown = useCallback(
|
| 174 |
+
(e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
| 175 |
+
switch (e.key) {
|
| 176 |
+
case 'ArrowDown':
|
| 177 |
+
e.preventDefault();
|
| 178 |
+
const nextIndex = (index + 1) % options.length;
|
| 179 |
+
const nextButton = containerRef.current?.querySelectorAll('[role="option"]')[nextIndex] as HTMLButtonElement;
|
| 180 |
+
nextButton?.focus();
|
| 181 |
+
break;
|
| 182 |
+
case 'ArrowUp':
|
| 183 |
+
e.preventDefault();
|
| 184 |
+
const prevIndex = (index - 1 + options.length) % options.length;
|
| 185 |
+
const prevButton = containerRef.current?.querySelectorAll('[role="option"]')[prevIndex] as HTMLButtonElement;
|
| 186 |
+
prevButton?.focus();
|
| 187 |
+
break;
|
| 188 |
+
case 'Enter':
|
| 189 |
+
case ' ':
|
| 190 |
+
e.preventDefault();
|
| 191 |
+
handleSelect(options[index].id);
|
| 192 |
+
break;
|
| 193 |
+
}
|
| 194 |
+
},
|
| 195 |
+
[options, handleSelect]
|
| 196 |
+
);
|
| 197 |
+
|
| 198 |
+
/* Loading state */
|
| 199 |
+
if (isLoading) {
|
| 200 |
+
return (
|
| 201 |
+
<div className={cn('h-8 w-20 animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700', className)} />
|
| 202 |
+
);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
return (
|
| 206 |
+
<div ref={containerRef} className={cn('relative', className)}>
|
| 207 |
+
{/* Trigger Button - Claude-style minimal */}
|
| 208 |
+
<button
|
| 209 |
+
ref={buttonRef}
|
| 210 |
+
type="button"
|
| 211 |
+
onClick={handleToggle}
|
| 212 |
+
aria-expanded={isOpen}
|
| 213 |
+
aria-haspopup="listbox"
|
| 214 |
+
aria-label={`Selected provider: ${selectedOption.name}`}
|
| 215 |
+
className={cn(
|
| 216 |
+
/* Layout */
|
| 217 |
+
'inline-flex items-center gap-1.5',
|
| 218 |
+
'h-8 px-3',
|
| 219 |
+
/* Typography */
|
| 220 |
+
'text-sm font-medium',
|
| 221 |
+
'text-gray-600 dark:text-gray-300',
|
| 222 |
+
/* Styling - minimal and clean */
|
| 223 |
+
'rounded-lg',
|
| 224 |
+
'bg-transparent',
|
| 225 |
+
/* Hover/focus states */
|
| 226 |
+
'hover:bg-gray-100 dark:hover:bg-gray-800',
|
| 227 |
+
'focus:outline-none',
|
| 228 |
+
'focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:ring-offset-2',
|
| 229 |
+
/* Transition */
|
| 230 |
+
'transition-colors duration-150',
|
| 231 |
+
/* Cursor */
|
| 232 |
+
'cursor-pointer'
|
| 233 |
+
)}
|
| 234 |
+
>
|
| 235 |
+
{/* Auto icon or status dot */}
|
| 236 |
+
{selectedOption.id === null ? (
|
| 237 |
+
<Zap className="h-3.5 w-3.5 text-purple-500" aria-hidden="true" />
|
| 238 |
+
) : (
|
| 239 |
+
<StatusDot
|
| 240 |
+
isAvailable={selectedOption.isAvailable}
|
| 241 |
+
hasCooldown={selectedOption.cooldownSeconds !== null && selectedOption.cooldownSeconds > 0}
|
| 242 |
+
/>
|
| 243 |
+
)}
|
| 244 |
+
|
| 245 |
+
{/* Provider name */}
|
| 246 |
+
<span>{selectedOption.name}</span>
|
| 247 |
+
|
| 248 |
+
{/* Chevron */}
|
| 249 |
+
<ChevronDown
|
| 250 |
+
className={cn(
|
| 251 |
+
'h-3.5 w-3.5 text-gray-400',
|
| 252 |
+
'transition-transform duration-150',
|
| 253 |
+
isOpen && 'rotate-180'
|
| 254 |
+
)}
|
| 255 |
+
aria-hidden="true"
|
| 256 |
+
/>
|
| 257 |
+
</button>
|
| 258 |
+
|
| 259 |
+
{/* Dropdown Menu */}
|
| 260 |
+
{isOpen && (
|
| 261 |
+
<div
|
| 262 |
+
role="listbox"
|
| 263 |
+
aria-label="Select provider"
|
| 264 |
+
className={cn(
|
| 265 |
+
/* Position - above the button */
|
| 266 |
+
'absolute bottom-full left-0 mb-2',
|
| 267 |
+
/* Sizing */
|
| 268 |
+
'min-w-[160px]',
|
| 269 |
+
/* Styling */
|
| 270 |
+
'bg-white dark:bg-gray-900',
|
| 271 |
+
'border border-gray-200 dark:border-gray-700',
|
| 272 |
+
'rounded-xl',
|
| 273 |
+
'shadow-lg',
|
| 274 |
+
/* Animation */
|
| 275 |
+
'animate-fade-in',
|
| 276 |
+
/* Overflow */
|
| 277 |
+
'overflow-hidden',
|
| 278 |
+
/* Z-index */
|
| 279 |
+
'z-50'
|
| 280 |
+
)}
|
| 281 |
+
>
|
| 282 |
+
<div className="py-1">
|
| 283 |
+
{options.map((option, index) => {
|
| 284 |
+
const isSelected = option.id === selectedProvider;
|
| 285 |
+
const hasCooldown = option.cooldownSeconds !== null && option.cooldownSeconds > 0;
|
| 286 |
+
|
| 287 |
+
return (
|
| 288 |
+
<button
|
| 289 |
+
key={option.id ?? 'auto'}
|
| 290 |
+
type="button"
|
| 291 |
+
role="option"
|
| 292 |
+
aria-selected={isSelected}
|
| 293 |
+
onClick={() => handleSelect(option.id)}
|
| 294 |
+
onKeyDown={(e) => handleKeyDown(e, index)}
|
| 295 |
+
className={cn(
|
| 296 |
+
/* Layout */
|
| 297 |
+
'w-full flex items-center justify-between gap-3',
|
| 298 |
+
'px-3 py-2',
|
| 299 |
+
/* Typography */
|
| 300 |
+
'text-sm',
|
| 301 |
+
/* Colors */
|
| 302 |
+
isSelected
|
| 303 |
+
? 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300'
|
| 304 |
+
: 'text-gray-700 dark:text-gray-200',
|
| 305 |
+
/* Hover */
|
| 306 |
+
!isSelected && 'hover:bg-gray-50 dark:hover:bg-gray-800',
|
| 307 |
+
/* Disabled appearance for cooldown */
|
| 308 |
+
hasCooldown && !isSelected && 'opacity-60',
|
| 309 |
+
/* Focus */
|
| 310 |
+
'focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-800',
|
| 311 |
+
/* Transition */
|
| 312 |
+
'transition-colors duration-100',
|
| 313 |
+
/* Cursor */
|
| 314 |
+
'cursor-pointer'
|
| 315 |
+
)}
|
| 316 |
+
>
|
| 317 |
+
{/* Left side: icon/dot and name */}
|
| 318 |
+
<span className="flex items-center gap-2">
|
| 319 |
+
{option.id === null ? (
|
| 320 |
+
<Zap
|
| 321 |
+
className={cn(
|
| 322 |
+
'h-3.5 w-3.5',
|
| 323 |
+
isSelected ? 'text-purple-500' : 'text-purple-400'
|
| 324 |
+
)}
|
| 325 |
+
aria-hidden="true"
|
| 326 |
+
/>
|
| 327 |
+
) : (
|
| 328 |
+
<StatusDot isAvailable={option.isAvailable} hasCooldown={hasCooldown} />
|
| 329 |
+
)}
|
| 330 |
+
<span className="font-medium">{option.name}</span>
|
| 331 |
+
</span>
|
| 332 |
+
|
| 333 |
+
{/* Right side: cooldown or check */}
|
| 334 |
+
<span className="flex items-center gap-1.5">
|
| 335 |
+
{hasCooldown && (
|
| 336 |
+
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
| 337 |
+
<Clock className="h-3 w-3" aria-hidden="true" />
|
| 338 |
+
{formatCooldown(option.cooldownSeconds!)}
|
| 339 |
+
</span>
|
| 340 |
+
)}
|
| 341 |
+
{isSelected && (
|
| 342 |
+
<Check
|
| 343 |
+
className="h-4 w-4 text-purple-500"
|
| 344 |
+
strokeWidth={2.5}
|
| 345 |
+
aria-hidden="true"
|
| 346 |
+
/>
|
| 347 |
+
)}
|
| 348 |
+
</span>
|
| 349 |
+
</button>
|
| 350 |
+
);
|
| 351 |
+
})}
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
)}
|
| 355 |
+
</div>
|
| 356 |
+
);
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
ProviderSelector.displayName = 'ProviderSelector';
|
frontend/src/components/chat/provider-toggle.tsx
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Provider Toggle Component
|
| 5 |
+
*
|
| 6 |
+
* A production-ready, premium UI component for selecting LLM providers.
|
| 7 |
+
* Displays providers as horizontal selectable pills with real-time status
|
| 8 |
+
* indicators, cooldown timers, and accessibility-first design.
|
| 9 |
+
*
|
| 10 |
+
* @module components/chat/provider-toggle
|
| 11 |
+
* @since 1.0.0
|
| 12 |
+
*
|
| 13 |
+
* @example
|
| 14 |
+
* // Basic usage
|
| 15 |
+
* import { ProviderToggle } from '@/components/chat';
|
| 16 |
+
*
|
| 17 |
+
* function ChatHeader() {
|
| 18 |
+
* return (
|
| 19 |
+
* <header>
|
| 20 |
+
* <h1>Chat</h1>
|
| 21 |
+
* <ProviderToggle />
|
| 22 |
+
* </header>
|
| 23 |
+
* );
|
| 24 |
+
* }
|
| 25 |
+
*
|
| 26 |
+
* @example
|
| 27 |
+
* // Compact variant for tight spaces
|
| 28 |
+
* <ProviderToggle compact />
|
| 29 |
+
*
|
| 30 |
+
* @example
|
| 31 |
+
* // With custom className
|
| 32 |
+
* <ProviderToggle className="mt-4" />
|
| 33 |
+
*/
|
| 34 |
+
|
| 35 |
+
import {
|
| 36 |
+
useCallback,
|
| 37 |
+
useEffect,
|
| 38 |
+
useMemo,
|
| 39 |
+
useRef,
|
| 40 |
+
useState,
|
| 41 |
+
type KeyboardEvent,
|
| 42 |
+
type ReactElement,
|
| 43 |
+
} from 'react';
|
| 44 |
+
import { Zap, RefreshCw, Clock, AlertCircle } from 'lucide-react';
|
| 45 |
+
import { cn } from '@/lib/utils';
|
| 46 |
+
import { useProviders, type ProviderStatus } from '@/hooks';
|
| 47 |
+
|
| 48 |
+
// ============================================================================
|
| 49 |
+
// Type Definitions
|
| 50 |
+
// ============================================================================
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Props for the ProviderToggle component.
|
| 54 |
+
*
|
| 55 |
+
* @public
|
| 56 |
+
*/
|
| 57 |
+
export interface ProviderToggleProps {
|
| 58 |
+
/**
|
| 59 |
+
* Additional CSS classes to apply to the container.
|
| 60 |
+
*/
|
| 61 |
+
className?: string;
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Smaller variant for tight spaces.
|
| 65 |
+
* Reduces padding, font sizes, and indicator sizes.
|
| 66 |
+
*
|
| 67 |
+
* @default false
|
| 68 |
+
*/
|
| 69 |
+
compact?: boolean;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Internal type representing a provider option including the "Auto" option.
|
| 74 |
+
*
|
| 75 |
+
* @internal
|
| 76 |
+
*/
|
| 77 |
+
interface ProviderOption {
|
| 78 |
+
/** Unique identifier (null for auto mode) */
|
| 79 |
+
id: string | null;
|
| 80 |
+
/** Display name */
|
| 81 |
+
name: string;
|
| 82 |
+
/** Description text */
|
| 83 |
+
description: string;
|
| 84 |
+
/** Whether the provider is available */
|
| 85 |
+
isAvailable: boolean;
|
| 86 |
+
/** Remaining cooldown in seconds (null if not in cooldown) */
|
| 87 |
+
cooldownSeconds: number | null;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// ============================================================================
|
| 91 |
+
// Constants
|
| 92 |
+
// ============================================================================
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Auto option configuration.
|
| 96 |
+
* This is always the first option in the provider list.
|
| 97 |
+
*
|
| 98 |
+
* @internal
|
| 99 |
+
*/
|
| 100 |
+
const AUTO_OPTION: ProviderOption = {
|
| 101 |
+
id: null,
|
| 102 |
+
name: 'Auto',
|
| 103 |
+
description: 'Automatically select the best available provider',
|
| 104 |
+
isAvailable: true,
|
| 105 |
+
cooldownSeconds: null,
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
// ============================================================================
|
| 109 |
+
// Utility Functions
|
| 110 |
+
// ============================================================================
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* Format cooldown seconds into a human-readable string.
|
| 114 |
+
*
|
| 115 |
+
* @param seconds - Cooldown duration in seconds
|
| 116 |
+
* @returns Formatted string (e.g., "2m 30s" or "45s")
|
| 117 |
+
*
|
| 118 |
+
* @internal
|
| 119 |
+
*/
|
| 120 |
+
function formatCooldown(seconds: number): string {
|
| 121 |
+
if (seconds <= 0) return '';
|
| 122 |
+
|
| 123 |
+
const minutes = Math.floor(seconds / 60);
|
| 124 |
+
const remainingSeconds = seconds % 60;
|
| 125 |
+
|
| 126 |
+
if (minutes > 0) {
|
| 127 |
+
return remainingSeconds > 0
|
| 128 |
+
? `${minutes}m ${remainingSeconds}s`
|
| 129 |
+
: `${minutes}m`;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return `${remainingSeconds}s`;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* Transform provider statuses into option format, prepending Auto option.
|
| 137 |
+
*
|
| 138 |
+
* @param providers - Array of provider statuses from the hook
|
| 139 |
+
* @returns Array of provider options with Auto as the first element
|
| 140 |
+
*
|
| 141 |
+
* @internal
|
| 142 |
+
*/
|
| 143 |
+
function transformToOptions(providers: ProviderStatus[]): ProviderOption[] {
|
| 144 |
+
const providerOptions: ProviderOption[] = providers.map((provider) => ({
|
| 145 |
+
id: provider.id,
|
| 146 |
+
name: provider.name,
|
| 147 |
+
description: provider.description,
|
| 148 |
+
isAvailable: provider.isAvailable,
|
| 149 |
+
cooldownSeconds: provider.cooldownSeconds,
|
| 150 |
+
}));
|
| 151 |
+
|
| 152 |
+
return [AUTO_OPTION, ...providerOptions];
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// ============================================================================
|
| 156 |
+
// Sub-Components
|
| 157 |
+
// ============================================================================
|
| 158 |
+
|
| 159 |
+
/**
|
| 160 |
+
* Status indicator dot with pulse animation for available providers.
|
| 161 |
+
*
|
| 162 |
+
* @param props - Component props
|
| 163 |
+
* @param props.status - Current status ('available', 'cooldown', 'unknown')
|
| 164 |
+
* @param props.compact - Whether to render in compact mode
|
| 165 |
+
* @returns Status indicator element
|
| 166 |
+
*
|
| 167 |
+
* @internal
|
| 168 |
+
*/
|
| 169 |
+
function StatusIndicator({
|
| 170 |
+
status,
|
| 171 |
+
compact = false,
|
| 172 |
+
}: {
|
| 173 |
+
status: 'available' | 'cooldown' | 'unknown';
|
| 174 |
+
compact?: boolean;
|
| 175 |
+
}): ReactElement {
|
| 176 |
+
const baseClasses = cn(
|
| 177 |
+
'rounded-full shrink-0',
|
| 178 |
+
compact ? 'h-1.5 w-1.5' : 'h-2 w-2'
|
| 179 |
+
);
|
| 180 |
+
|
| 181 |
+
switch (status) {
|
| 182 |
+
case 'available':
|
| 183 |
+
return (
|
| 184 |
+
<span className="relative flex">
|
| 185 |
+
<span
|
| 186 |
+
className={cn(
|
| 187 |
+
baseClasses,
|
| 188 |
+
'bg-[var(--success)]',
|
| 189 |
+
/* Pulse animation for available status */
|
| 190 |
+
'animate-[pulse-glow_2s_ease-in-out_infinite]'
|
| 191 |
+
)}
|
| 192 |
+
/>
|
| 193 |
+
{/* Outer glow ring for emphasis */}
|
| 194 |
+
<span
|
| 195 |
+
className={cn(
|
| 196 |
+
'absolute inset-0 rounded-full bg-[var(--success)]',
|
| 197 |
+
'animate-[ping_2s_cubic-bezier(0,0,0.2,1)_infinite] opacity-75'
|
| 198 |
+
)}
|
| 199 |
+
/>
|
| 200 |
+
<style>{`
|
| 201 |
+
@keyframes pulse-glow {
|
| 202 |
+
0%, 100% { opacity: 1; }
|
| 203 |
+
50% { opacity: 0.7; }
|
| 204 |
+
}
|
| 205 |
+
`}</style>
|
| 206 |
+
</span>
|
| 207 |
+
);
|
| 208 |
+
|
| 209 |
+
case 'cooldown':
|
| 210 |
+
return <span className={cn(baseClasses, 'bg-[var(--error)]')} />;
|
| 211 |
+
|
| 212 |
+
case 'unknown':
|
| 213 |
+
default:
|
| 214 |
+
return (
|
| 215 |
+
<span
|
| 216 |
+
className={cn(baseClasses, 'bg-[var(--foreground-muted)] opacity-50')}
|
| 217 |
+
/>
|
| 218 |
+
);
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/**
|
| 223 |
+
* Inner countdown timer component that handles the countdown logic.
|
| 224 |
+
*
|
| 225 |
+
* This component is designed to be used with a key prop that changes
|
| 226 |
+
* when initialSeconds changes, causing a remount that resets the countdown.
|
| 227 |
+
* This pattern avoids calling setState synchronously in useEffect.
|
| 228 |
+
*
|
| 229 |
+
* @param props - Component props
|
| 230 |
+
* @param props.initialSeconds - Starting countdown value in seconds
|
| 231 |
+
* @param props.compact - Whether to render in compact mode
|
| 232 |
+
* @returns Countdown display element
|
| 233 |
+
*
|
| 234 |
+
* @internal
|
| 235 |
+
*/
|
| 236 |
+
function CooldownTimerInner({
|
| 237 |
+
initialSeconds,
|
| 238 |
+
compact = false,
|
| 239 |
+
}: {
|
| 240 |
+
initialSeconds: number;
|
| 241 |
+
compact?: boolean;
|
| 242 |
+
}): ReactElement {
|
| 243 |
+
/* Initialize state with the starting value */
|
| 244 |
+
const [remainingSeconds, setRemainingSeconds] = useState(
|
| 245 |
+
() => initialSeconds
|
| 246 |
+
);
|
| 247 |
+
|
| 248 |
+
/* Set up decrement interval - runs once on mount */
|
| 249 |
+
useEffect(() => {
|
| 250 |
+
/* Don't set up interval if no time remaining */
|
| 251 |
+
if (initialSeconds <= 0) {
|
| 252 |
+
return;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/* Set up decrement interval */
|
| 256 |
+
const intervalId = setInterval(() => {
|
| 257 |
+
setRemainingSeconds((prev) => {
|
| 258 |
+
const next = prev - 1;
|
| 259 |
+
if (next <= 0) {
|
| 260 |
+
clearInterval(intervalId);
|
| 261 |
+
return 0;
|
| 262 |
+
}
|
| 263 |
+
return next;
|
| 264 |
+
});
|
| 265 |
+
}, 1000);
|
| 266 |
+
|
| 267 |
+
/* Cleanup on unmount */
|
| 268 |
+
return () => {
|
| 269 |
+
clearInterval(intervalId);
|
| 270 |
+
};
|
| 271 |
+
/* Empty dependency array - only run on mount */
|
| 272 |
+
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
| 273 |
+
}, []);
|
| 274 |
+
|
| 275 |
+
if (remainingSeconds <= 0) {
|
| 276 |
+
return <></>;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
return (
|
| 280 |
+
<span
|
| 281 |
+
className={cn(
|
| 282 |
+
'inline-flex items-center gap-1',
|
| 283 |
+
'font-medium text-[var(--error)]',
|
| 284 |
+
compact ? 'text-[10px]' : 'text-xs'
|
| 285 |
+
)}
|
| 286 |
+
aria-live="polite"
|
| 287 |
+
aria-atomic="true"
|
| 288 |
+
>
|
| 289 |
+
<Clock
|
| 290 |
+
className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'}
|
| 291 |
+
aria-hidden="true"
|
| 292 |
+
/>
|
| 293 |
+
<span>{formatCooldown(remainingSeconds)}</span>
|
| 294 |
+
</span>
|
| 295 |
+
);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
/**
|
| 299 |
+
* Cooldown countdown timer wrapper that handles prop changes via key.
|
| 300 |
+
*
|
| 301 |
+
* Uses the key prop pattern to remount the inner component when
|
| 302 |
+
* initialSeconds changes, which resets the countdown properly without
|
| 303 |
+
* needing to call setState in useEffect.
|
| 304 |
+
*
|
| 305 |
+
* @param props - Component props
|
| 306 |
+
* @param props.initialSeconds - Starting cooldown value in seconds
|
| 307 |
+
* @param props.compact - Whether to render in compact mode
|
| 308 |
+
* @returns Countdown display element
|
| 309 |
+
*
|
| 310 |
+
* @internal
|
| 311 |
+
*/
|
| 312 |
+
function CooldownTimer({
|
| 313 |
+
initialSeconds,
|
| 314 |
+
compact = false,
|
| 315 |
+
}: {
|
| 316 |
+
initialSeconds: number;
|
| 317 |
+
compact?: boolean;
|
| 318 |
+
}): ReactElement {
|
| 319 |
+
/* Use initialSeconds as key to remount when it changes */
|
| 320 |
+
return (
|
| 321 |
+
<CooldownTimerInner
|
| 322 |
+
key={initialSeconds}
|
| 323 |
+
initialSeconds={initialSeconds}
|
| 324 |
+
compact={compact}
|
| 325 |
+
/>
|
| 326 |
+
);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
/**
|
| 330 |
+
* Skeleton loader for the provider toggle during loading state.
|
| 331 |
+
*
|
| 332 |
+
* @param props - Component props
|
| 333 |
+
* @param props.compact - Whether to render in compact mode
|
| 334 |
+
* @returns Skeleton element
|
| 335 |
+
*
|
| 336 |
+
* @internal
|
| 337 |
+
*/
|
| 338 |
+
function ProviderToggleSkeleton({
|
| 339 |
+
compact = false,
|
| 340 |
+
}: {
|
| 341 |
+
compact?: boolean;
|
| 342 |
+
}): ReactElement {
|
| 343 |
+
return (
|
| 344 |
+
<div
|
| 345 |
+
className={cn('flex flex-wrap gap-2', compact ? 'gap-1.5' : 'gap-2')}
|
| 346 |
+
role="group"
|
| 347 |
+
aria-label="Loading provider options..."
|
| 348 |
+
>
|
| 349 |
+
{/* Render 3 skeleton pills (Auto + 2 providers) */}
|
| 350 |
+
{[1, 2, 3].map((i) => (
|
| 351 |
+
<div
|
| 352 |
+
key={i}
|
| 353 |
+
className={cn(
|
| 354 |
+
'animate-pulse rounded-full',
|
| 355 |
+
'bg-[var(--background-tertiary)]',
|
| 356 |
+
compact ? 'h-7 w-16' : 'h-9 w-20'
|
| 357 |
+
)}
|
| 358 |
+
/>
|
| 359 |
+
))}
|
| 360 |
+
</div>
|
| 361 |
+
);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
/**
|
| 365 |
+
* Error state display with refresh button.
|
| 366 |
+
*
|
| 367 |
+
* @param props - Component props
|
| 368 |
+
* @param props.error - Error message to display
|
| 369 |
+
* @param props.onRefresh - Callback to trigger refresh
|
| 370 |
+
* @param props.compact - Whether to render in compact mode
|
| 371 |
+
* @returns Error display element
|
| 372 |
+
*
|
| 373 |
+
* @internal
|
| 374 |
+
*/
|
| 375 |
+
function ProviderToggleError({
|
| 376 |
+
error,
|
| 377 |
+
onRefresh,
|
| 378 |
+
compact = false,
|
| 379 |
+
}: {
|
| 380 |
+
error: string;
|
| 381 |
+
onRefresh: () => void;
|
| 382 |
+
compact?: boolean;
|
| 383 |
+
}): ReactElement {
|
| 384 |
+
return (
|
| 385 |
+
<div
|
| 386 |
+
className={cn(
|
| 387 |
+
'flex items-center gap-2',
|
| 388 |
+
'text-[var(--error)]',
|
| 389 |
+
compact ? 'text-xs' : 'text-sm'
|
| 390 |
+
)}
|
| 391 |
+
role="alert"
|
| 392 |
+
>
|
| 393 |
+
<AlertCircle
|
| 394 |
+
className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'}
|
| 395 |
+
aria-hidden="true"
|
| 396 |
+
/>
|
| 397 |
+
<span className="max-w-[200px] truncate" title={error}>
|
| 398 |
+
{error}
|
| 399 |
+
</span>
|
| 400 |
+
<button
|
| 401 |
+
type="button"
|
| 402 |
+
onClick={onRefresh}
|
| 403 |
+
className={cn(
|
| 404 |
+
'inline-flex items-center justify-center',
|
| 405 |
+
'rounded-full p-1',
|
| 406 |
+
'text-[var(--foreground-secondary)]',
|
| 407 |
+
'hover:bg-[var(--background-secondary)] hover:text-[var(--foreground)]',
|
| 408 |
+
'focus-visible:ring-2 focus-visible:outline-none',
|
| 409 |
+
'focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2',
|
| 410 |
+
'focus-visible:ring-offset-[var(--background)]',
|
| 411 |
+
'transition-colors duration-[var(--transition-fast)]'
|
| 412 |
+
)}
|
| 413 |
+
aria-label="Retry loading providers"
|
| 414 |
+
>
|
| 415 |
+
<RefreshCw
|
| 416 |
+
className={compact ? 'h-3 w-3' : 'h-3.5 w-3.5'}
|
| 417 |
+
aria-hidden="true"
|
| 418 |
+
/>
|
| 419 |
+
</button>
|
| 420 |
+
</div>
|
| 421 |
+
);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
/**
|
| 425 |
+
* Individual provider pill button.
|
| 426 |
+
*
|
| 427 |
+
* @param props - Component props
|
| 428 |
+
* @returns Provider pill element
|
| 429 |
+
*
|
| 430 |
+
* @internal
|
| 431 |
+
*/
|
| 432 |
+
function ProviderPill({
|
| 433 |
+
option,
|
| 434 |
+
isSelected,
|
| 435 |
+
onSelect,
|
| 436 |
+
compact = false,
|
| 437 |
+
tabIndex,
|
| 438 |
+
onKeyDown,
|
| 439 |
+
}: {
|
| 440 |
+
option: ProviderOption;
|
| 441 |
+
isSelected: boolean;
|
| 442 |
+
onSelect: (id: string | null) => void;
|
| 443 |
+
compact?: boolean;
|
| 444 |
+
tabIndex: number;
|
| 445 |
+
onKeyDown: (e: KeyboardEvent<HTMLButtonElement>) => void;
|
| 446 |
+
}): ReactElement {
|
| 447 |
+
const isAuto = option.id === null;
|
| 448 |
+
const isUnavailable = !option.isAvailable;
|
| 449 |
+
const hasCooldown =
|
| 450 |
+
option.cooldownSeconds !== null && option.cooldownSeconds > 0;
|
| 451 |
+
|
| 452 |
+
/* Determine status for indicator */
|
| 453 |
+
const status: 'available' | 'cooldown' | 'unknown' = useMemo(() => {
|
| 454 |
+
if (isAuto) return 'available';
|
| 455 |
+
if (hasCooldown) return 'cooldown';
|
| 456 |
+
if (option.isAvailable) return 'available';
|
| 457 |
+
return 'unknown';
|
| 458 |
+
}, [isAuto, hasCooldown, option.isAvailable]);
|
| 459 |
+
|
| 460 |
+
/* Handle click */
|
| 461 |
+
const handleClick = useCallback(() => {
|
| 462 |
+
/* Allow selection even if unavailable (user might want to see cooldown) */
|
| 463 |
+
onSelect(option.id);
|
| 464 |
+
}, [onSelect, option.id]);
|
| 465 |
+
|
| 466 |
+
return (
|
| 467 |
+
<button
|
| 468 |
+
type="button"
|
| 469 |
+
role="radio"
|
| 470 |
+
aria-checked={isSelected}
|
| 471 |
+
aria-label={`${option.name}${isSelected ? ' (selected)' : ''}${
|
| 472 |
+
isUnavailable && !isAuto
|
| 473 |
+
? hasCooldown
|
| 474 |
+
? ` - cooling down for ${formatCooldown(option.cooldownSeconds!)}`
|
| 475 |
+
: ' - unavailable'
|
| 476 |
+
: ''
|
| 477 |
+
}`}
|
| 478 |
+
tabIndex={tabIndex}
|
| 479 |
+
onClick={handleClick}
|
| 480 |
+
onKeyDown={onKeyDown}
|
| 481 |
+
disabled={false}
|
| 482 |
+
className={cn(
|
| 483 |
+
/* Base styles */
|
| 484 |
+
'relative inline-flex items-center gap-1.5',
|
| 485 |
+
'rounded-full font-medium whitespace-nowrap',
|
| 486 |
+
'transition-all duration-[var(--transition-fast)]',
|
| 487 |
+
'cursor-pointer select-none',
|
| 488 |
+
|
| 489 |
+
/* Size variants */
|
| 490 |
+
compact ? 'h-7 px-2.5 py-1 text-xs' : 'h-9 px-3.5 py-1.5 text-sm',
|
| 491 |
+
|
| 492 |
+
/* Focus styles */
|
| 493 |
+
'focus-visible:ring-2 focus-visible:outline-none',
|
| 494 |
+
'focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2',
|
| 495 |
+
'focus-visible:ring-offset-[var(--background)]',
|
| 496 |
+
|
| 497 |
+
/* Selected state */
|
| 498 |
+
isSelected && [
|
| 499 |
+
'bg-[var(--color-primary-500)] text-white',
|
| 500 |
+
'shadow-[var(--shadow-md)]',
|
| 501 |
+
'border-2 border-[var(--color-primary-600)]',
|
| 502 |
+
],
|
| 503 |
+
|
| 504 |
+
/* Unselected state */
|
| 505 |
+
!isSelected && [
|
| 506 |
+
'bg-[var(--background-secondary)]',
|
| 507 |
+
'border border-[var(--border)]',
|
| 508 |
+
'text-[var(--foreground-secondary)]',
|
| 509 |
+
/* Hover - only when not disabled visually */
|
| 510 |
+
!isUnavailable && [
|
| 511 |
+
'hover:bg-[var(--background-tertiary)]',
|
| 512 |
+
'hover:border-[var(--foreground-muted)]',
|
| 513 |
+
'hover:text-[var(--foreground)]',
|
| 514 |
+
'hover:scale-[1.02]',
|
| 515 |
+
'hover:shadow-[var(--shadow-sm)]',
|
| 516 |
+
],
|
| 517 |
+
],
|
| 518 |
+
|
| 519 |
+
/* Unavailable/muted state (not selected) */
|
| 520 |
+
isUnavailable &&
|
| 521 |
+
!isSelected &&
|
| 522 |
+
!isAuto && ['opacity-60', 'cursor-not-allowed']
|
| 523 |
+
)}
|
| 524 |
+
>
|
| 525 |
+
{/* Auto icon for the Auto option */}
|
| 526 |
+
{isAuto && (
|
| 527 |
+
<Zap
|
| 528 |
+
className={cn(
|
| 529 |
+
compact ? 'h-3 w-3' : 'h-3.5 w-3.5',
|
| 530 |
+
isSelected ? 'text-white' : 'text-[var(--color-primary-500)]'
|
| 531 |
+
)}
|
| 532 |
+
aria-hidden="true"
|
| 533 |
+
/>
|
| 534 |
+
)}
|
| 535 |
+
|
| 536 |
+
{/* Status indicator for non-auto options */}
|
| 537 |
+
{!isAuto && <StatusIndicator status={status} compact={compact} />}
|
| 538 |
+
|
| 539 |
+
{/* Provider name */}
|
| 540 |
+
<span>{option.name}</span>
|
| 541 |
+
|
| 542 |
+
{/* Cooldown timer (only shown for non-auto options in cooldown) */}
|
| 543 |
+
{hasCooldown && !isAuto && (
|
| 544 |
+
<CooldownTimer
|
| 545 |
+
initialSeconds={option.cooldownSeconds!}
|
| 546 |
+
compact={compact}
|
| 547 |
+
/>
|
| 548 |
+
)}
|
| 549 |
+
</button>
|
| 550 |
+
);
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
// ============================================================================
|
| 554 |
+
// Main Component
|
| 555 |
+
// ============================================================================
|
| 556 |
+
|
| 557 |
+
/**
|
| 558 |
+
* Provider Toggle Component
|
| 559 |
+
*
|
| 560 |
+
* A premium horizontal pill/chip layout for selecting LLM providers.
|
| 561 |
+
* Features real-time status indicators, cooldown timers, and full
|
| 562 |
+
* keyboard navigation support.
|
| 563 |
+
*
|
| 564 |
+
* @remarks
|
| 565 |
+
* ## Features
|
| 566 |
+
* - Horizontal pill layout with "Auto" as the first option
|
| 567 |
+
* - Green pulse dot for available providers
|
| 568 |
+
* - Red dot with countdown timer for providers in cooldown
|
| 569 |
+
* - Gray dot for unknown/unavailable status
|
| 570 |
+
* - Selected state with primary color highlight
|
| 571 |
+
* - Subtle hover scale and shadow transitions
|
| 572 |
+
* - Responsive: stacks vertically on mobile
|
| 573 |
+
*
|
| 574 |
+
* ## Accessibility
|
| 575 |
+
* - Radio group semantics for selection
|
| 576 |
+
* - Full keyboard navigation (arrow keys)
|
| 577 |
+
* - Proper ARIA labels and states
|
| 578 |
+
* - Focus indicators meeting WCAG AA
|
| 579 |
+
* - Screen reader announcements for cooldown timers
|
| 580 |
+
*
|
| 581 |
+
* ## State Management
|
| 582 |
+
* - Uses useProviders hook internally for provider status
|
| 583 |
+
* - Graceful loading state with skeleton
|
| 584 |
+
* - Error state with refresh button
|
| 585 |
+
* - Local countdown timer that decrements every second
|
| 586 |
+
*
|
| 587 |
+
* @param props - Component props
|
| 588 |
+
* @returns Provider toggle element
|
| 589 |
+
*
|
| 590 |
+
* @example
|
| 591 |
+
* // Basic usage
|
| 592 |
+
* <ProviderToggle />
|
| 593 |
+
*
|
| 594 |
+
* @example
|
| 595 |
+
* // Compact mode for headers
|
| 596 |
+
* <ProviderToggle compact className="ml-auto" />
|
| 597 |
+
*/
|
| 598 |
+
export function ProviderToggle({
|
| 599 |
+
className,
|
| 600 |
+
compact = false,
|
| 601 |
+
}: ProviderToggleProps): ReactElement {
|
| 602 |
+
const {
|
| 603 |
+
providers,
|
| 604 |
+
selectedProvider,
|
| 605 |
+
selectProvider,
|
| 606 |
+
isLoading,
|
| 607 |
+
error,
|
| 608 |
+
refresh,
|
| 609 |
+
} = useProviders();
|
| 610 |
+
|
| 611 |
+
/* Transform providers to options format */
|
| 612 |
+
const options = useMemo(() => transformToOptions(providers), [providers]);
|
| 613 |
+
|
| 614 |
+
/* Find the index of the currently selected option */
|
| 615 |
+
const selectedIndex = useMemo(() => {
|
| 616 |
+
return options.findIndex((opt) => opt.id === selectedProvider);
|
| 617 |
+
}, [options, selectedProvider]);
|
| 618 |
+
|
| 619 |
+
/* Refs for keyboard navigation */
|
| 620 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 621 |
+
|
| 622 |
+
/**
|
| 623 |
+
* Handle keyboard navigation within the radio group.
|
| 624 |
+
* Supports arrow keys for navigation and Enter/Space for selection.
|
| 625 |
+
*/
|
| 626 |
+
const handleKeyDown = useCallback(
|
| 627 |
+
(e: KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
|
| 628 |
+
let newIndex = currentIndex;
|
| 629 |
+
let handled = false;
|
| 630 |
+
|
| 631 |
+
switch (e.key) {
|
| 632 |
+
case 'ArrowRight':
|
| 633 |
+
case 'ArrowDown':
|
| 634 |
+
/* Move to next option, wrap around */
|
| 635 |
+
newIndex = (currentIndex + 1) % options.length;
|
| 636 |
+
handled = true;
|
| 637 |
+
break;
|
| 638 |
+
|
| 639 |
+
case 'ArrowLeft':
|
| 640 |
+
case 'ArrowUp':
|
| 641 |
+
/* Move to previous option, wrap around */
|
| 642 |
+
newIndex = (currentIndex - 1 + options.length) % options.length;
|
| 643 |
+
handled = true;
|
| 644 |
+
break;
|
| 645 |
+
|
| 646 |
+
case 'Home':
|
| 647 |
+
/* Move to first option */
|
| 648 |
+
newIndex = 0;
|
| 649 |
+
handled = true;
|
| 650 |
+
break;
|
| 651 |
+
|
| 652 |
+
case 'End':
|
| 653 |
+
/* Move to last option */
|
| 654 |
+
newIndex = options.length - 1;
|
| 655 |
+
handled = true;
|
| 656 |
+
break;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
if (handled) {
|
| 660 |
+
e.preventDefault();
|
| 661 |
+
e.stopPropagation();
|
| 662 |
+
|
| 663 |
+
/* Select the new option */
|
| 664 |
+
selectProvider(options[newIndex].id);
|
| 665 |
+
|
| 666 |
+
/* Focus the new button */
|
| 667 |
+
const container = containerRef.current;
|
| 668 |
+
if (container) {
|
| 669 |
+
const buttons = container.querySelectorAll('button[role="radio"]');
|
| 670 |
+
const targetButton = buttons[newIndex] as HTMLButtonElement;
|
| 671 |
+
targetButton?.focus();
|
| 672 |
+
}
|
| 673 |
+
}
|
| 674 |
+
},
|
| 675 |
+
[options, selectProvider]
|
| 676 |
+
);
|
| 677 |
+
|
| 678 |
+
/* Render loading state */
|
| 679 |
+
if (isLoading) {
|
| 680 |
+
return (
|
| 681 |
+
<div className={className}>
|
| 682 |
+
<ProviderToggleSkeleton compact={compact} />
|
| 683 |
+
</div>
|
| 684 |
+
);
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
/* Render error state */
|
| 688 |
+
if (error && providers.length === 0) {
|
| 689 |
+
return (
|
| 690 |
+
<div className={className}>
|
| 691 |
+
<ProviderToggleError
|
| 692 |
+
error={error}
|
| 693 |
+
onRefresh={refresh}
|
| 694 |
+
compact={compact}
|
| 695 |
+
/>
|
| 696 |
+
</div>
|
| 697 |
+
);
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
return (
|
| 701 |
+
<div
|
| 702 |
+
ref={containerRef}
|
| 703 |
+
className={cn(
|
| 704 |
+
/* Layout - horizontal on desktop, wrap on mobile */
|
| 705 |
+
'flex flex-wrap items-center',
|
| 706 |
+
compact ? 'gap-1.5' : 'gap-2',
|
| 707 |
+
/* Responsive: vertical stack on very small screens */
|
| 708 |
+
'flex-col items-stretch sm:flex-row sm:items-center',
|
| 709 |
+
className
|
| 710 |
+
)}
|
| 711 |
+
role="radiogroup"
|
| 712 |
+
aria-label="Select LLM provider"
|
| 713 |
+
>
|
| 714 |
+
{options.map((option, index) => (
|
| 715 |
+
<ProviderPill
|
| 716 |
+
key={option.id ?? 'auto'}
|
| 717 |
+
option={option}
|
| 718 |
+
isSelected={option.id === selectedProvider}
|
| 719 |
+
onSelect={selectProvider}
|
| 720 |
+
compact={compact}
|
| 721 |
+
/* Only the selected option is in tab order (roving tabindex) */
|
| 722 |
+
tabIndex={index === selectedIndex ? 0 : -1}
|
| 723 |
+
onKeyDown={(e) => handleKeyDown(e, index)}
|
| 724 |
+
/>
|
| 725 |
+
))}
|
| 726 |
+
|
| 727 |
+
{/* Refresh button (subtle, at the end) */}
|
| 728 |
+
<button
|
| 729 |
+
type="button"
|
| 730 |
+
onClick={refresh}
|
| 731 |
+
className={cn(
|
| 732 |
+
'inline-flex items-center justify-center',
|
| 733 |
+
'rounded-full',
|
| 734 |
+
'text-[var(--foreground-muted)]',
|
| 735 |
+
'hover:bg-[var(--background-secondary)] hover:text-[var(--foreground)]',
|
| 736 |
+
'focus-visible:ring-2 focus-visible:outline-none',
|
| 737 |
+
'focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2',
|
| 738 |
+
'focus-visible:ring-offset-[var(--background)]',
|
| 739 |
+
'transition-all duration-[var(--transition-fast)]',
|
| 740 |
+
'hover:rotate-180',
|
| 741 |
+
compact ? 'h-6 w-6' : 'h-7 w-7'
|
| 742 |
+
)}
|
| 743 |
+
aria-label="Refresh provider status"
|
| 744 |
+
title="Refresh provider status"
|
| 745 |
+
>
|
| 746 |
+
<RefreshCw
|
| 747 |
+
className={compact ? 'h-3 w-3' : 'h-3.5 w-3.5'}
|
| 748 |
+
aria-hidden="true"
|
| 749 |
+
/>
|
| 750 |
+
</button>
|
| 751 |
+
</div>
|
| 752 |
+
);
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
/* Display name for React DevTools */
|
| 756 |
+
ProviderToggle.displayName = 'ProviderToggle';
|
frontend/src/components/chat/sidebar.tsx
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Sidebar Component
|
| 3 |
+
*
|
| 4 |
+
* A collapsible left sidebar that displays the current LLM provider
|
| 5 |
+
* and model information. Follows a modern design similar to VS Code
|
| 6 |
+
* or Slack sidebars.
|
| 7 |
+
*
|
| 8 |
+
* @module components/chat/sidebar
|
| 9 |
+
* @since 1.0.0
|
| 10 |
+
*
|
| 11 |
+
* @example
|
| 12 |
+
* // Basic usage
|
| 13 |
+
* <Sidebar />
|
| 14 |
+
*
|
| 15 |
+
* @example
|
| 16 |
+
* // With custom className
|
| 17 |
+
* <Sidebar className="border-r" />
|
| 18 |
+
*/
|
| 19 |
+
|
| 20 |
+
'use client';
|
| 21 |
+
|
| 22 |
+
import {
|
| 23 |
+
useCallback,
|
| 24 |
+
useEffect,
|
| 25 |
+
useState,
|
| 26 |
+
type ReactElement,
|
| 27 |
+
} from 'react';
|
| 28 |
+
import {
|
| 29 |
+
ChevronLeft,
|
| 30 |
+
ChevronRight,
|
| 31 |
+
Cpu,
|
| 32 |
+
Info,
|
| 33 |
+
Zap,
|
| 34 |
+
} from 'lucide-react';
|
| 35 |
+
import { cn } from '@/lib/utils';
|
| 36 |
+
import { useProviders } from '@/hooks';
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Props for the Sidebar component.
|
| 40 |
+
*/
|
| 41 |
+
export interface SidebarProps {
|
| 42 |
+
/** Additional CSS classes */
|
| 43 |
+
className?: string;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* localStorage key for sidebar collapsed state.
|
| 48 |
+
* @internal
|
| 49 |
+
*/
|
| 50 |
+
const STORAGE_KEY = 'rag-chatbot-sidebar-collapsed';
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Sidebar width constants.
|
| 54 |
+
* @internal
|
| 55 |
+
*/
|
| 56 |
+
const SIDEBAR_WIDTH = {
|
| 57 |
+
expanded: 'w-56',
|
| 58 |
+
collapsed: 'w-12',
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Sidebar Component
|
| 63 |
+
*
|
| 64 |
+
* A collapsible sidebar showing the current LLM provider and model.
|
| 65 |
+
* Features:
|
| 66 |
+
* - Displays provider name and model name
|
| 67 |
+
* - Collapsible to icon-only mode
|
| 68 |
+
* - Persists collapse state to localStorage
|
| 69 |
+
* - Smooth animations
|
| 70 |
+
*/
|
| 71 |
+
export function Sidebar({ className }: SidebarProps): ReactElement {
|
| 72 |
+
const { providers, selectedProvider } = useProviders();
|
| 73 |
+
|
| 74 |
+
// Collapse state with localStorage persistence
|
| 75 |
+
const [isCollapsed, setIsCollapsed] = useState(false);
|
| 76 |
+
const [mounted, setMounted] = useState(false);
|
| 77 |
+
|
| 78 |
+
// Load collapsed state from localStorage on mount
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
setMounted(true);
|
| 81 |
+
const stored = localStorage.getItem(STORAGE_KEY);
|
| 82 |
+
if (stored !== null) {
|
| 83 |
+
setIsCollapsed(stored === 'true');
|
| 84 |
+
}
|
| 85 |
+
}, []);
|
| 86 |
+
|
| 87 |
+
// Toggle collapse state
|
| 88 |
+
const toggleCollapsed = useCallback(() => {
|
| 89 |
+
setIsCollapsed((prev) => {
|
| 90 |
+
const newValue = !prev;
|
| 91 |
+
localStorage.setItem(STORAGE_KEY, String(newValue));
|
| 92 |
+
return newValue;
|
| 93 |
+
});
|
| 94 |
+
}, []);
|
| 95 |
+
|
| 96 |
+
// Get current provider info
|
| 97 |
+
const currentProvider = selectedProvider
|
| 98 |
+
? providers.find((p) => p.id === selectedProvider)
|
| 99 |
+
: null;
|
| 100 |
+
|
| 101 |
+
const providerName = selectedProvider
|
| 102 |
+
? currentProvider?.name || selectedProvider
|
| 103 |
+
: 'Auto';
|
| 104 |
+
|
| 105 |
+
// Use primaryModel from backend API (fetched via useProviders)
|
| 106 |
+
// Falls back to "Auto-selected" for auto mode or "Loading..." if not yet fetched
|
| 107 |
+
const modelName = selectedProvider
|
| 108 |
+
? currentProvider?.primaryModel || 'Loading...'
|
| 109 |
+
: 'Auto-selected';
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
// Don't render until mounted to avoid hydration mismatch
|
| 113 |
+
if (!mounted) {
|
| 114 |
+
return (
|
| 115 |
+
<aside
|
| 116 |
+
className={cn(
|
| 117 |
+
SIDEBAR_WIDTH.expanded,
|
| 118 |
+
'shrink-0',
|
| 119 |
+
className
|
| 120 |
+
)}
|
| 121 |
+
/>
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return (
|
| 126 |
+
<aside
|
| 127 |
+
className={cn(
|
| 128 |
+
/* Width transition */
|
| 129 |
+
isCollapsed ? SIDEBAR_WIDTH.collapsed : SIDEBAR_WIDTH.expanded,
|
| 130 |
+
'shrink-0',
|
| 131 |
+
/* Transition for smooth collapse */
|
| 132 |
+
'transition-all duration-200 ease-in-out',
|
| 133 |
+
/* Border */
|
| 134 |
+
'border-r border-[var(--border)]/20',
|
| 135 |
+
/* Background */
|
| 136 |
+
'bg-[var(--background-secondary)]',
|
| 137 |
+
/* Flex layout */
|
| 138 |
+
'flex flex-col',
|
| 139 |
+
className
|
| 140 |
+
)}
|
| 141 |
+
>
|
| 142 |
+
{/* Header with collapse button */}
|
| 143 |
+
<div
|
| 144 |
+
className={cn(
|
| 145 |
+
'flex items-center justify-between',
|
| 146 |
+
'px-3 py-3',
|
| 147 |
+
'border-b border-[var(--border)]/10'
|
| 148 |
+
)}
|
| 149 |
+
>
|
| 150 |
+
{!isCollapsed && (
|
| 151 |
+
<span className="text-xs font-medium text-[var(--foreground-muted)] uppercase tracking-wider">
|
| 152 |
+
Model
|
| 153 |
+
</span>
|
| 154 |
+
)}
|
| 155 |
+
<button
|
| 156 |
+
type="button"
|
| 157 |
+
onClick={toggleCollapsed}
|
| 158 |
+
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
| 159 |
+
className={cn(
|
| 160 |
+
'p-1.5 rounded-md',
|
| 161 |
+
'text-[var(--foreground-muted)]',
|
| 162 |
+
'hover:bg-[var(--background-tertiary)]',
|
| 163 |
+
'hover:text-[var(--foreground)]',
|
| 164 |
+
'transition-colors duration-150',
|
| 165 |
+
isCollapsed && 'mx-auto'
|
| 166 |
+
)}
|
| 167 |
+
>
|
| 168 |
+
{isCollapsed ? (
|
| 169 |
+
<ChevronRight className="h-4 w-4" />
|
| 170 |
+
) : (
|
| 171 |
+
<ChevronLeft className="h-4 w-4" />
|
| 172 |
+
)}
|
| 173 |
+
</button>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{/* Provider/Model Info */}
|
| 177 |
+
<div className="flex-1 px-2 py-3">
|
| 178 |
+
<div
|
| 179 |
+
className={cn(
|
| 180 |
+
'rounded-lg p-3',
|
| 181 |
+
'bg-[var(--background-tertiary)]/50',
|
| 182 |
+
'border border-[var(--border)]/10'
|
| 183 |
+
)}
|
| 184 |
+
>
|
| 185 |
+
{isCollapsed ? (
|
| 186 |
+
/* Collapsed: Icon only */
|
| 187 |
+
<div className="flex flex-col items-center gap-2">
|
| 188 |
+
{selectedProvider ? (
|
| 189 |
+
<Cpu
|
| 190 |
+
className="h-5 w-5 text-[var(--color-primary-500)]"
|
| 191 |
+
aria-label={`${providerName} - ${modelName}`}
|
| 192 |
+
/>
|
| 193 |
+
) : (
|
| 194 |
+
<Zap
|
| 195 |
+
className="h-5 w-5 text-[var(--color-primary-500)]"
|
| 196 |
+
aria-label="Auto mode"
|
| 197 |
+
/>
|
| 198 |
+
)}
|
| 199 |
+
</div>
|
| 200 |
+
) : (
|
| 201 |
+
/* Expanded: Full info */
|
| 202 |
+
<div className="space-y-2">
|
| 203 |
+
{/* Provider icon and name */}
|
| 204 |
+
<div className="flex items-center gap-2">
|
| 205 |
+
{selectedProvider ? (
|
| 206 |
+
<Cpu className="h-4 w-4 text-[var(--color-primary-500)]" />
|
| 207 |
+
) : (
|
| 208 |
+
<Zap className="h-4 w-4 text-[var(--color-primary-500)]" />
|
| 209 |
+
)}
|
| 210 |
+
<span className="text-sm font-medium text-[var(--foreground)]">
|
| 211 |
+
{providerName}
|
| 212 |
+
</span>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{/* Model name */}
|
| 216 |
+
<div className="pl-6">
|
| 217 |
+
<p className="text-xs text-[var(--foreground-muted)]">
|
| 218 |
+
{modelName}
|
| 219 |
+
</p>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
{/* Status indicator */}
|
| 223 |
+
{currentProvider && (
|
| 224 |
+
<div className="flex items-center gap-2 pl-6 pt-1">
|
| 225 |
+
<span
|
| 226 |
+
className={cn(
|
| 227 |
+
'h-1.5 w-1.5 rounded-full',
|
| 228 |
+
currentProvider.isAvailable
|
| 229 |
+
? 'bg-green-500'
|
| 230 |
+
: 'bg-amber-500'
|
| 231 |
+
)}
|
| 232 |
+
/>
|
| 233 |
+
<span className="text-[10px] text-[var(--foreground-muted)]/70">
|
| 234 |
+
{currentProvider.isAvailable
|
| 235 |
+
? 'Available'
|
| 236 |
+
: `Cooldown: ${currentProvider.cooldownSeconds}s`}
|
| 237 |
+
</span>
|
| 238 |
+
</div>
|
| 239 |
+
)}
|
| 240 |
+
|
| 241 |
+
{/* Auto mode indicator */}
|
| 242 |
+
{!selectedProvider && (
|
| 243 |
+
<div className="flex items-center gap-2 pl-6 pt-1">
|
| 244 |
+
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
| 245 |
+
<span className="text-[10px] text-[var(--foreground-muted)]/70">
|
| 246 |
+
Auto-selecting best
|
| 247 |
+
</span>
|
| 248 |
+
</div>
|
| 249 |
+
)}
|
| 250 |
+
</div>
|
| 251 |
+
)}
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
{/* Info section - only when expanded */}
|
| 255 |
+
{!isCollapsed && (
|
| 256 |
+
<div
|
| 257 |
+
className={cn(
|
| 258 |
+
'mt-3 rounded-lg p-3',
|
| 259 |
+
'bg-[var(--color-primary-500)]/5',
|
| 260 |
+
'border border-[var(--color-primary-500)]/10'
|
| 261 |
+
)}
|
| 262 |
+
>
|
| 263 |
+
<div className="flex items-start gap-2">
|
| 264 |
+
<Info className="h-3.5 w-3.5 text-[var(--color-primary-500)] mt-0.5 shrink-0" />
|
| 265 |
+
<div className="space-y-1.5">
|
| 266 |
+
<p className="text-xs text-[var(--foreground-muted)] leading-relaxed">
|
| 267 |
+
Shows the currently active model. If a model hits its rate limit,
|
| 268 |
+
the system automatically switches to the next available model.
|
| 269 |
+
</p>
|
| 270 |
+
<p className="text-xs text-[var(--foreground-muted)]/70 leading-relaxed">
|
| 271 |
+
{selectedProvider
|
| 272 |
+
? `Fallback order: ${currentProvider?.allModels?.slice(0, 3).join(' → ') || 'Loading...'}`
|
| 273 |
+
: 'Auto mode picks the best available provider automatically.'}
|
| 274 |
+
</p>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
)}
|
| 279 |
+
</div>
|
| 280 |
+
</aside>
|
| 281 |
+
);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
Sidebar.displayName = 'Sidebar';
|
frontend/src/components/chat/source-card.tsx
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SourceCard Component
|
| 3 |
+
*
|
| 4 |
+
* A premium-quality card component for displaying source citations from the
|
| 5 |
+
* RAG retrieval system. Designed to present document chunk information in a
|
| 6 |
+
* clean, accessible, and interactive manner.
|
| 7 |
+
*
|
| 8 |
+
* This component displays:
|
| 9 |
+
* - Document heading path (hierarchy breadcrumb)
|
| 10 |
+
* - Page number badge
|
| 11 |
+
* - Truncated/expandable source text
|
| 12 |
+
* - Optional relevance score indicator
|
| 13 |
+
*
|
| 14 |
+
* @module components/chat/source-card
|
| 15 |
+
* @since 1.0.0
|
| 16 |
+
*
|
| 17 |
+
* @example
|
| 18 |
+
* // Basic usage with required props
|
| 19 |
+
* <SourceCard
|
| 20 |
+
* source={{
|
| 21 |
+
* id: 'chunk-123',
|
| 22 |
+
* headingPath: 'Introduction > Thermal Comfort > PMV Model',
|
| 23 |
+
* page: 42,
|
| 24 |
+
* text: 'The PMV model predicts the mean response...',
|
| 25 |
+
* }}
|
| 26 |
+
* />
|
| 27 |
+
*
|
| 28 |
+
* @example
|
| 29 |
+
* // With relevance score displayed
|
| 30 |
+
* <SourceCard
|
| 31 |
+
* source={{
|
| 32 |
+
* id: 'chunk-456',
|
| 33 |
+
* headingPath: 'Chapter 3 > Calculations',
|
| 34 |
+
* page: 78,
|
| 35 |
+
* text: 'To calculate the operative temperature...',
|
| 36 |
+
* score: 0.92,
|
| 37 |
+
* }}
|
| 38 |
+
* showScore
|
| 39 |
+
* />
|
| 40 |
+
*
|
| 41 |
+
* @example
|
| 42 |
+
* // Custom truncation length
|
| 43 |
+
* <SourceCard
|
| 44 |
+
* source={source}
|
| 45 |
+
* truncateLength={200}
|
| 46 |
+
* />
|
| 47 |
+
*/
|
| 48 |
+
|
| 49 |
+
'use client';
|
| 50 |
+
|
| 51 |
+
import {
|
| 52 |
+
forwardRef,
|
| 53 |
+
lazy,
|
| 54 |
+
memo,
|
| 55 |
+
Suspense,
|
| 56 |
+
useCallback,
|
| 57 |
+
useState,
|
| 58 |
+
type HTMLAttributes,
|
| 59 |
+
type KeyboardEvent,
|
| 60 |
+
} from 'react';
|
| 61 |
+
import { cn, truncateText } from '@/lib/utils';
|
| 62 |
+
import type { Source } from '@/types';
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Lazy-loaded icons from lucide-react for bundle optimization.
|
| 66 |
+
*
|
| 67 |
+
* Icons are only loaded when the component is rendered, reducing
|
| 68 |
+
* the initial bundle size. This follows the project convention of
|
| 69 |
+
* lazy-loading heavy dependencies.
|
| 70 |
+
*/
|
| 71 |
+
const FileText = lazy(() =>
|
| 72 |
+
import('lucide-react').then((mod) => ({ default: mod.FileText }))
|
| 73 |
+
);
|
| 74 |
+
const ChevronDown = lazy(() =>
|
| 75 |
+
import('lucide-react').then((mod) => ({ default: mod.ChevronDown }))
|
| 76 |
+
);
|
| 77 |
+
const ChevronUp = lazy(() =>
|
| 78 |
+
import('lucide-react').then((mod) => ({ default: mod.ChevronUp }))
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* Default maximum characters to display before truncation.
|
| 83 |
+
* Chosen to show approximately 2-3 lines of text in typical card widths.
|
| 84 |
+
*/
|
| 85 |
+
const DEFAULT_TRUNCATE_LENGTH = 150;
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Icon placeholder component for Suspense fallback.
|
| 89 |
+
* Renders an empty span with icon dimensions to prevent layout shift.
|
| 90 |
+
*
|
| 91 |
+
* @internal
|
| 92 |
+
*/
|
| 93 |
+
function IconPlaceholder({
|
| 94 |
+
className,
|
| 95 |
+
}: {
|
| 96 |
+
className?: string;
|
| 97 |
+
}): React.ReactElement {
|
| 98 |
+
return <span className={cn('inline-block h-4 w-4', className)} />;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Props for the SourceCard component.
|
| 103 |
+
*
|
| 104 |
+
* Extends standard HTML div attributes for flexibility in styling
|
| 105 |
+
* and event handling while requiring the core source data.
|
| 106 |
+
*/
|
| 107 |
+
export interface SourceCardProps
|
| 108 |
+
extends Omit<HTMLAttributes<HTMLDivElement>, 'id'> {
|
| 109 |
+
/**
|
| 110 |
+
* The source object containing citation data from RAG retrieval.
|
| 111 |
+
* Includes heading path, page number, text excerpt, and optional score.
|
| 112 |
+
*/
|
| 113 |
+
source: Source;
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Whether to display the relevance score badge.
|
| 117 |
+
* When true, shows a subtle percentage indicator based on source.score.
|
| 118 |
+
*
|
| 119 |
+
* @default false
|
| 120 |
+
*/
|
| 121 |
+
showScore?: boolean;
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Maximum number of characters to display before truncation.
|
| 125 |
+
* Text exceeding this length will show a "Show more" button.
|
| 126 |
+
*
|
| 127 |
+
* @default 150
|
| 128 |
+
*/
|
| 129 |
+
truncateLength?: number;
|
| 130 |
+
|
| 131 |
+
/**
|
| 132 |
+
* Initial expanded state for the text content.
|
| 133 |
+
* When true, the full text is shown by default.
|
| 134 |
+
*
|
| 135 |
+
* @default false
|
| 136 |
+
*/
|
| 137 |
+
defaultExpanded?: boolean;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/**
|
| 141 |
+
* PageBadge Component
|
| 142 |
+
*
|
| 143 |
+
* Renders a small badge displaying the page number from the source document.
|
| 144 |
+
* Uses muted styling to be informative without dominating the visual hierarchy.
|
| 145 |
+
*
|
| 146 |
+
* @param page - The page number to display
|
| 147 |
+
* @returns Badge element with page number
|
| 148 |
+
*
|
| 149 |
+
* @internal
|
| 150 |
+
*/
|
| 151 |
+
function PageBadge({ page }: { page: number }): React.ReactElement {
|
| 152 |
+
return (
|
| 153 |
+
<span
|
| 154 |
+
className={cn(
|
| 155 |
+
/* Badge layout and sizing */
|
| 156 |
+
'inline-flex items-center justify-center',
|
| 157 |
+
'px-2 py-0.5',
|
| 158 |
+
/* Typography - small and subtle */
|
| 159 |
+
'text-xs font-medium',
|
| 160 |
+
'text-[var(--foreground-muted)]',
|
| 161 |
+
/* Background and border styling */
|
| 162 |
+
'bg-[var(--background-tertiary)]',
|
| 163 |
+
'rounded-[var(--radius-sm)]',
|
| 164 |
+
/* Prevent shrinking in flex containers */
|
| 165 |
+
'shrink-0'
|
| 166 |
+
)}
|
| 167 |
+
/* Provide accessible label for screen readers */
|
| 168 |
+
aria-label={`Page ${page}`}
|
| 169 |
+
>
|
| 170 |
+
Page {page}
|
| 171 |
+
</span>
|
| 172 |
+
);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* ScoreBadge Component
|
| 177 |
+
*
|
| 178 |
+
* Renders an optional relevance score as a percentage badge.
|
| 179 |
+
* Only visible when showScore prop is true and score exists.
|
| 180 |
+
* Uses color coding to indicate relevance level.
|
| 181 |
+
*
|
| 182 |
+
* @param score - Relevance score from 0-1 (converted to percentage)
|
| 183 |
+
* @returns Badge element with percentage, or null if no score
|
| 184 |
+
*
|
| 185 |
+
* @internal
|
| 186 |
+
*/
|
| 187 |
+
function ScoreBadge({
|
| 188 |
+
score,
|
| 189 |
+
}: {
|
| 190 |
+
score: number | undefined;
|
| 191 |
+
}): React.ReactElement | null {
|
| 192 |
+
/* Don't render if no score provided */
|
| 193 |
+
if (score === undefined) {
|
| 194 |
+
return null;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* Convert 0-1 score to percentage for display */
|
| 198 |
+
const percentage = Math.round(score * 100);
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* Determine badge color based on relevance threshold:
|
| 202 |
+
* - High relevance (>= 80%): Success green
|
| 203 |
+
* - Medium relevance (>= 60%): Primary purple
|
| 204 |
+
* - Low relevance (< 60%): Muted gray
|
| 205 |
+
*/
|
| 206 |
+
const colorClass =
|
| 207 |
+
percentage >= 80
|
| 208 |
+
? 'text-[var(--success)] bg-[var(--success)]/10'
|
| 209 |
+
: percentage >= 60
|
| 210 |
+
? 'text-[var(--color-primary-600)] bg-[var(--color-primary-100)]'
|
| 211 |
+
: 'text-[var(--foreground-muted)] bg-[var(--background-tertiary)]';
|
| 212 |
+
|
| 213 |
+
return (
|
| 214 |
+
<span
|
| 215 |
+
className={cn(
|
| 216 |
+
/* Badge layout and sizing */
|
| 217 |
+
'inline-flex items-center justify-center',
|
| 218 |
+
'px-2 py-0.5',
|
| 219 |
+
/* Typography */
|
| 220 |
+
'text-xs font-medium',
|
| 221 |
+
/* Dynamic color based on score */
|
| 222 |
+
colorClass,
|
| 223 |
+
/* Border radius */
|
| 224 |
+
'rounded-[var(--radius-sm)]',
|
| 225 |
+
/* Prevent shrinking */
|
| 226 |
+
'shrink-0'
|
| 227 |
+
)}
|
| 228 |
+
/* Accessible label explaining the score */
|
| 229 |
+
aria-label={`Relevance score: ${percentage} percent`}
|
| 230 |
+
>
|
| 231 |
+
{percentage}%
|
| 232 |
+
</span>
|
| 233 |
+
);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/**
|
| 237 |
+
* HeadingPath Component
|
| 238 |
+
*
|
| 239 |
+
* Renders the document hierarchy breadcrumb with a file icon.
|
| 240 |
+
* The heading path shows the navigation from document root to the
|
| 241 |
+
* specific section (e.g., "Chapter 1 > Section 2 > Subsection").
|
| 242 |
+
*
|
| 243 |
+
* @param headingPath - The formatted heading hierarchy string
|
| 244 |
+
* @returns Heading path element with icon
|
| 245 |
+
*
|
| 246 |
+
* @internal
|
| 247 |
+
*/
|
| 248 |
+
function HeadingPath({
|
| 249 |
+
headingPath,
|
| 250 |
+
}: {
|
| 251 |
+
headingPath: string;
|
| 252 |
+
}): React.ReactElement {
|
| 253 |
+
return (
|
| 254 |
+
<div
|
| 255 |
+
className={cn(
|
| 256 |
+
/* Flex layout for icon + text alignment */
|
| 257 |
+
'flex items-start gap-2',
|
| 258 |
+
/* Allow text to wrap while maintaining alignment */
|
| 259 |
+
'min-w-0'
|
| 260 |
+
)}
|
| 261 |
+
>
|
| 262 |
+
{/* Document icon with Suspense for lazy loading */}
|
| 263 |
+
<Suspense fallback={<IconPlaceholder className="mt-0.5 shrink-0" />}>
|
| 264 |
+
<FileText
|
| 265 |
+
className={cn(
|
| 266 |
+
/* Icon sizing */
|
| 267 |
+
'h-4 w-4',
|
| 268 |
+
/* Prevent icon from shrinking */
|
| 269 |
+
'shrink-0',
|
| 270 |
+
/* Slight top offset to align with text baseline */
|
| 271 |
+
'mt-0.5',
|
| 272 |
+
/* Muted color to not dominate */
|
| 273 |
+
'text-[var(--foreground-muted)]'
|
| 274 |
+
)}
|
| 275 |
+
strokeWidth={2}
|
| 276 |
+
aria-hidden="true"
|
| 277 |
+
/>
|
| 278 |
+
</Suspense>
|
| 279 |
+
|
| 280 |
+
{/* Heading path text */}
|
| 281 |
+
<span
|
| 282 |
+
className={cn(
|
| 283 |
+
/* Typography */
|
| 284 |
+
'text-sm font-medium leading-snug',
|
| 285 |
+
'text-[var(--foreground)]',
|
| 286 |
+
/* Truncate with ellipsis if too long */
|
| 287 |
+
'line-clamp-2'
|
| 288 |
+
)}
|
| 289 |
+
>
|
| 290 |
+
{headingPath}
|
| 291 |
+
</span>
|
| 292 |
+
</div>
|
| 293 |
+
);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/**
|
| 297 |
+
* ExpandableText Component
|
| 298 |
+
*
|
| 299 |
+
* Renders the source text content with expand/collapse functionality.
|
| 300 |
+
* Text is truncated by default and can be expanded via button click.
|
| 301 |
+
* Includes smooth height animation and proper accessibility attributes.
|
| 302 |
+
*
|
| 303 |
+
* @param text - The full source text content
|
| 304 |
+
* @param truncateLength - Maximum characters before truncation
|
| 305 |
+
* @param expanded - Current expanded state
|
| 306 |
+
* @param onToggle - Callback when expand/collapse is triggered
|
| 307 |
+
* @returns Expandable text section with toggle button
|
| 308 |
+
*
|
| 309 |
+
* @internal
|
| 310 |
+
*/
|
| 311 |
+
function ExpandableText({
|
| 312 |
+
text,
|
| 313 |
+
truncateLength,
|
| 314 |
+
expanded,
|
| 315 |
+
onToggle,
|
| 316 |
+
}: {
|
| 317 |
+
text: string;
|
| 318 |
+
truncateLength: number;
|
| 319 |
+
expanded: boolean;
|
| 320 |
+
onToggle: () => void;
|
| 321 |
+
}): React.ReactElement {
|
| 322 |
+
/**
|
| 323 |
+
* Determine if text needs truncation based on length.
|
| 324 |
+
* If text is shorter than truncateLength, no expand button is needed.
|
| 325 |
+
*/
|
| 326 |
+
const needsTruncation = text.length > truncateLength;
|
| 327 |
+
|
| 328 |
+
/**
|
| 329 |
+
* Compute displayed text based on expansion state.
|
| 330 |
+
* Uses the truncateText utility for word-boundary-aware truncation.
|
| 331 |
+
*/
|
| 332 |
+
const displayedText =
|
| 333 |
+
expanded || !needsTruncation ? text : truncateText(text, truncateLength);
|
| 334 |
+
|
| 335 |
+
/**
|
| 336 |
+
* Generate unique ID for the text region for aria-controls.
|
| 337 |
+
* Using a simple approach since each card instance is unique.
|
| 338 |
+
*/
|
| 339 |
+
const textRegionId = 'source-text-region';
|
| 340 |
+
|
| 341 |
+
/**
|
| 342 |
+
* Handle keyboard interaction for accessibility.
|
| 343 |
+
* Supports Enter and Space keys for toggle activation.
|
| 344 |
+
*/
|
| 345 |
+
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
| 346 |
+
if (event.key === 'Enter' || event.key === ' ') {
|
| 347 |
+
event.preventDefault();
|
| 348 |
+
onToggle();
|
| 349 |
+
}
|
| 350 |
+
};
|
| 351 |
+
|
| 352 |
+
return (
|
| 353 |
+
<div className="flex flex-col gap-2">
|
| 354 |
+
{/* Text content region with smooth transition */}
|
| 355 |
+
<p
|
| 356 |
+
id={textRegionId}
|
| 357 |
+
className={cn(
|
| 358 |
+
/* Typography for readability */
|
| 359 |
+
'text-sm leading-relaxed',
|
| 360 |
+
'text-[var(--foreground-secondary)]',
|
| 361 |
+
/* Whitespace handling */
|
| 362 |
+
'whitespace-pre-wrap break-words',
|
| 363 |
+
/* Smooth height transition for expand/collapse */
|
| 364 |
+
'transition-all duration-[var(--transition-normal)]'
|
| 365 |
+
)}
|
| 366 |
+
>
|
| 367 |
+
{displayedText}
|
| 368 |
+
</p>
|
| 369 |
+
|
| 370 |
+
{/* Show more/less toggle button - only if truncation is needed */}
|
| 371 |
+
{needsTruncation && (
|
| 372 |
+
<button
|
| 373 |
+
type="button"
|
| 374 |
+
onClick={onToggle}
|
| 375 |
+
onKeyDown={handleKeyDown}
|
| 376 |
+
aria-expanded={expanded}
|
| 377 |
+
aria-controls={textRegionId}
|
| 378 |
+
className={cn(
|
| 379 |
+
/* Layout - align to end of container */
|
| 380 |
+
'inline-flex items-center gap-1',
|
| 381 |
+
'self-end',
|
| 382 |
+
/* Typography */
|
| 383 |
+
'text-xs font-medium',
|
| 384 |
+
'text-[var(--color-primary-text)]',
|
| 385 |
+
/* Interactive states */
|
| 386 |
+
'cursor-pointer',
|
| 387 |
+
'transition-colors duration-[var(--transition-fast)]',
|
| 388 |
+
'hover:text-[var(--color-primary-600)]',
|
| 389 |
+
/* Focus styles for accessibility */
|
| 390 |
+
'rounded-[var(--radius-sm)]',
|
| 391 |
+
'focus-visible:outline-none',
|
| 392 |
+
'focus-visible:ring-2',
|
| 393 |
+
'focus-visible:ring-[var(--border-focus)]',
|
| 394 |
+
'focus-visible:ring-offset-2',
|
| 395 |
+
'focus-visible:ring-offset-[var(--background-secondary)]',
|
| 396 |
+
/* Padding for better click target */
|
| 397 |
+
'px-1.5 py-0.5',
|
| 398 |
+
'-mr-1.5'
|
| 399 |
+
)}
|
| 400 |
+
>
|
| 401 |
+
{/* Button label changes based on state */}
|
| 402 |
+
<span>{expanded ? 'Show less' : 'Show more'}</span>
|
| 403 |
+
|
| 404 |
+
{/* Chevron icon indicates expandable action */}
|
| 405 |
+
<Suspense fallback={<IconPlaceholder className="h-3.5 w-3.5" />}>
|
| 406 |
+
{expanded ? (
|
| 407 |
+
<ChevronUp
|
| 408 |
+
className="h-3.5 w-3.5"
|
| 409 |
+
strokeWidth={2.5}
|
| 410 |
+
aria-hidden="true"
|
| 411 |
+
/>
|
| 412 |
+
) : (
|
| 413 |
+
<ChevronDown
|
| 414 |
+
className="h-3.5 w-3.5"
|
| 415 |
+
strokeWidth={2.5}
|
| 416 |
+
aria-hidden="true"
|
| 417 |
+
/>
|
| 418 |
+
)}
|
| 419 |
+
</Suspense>
|
| 420 |
+
</button>
|
| 421 |
+
)}
|
| 422 |
+
</div>
|
| 423 |
+
);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/**
|
| 427 |
+
* SourceCard Component
|
| 428 |
+
*
|
| 429 |
+
* A production-ready card component for displaying source citations
|
| 430 |
+
* from the RAG retrieval system. Presents document chunks with their
|
| 431 |
+
* metadata in an accessible, interactive format.
|
| 432 |
+
*
|
| 433 |
+
* @remarks
|
| 434 |
+
* ## Visual Design
|
| 435 |
+
* - Clean card layout with subtle background differentiation
|
| 436 |
+
* - Document icon with heading path breadcrumb
|
| 437 |
+
* - Page number badge in the header
|
| 438 |
+
* - Expandable text content with smooth animation
|
| 439 |
+
* - Optional relevance score indicator
|
| 440 |
+
*
|
| 441 |
+
* ## Accessibility Features
|
| 442 |
+
* - Proper ARIA labels on all interactive elements
|
| 443 |
+
* - `aria-expanded` attribute on expand/collapse button
|
| 444 |
+
* - `aria-controls` linking button to controlled region
|
| 445 |
+
* - Keyboard navigation support (Enter/Space to toggle)
|
| 446 |
+
* - Focus visible styles meeting WCAG 2.1 requirements
|
| 447 |
+
* - Semantic HTML structure (article element for grouping)
|
| 448 |
+
*
|
| 449 |
+
* ## Performance Optimizations
|
| 450 |
+
* - Lazy-loaded icons to reduce initial bundle size
|
| 451 |
+
* - React.memo wrapper to prevent unnecessary re-renders
|
| 452 |
+
* - Efficient state management with useState
|
| 453 |
+
*
|
| 454 |
+
* ## Color Contrast (WCAG AA)
|
| 455 |
+
* - Primary text: 15.7:1 on background-secondary (light mode)
|
| 456 |
+
* - Secondary text: 6.9:1 on background-secondary (light mode)
|
| 457 |
+
* - Muted text: 4.55:1 on background-secondary (light mode)
|
| 458 |
+
* - All interactions maintain required contrast ratios
|
| 459 |
+
*
|
| 460 |
+
* @param props - SourceCard component props
|
| 461 |
+
* @returns React element containing the styled source card
|
| 462 |
+
*
|
| 463 |
+
* @see {@link SourceCardProps} for full prop documentation
|
| 464 |
+
* @see {@link Source} for the source data structure
|
| 465 |
+
*/
|
| 466 |
+
const SourceCard = forwardRef<HTMLDivElement, SourceCardProps>(
|
| 467 |
+
(
|
| 468 |
+
{
|
| 469 |
+
source,
|
| 470 |
+
showScore = false,
|
| 471 |
+
truncateLength = DEFAULT_TRUNCATE_LENGTH,
|
| 472 |
+
defaultExpanded = false,
|
| 473 |
+
className,
|
| 474 |
+
...props
|
| 475 |
+
},
|
| 476 |
+
ref
|
| 477 |
+
) => {
|
| 478 |
+
/**
|
| 479 |
+
* Local state for tracking text expansion.
|
| 480 |
+
* Initialized from defaultExpanded prop.
|
| 481 |
+
*/
|
| 482 |
+
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
| 483 |
+
|
| 484 |
+
/**
|
| 485 |
+
* Memoized toggle handler to prevent unnecessary re-creations.
|
| 486 |
+
* Uses functional update pattern for state safety.
|
| 487 |
+
*/
|
| 488 |
+
const handleToggle = useCallback(() => {
|
| 489 |
+
setIsExpanded((prev) => !prev);
|
| 490 |
+
}, []);
|
| 491 |
+
|
| 492 |
+
return (
|
| 493 |
+
<article
|
| 494 |
+
ref={ref}
|
| 495 |
+
/**
|
| 496 |
+
* Using article element for semantic grouping of related content.
|
| 497 |
+
* Each source citation is a self-contained piece of information.
|
| 498 |
+
*/
|
| 499 |
+
className={cn(
|
| 500 |
+
/* Card container styling */
|
| 501 |
+
'relative',
|
| 502 |
+
'rounded-[var(--radius-sm)]',
|
| 503 |
+
'bg-[var(--background-secondary)]',
|
| 504 |
+
'border border-[var(--border)]',
|
| 505 |
+
/* Padding for content */
|
| 506 |
+
'p-3 sm:p-4',
|
| 507 |
+
/* Subtle hover effect for interactive feel */
|
| 508 |
+
'transition-all duration-[var(--transition-normal)]',
|
| 509 |
+
'hover:border-[var(--foreground-muted)]',
|
| 510 |
+
'hover:shadow-[var(--shadow-sm)]',
|
| 511 |
+
/* Custom classes from props */
|
| 512 |
+
className
|
| 513 |
+
)}
|
| 514 |
+
/* Accessible label describing the source */
|
| 515 |
+
aria-label={`Source from ${source.headingPath}, page ${source.page}`}
|
| 516 |
+
{...props}
|
| 517 |
+
>
|
| 518 |
+
{/* Header section: heading path + badges */}
|
| 519 |
+
<header
|
| 520 |
+
className={cn(
|
| 521 |
+
/* Flex layout for header content */
|
| 522 |
+
'flex items-start justify-between gap-3',
|
| 523 |
+
/* Bottom spacing before text content */
|
| 524 |
+
'mb-3'
|
| 525 |
+
)}
|
| 526 |
+
>
|
| 527 |
+
{/* Left side: heading path with icon */}
|
| 528 |
+
<HeadingPath headingPath={source.headingPath} />
|
| 529 |
+
|
| 530 |
+
{/* Right side: badges (page number and optional score) */}
|
| 531 |
+
<div className="flex items-center gap-2 shrink-0">
|
| 532 |
+
{/* Relevance score badge (conditional) */}
|
| 533 |
+
{showScore && <ScoreBadge score={source.score} />}
|
| 534 |
+
|
| 535 |
+
{/* Page number badge (always shown) */}
|
| 536 |
+
<PageBadge page={source.page} />
|
| 537 |
+
</div>
|
| 538 |
+
</header>
|
| 539 |
+
|
| 540 |
+
{/* Divider line between header and content */}
|
| 541 |
+
<hr
|
| 542 |
+
className={cn(
|
| 543 |
+
'border-t border-[var(--border)]',
|
| 544 |
+
'-mx-3 sm:-mx-4',
|
| 545 |
+
'mb-3'
|
| 546 |
+
)}
|
| 547 |
+
aria-hidden="true"
|
| 548 |
+
/>
|
| 549 |
+
|
| 550 |
+
{/* Content section: expandable text */}
|
| 551 |
+
<ExpandableText
|
| 552 |
+
text={source.text}
|
| 553 |
+
truncateLength={truncateLength}
|
| 554 |
+
expanded={isExpanded}
|
| 555 |
+
onToggle={handleToggle}
|
| 556 |
+
/>
|
| 557 |
+
</article>
|
| 558 |
+
);
|
| 559 |
+
}
|
| 560 |
+
);
|
| 561 |
+
|
| 562 |
+
/* Display name for React DevTools debugging */
|
| 563 |
+
SourceCard.displayName = 'SourceCard';
|
| 564 |
+
|
| 565 |
+
/**
|
| 566 |
+
* Memoized SourceCard for performance optimization.
|
| 567 |
+
*
|
| 568 |
+
* Since source cards are typically rendered in lists and their content
|
| 569 |
+
* rarely changes after initial render, memoization prevents unnecessary
|
| 570 |
+
* re-renders when sibling components update.
|
| 571 |
+
*
|
| 572 |
+
* The component re-renders when:
|
| 573 |
+
* - source object reference changes
|
| 574 |
+
* - showScore, truncateLength, or defaultExpanded props change
|
| 575 |
+
* - className or other HTML attributes change
|
| 576 |
+
*/
|
| 577 |
+
const MemoizedSourceCard = memo(SourceCard);
|
| 578 |
+
|
| 579 |
+
/* Export both versions - use Memoized for lists, regular for single usage */
|
| 580 |
+
export { SourceCard, MemoizedSourceCard };
|