Spaces:
Sleeping
Sleeping
Commit ·
0c591a7
0
Parent(s):
Initial commit: Instant SWOT Agent
Browse filesMulti-agent strategic analysis system with:
- Research Gateway (A2A client to Researcher-Agent)
- Analyzer, Critic, Editor workflow nodes
- Data grounding enforcement in all LLM calls
- React frontend with real-time progress
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +52 -0
- .env.example +63 -0
- .gitignore +199 -0
- BUSINESS_README.md +89 -0
- Dockerfile +32 -0
- Makefile +104 -0
- README.md +345 -0
- a2a/__init__.py +7 -0
- a2a/agent_card.json +72 -0
- data/cache/us_stocks.json +0 -0
- data/strategy.db +0 -0
- docs/a2a_architecture.md +113 -0
- docs/architecture.md +160 -0
- docs/configuration.md +89 -0
- frontend/.gitignore +24 -0
- frontend/.storybook/main.ts +17 -0
- frontend/.storybook/preview.tsx +29 -0
- frontend/README.md +88 -0
- frontend/e2e/app.spec.ts +94 -0
- frontend/eslint.config.js +29 -0
- frontend/index.html +13 -0
- frontend/install_dependencies.sh +27 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +108 -0
- frontend/playwright.config.ts +42 -0
- frontend/postcss.config.js +6 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.css +42 -0
- frontend/src/App.tsx +722 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/components/ActivityLog.test.tsx +58 -0
- frontend/src/components/ActivityLog.tsx +130 -0
- frontend/src/components/MetricsPanel.tsx +178 -0
- frontend/src/components/ProcessFlow.tsx +536 -0
- frontend/src/components/StockSearch.tsx +261 -0
- frontend/src/components/ViewModeToggle.test.tsx +47 -0
- frontend/src/components/ViewModeToggle.tsx +43 -0
- frontend/src/components/ui/accordion.tsx +52 -0
- frontend/src/components/ui/alert-dialog.tsx +104 -0
- frontend/src/components/ui/alert.tsx +43 -0
- frontend/src/components/ui/aspect-ratio.tsx +5 -0
- frontend/src/components/ui/avatar.tsx +38 -0
- frontend/src/components/ui/badge.tsx +29 -0
- frontend/src/components/ui/breadcrumb.tsx +90 -0
- frontend/src/components/ui/button.tsx +47 -0
- frontend/src/components/ui/calendar.tsx +54 -0
- frontend/src/components/ui/card.tsx +43 -0
- frontend/src/components/ui/carousel.tsx +224 -0
- frontend/src/components/ui/chart.tsx +303 -0
- frontend/src/components/ui/checkbox.tsx +26 -0
.dockerignore
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker ignore file
|
| 2 |
+
|
| 3 |
+
# Node modules
|
| 4 |
+
node_modules
|
| 5 |
+
frontend/node_modules
|
| 6 |
+
|
| 7 |
+
# Python cache
|
| 8 |
+
__pycache__
|
| 9 |
+
*.pyc
|
| 10 |
+
*.pyo
|
| 11 |
+
*.pyd
|
| 12 |
+
|
| 13 |
+
# Virtual environment
|
| 14 |
+
.venv
|
| 15 |
+
venv
|
| 16 |
+
|
| 17 |
+
# Environment files
|
| 18 |
+
.env
|
| 19 |
+
.env.local
|
| 20 |
+
|
| 21 |
+
# Git files
|
| 22 |
+
git
|
| 23 |
+
.git
|
| 24 |
+
.gitignore
|
| 25 |
+
.gitmodules
|
| 26 |
+
|
| 27 |
+
# IDE files
|
| 28 |
+
.vscode
|
| 29 |
+
.idea
|
| 30 |
+
|
| 31 |
+
# Build artifacts
|
| 32 |
+
dist
|
| 33 |
+
build
|
| 34 |
+
|
| 35 |
+
# Logs and cache
|
| 36 |
+
*.log
|
| 37 |
+
*.cache
|
| 38 |
+
|
| 39 |
+
# Documentation
|
| 40 |
+
docs
|
| 41 |
+
|
| 42 |
+
# Test files
|
| 43 |
+
tests
|
| 44 |
+
|
| 45 |
+
# Coverage
|
| 46 |
+
.coverage
|
| 47 |
+
htmlcov
|
| 48 |
+
|
| 49 |
+
# Misc
|
| 50 |
+
.DS_Store
|
| 51 |
+
*.swp
|
| 52 |
+
*.swo
|
.env.example
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Instant SWOT Agent - Environment Configuration
|
| 2 |
+
# Copy this file to .env and fill in your API keys
|
| 3 |
+
|
| 4 |
+
# ========================================
|
| 5 |
+
# LLM Providers (at least one required)
|
| 6 |
+
# ========================================
|
| 7 |
+
|
| 8 |
+
# Primary - Groq (fast, recommended for demos)
|
| 9 |
+
GROQ_API_KEY=
|
| 10 |
+
GROQ_MODEL=llama-3.1-8b-instant
|
| 11 |
+
|
| 12 |
+
# Fallback 1 - Google Gemini
|
| 13 |
+
GEMINI_API_KEY=
|
| 14 |
+
GEMINI_MODEL=gemini-2.0-flash-exp
|
| 15 |
+
|
| 16 |
+
# Fallback 2 - OpenRouter (aggregator, many models)
|
| 17 |
+
OPENROUTER_API_KEY=
|
| 18 |
+
OPENROUTER_MODEL=google/gemini-2.0-flash-exp:free
|
| 19 |
+
|
| 20 |
+
# ========================================
|
| 21 |
+
# Search API (required for live company data)
|
| 22 |
+
# ========================================
|
| 23 |
+
TAVILY_API_KEY=
|
| 24 |
+
|
| 25 |
+
# ========================================
|
| 26 |
+
# Volatility MCP Server (optional)
|
| 27 |
+
# ========================================
|
| 28 |
+
|
| 29 |
+
# FRED - Federal Reserve Economic Data (for authoritative VIX)
|
| 30 |
+
# Get free key: https://fred.stlouisfed.org/docs/api/api_key.html
|
| 31 |
+
FRED_API_KEY=
|
| 32 |
+
|
| 33 |
+
# Alpha Vantage (for implied volatility from options)
|
| 34 |
+
# Get free key: https://www.alphavantage.co/support/#api-key
|
| 35 |
+
ALPHA_VANTAGE_API_KEY=
|
| 36 |
+
|
| 37 |
+
# ========================================
|
| 38 |
+
# Sentiment MCP Server (optional)
|
| 39 |
+
# ========================================
|
| 40 |
+
|
| 41 |
+
# Finnhub - News sentiment data
|
| 42 |
+
# Get free key: https://finnhub.io/register
|
| 43 |
+
FINNHUB_API_KEY=
|
| 44 |
+
|
| 45 |
+
# ========================================
|
| 46 |
+
# A2A Protocol Configuration (optional)
|
| 47 |
+
# ========================================
|
| 48 |
+
|
| 49 |
+
# Enable A2A mode (set to true to use Researcher A2A Server)
|
| 50 |
+
USE_A2A_RESEARCHER=false
|
| 51 |
+
|
| 52 |
+
# Researcher A2A Server URL
|
| 53 |
+
A2A_RESEARCHER_URL=https://vn6295337-researcher-a2a-agent.hf.space
|
| 54 |
+
|
| 55 |
+
# A2A timeout in seconds
|
| 56 |
+
A2A_TIMEOUT=60
|
| 57 |
+
|
| 58 |
+
# ========================================
|
| 59 |
+
# Tracing (optional, for debugging)
|
| 60 |
+
# ========================================
|
| 61 |
+
LANGCHAIN_API_KEY=
|
| 62 |
+
LANGCHAIN_TRACING_V2=false
|
| 63 |
+
LANGCHAIN_PROJECT=ai-strategy-copilot
|
.gitignore
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
# Python lib directories (but not frontend/src/lib)
|
| 18 |
+
/lib/
|
| 19 |
+
lib64/
|
| 20 |
+
!frontend/src/lib/
|
| 21 |
+
parts/
|
| 22 |
+
sdist/
|
| 23 |
+
var/
|
| 24 |
+
wheels/
|
| 25 |
+
share/python-wheels/
|
| 26 |
+
*.egg-info/
|
| 27 |
+
.installed.cfg
|
| 28 |
+
*.egg
|
| 29 |
+
MANIFEST
|
| 30 |
+
|
| 31 |
+
# PyInstaller
|
| 32 |
+
# Usually these files are written by a python script from a template
|
| 33 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 34 |
+
*.manifest
|
| 35 |
+
*.spec
|
| 36 |
+
|
| 37 |
+
# Installer logs
|
| 38 |
+
pip-log.txt
|
| 39 |
+
pip-delete-this-directory.txt
|
| 40 |
+
|
| 41 |
+
# Unit test / coverage reports
|
| 42 |
+
htmlcov/
|
| 43 |
+
.tox/
|
| 44 |
+
.nox/
|
| 45 |
+
.coverage
|
| 46 |
+
.coverage.*
|
| 47 |
+
.cache
|
| 48 |
+
nosetests.xml
|
| 49 |
+
coverage.xml
|
| 50 |
+
*.cover
|
| 51 |
+
*.py,cover
|
| 52 |
+
.hypothesis/
|
| 53 |
+
.pytest_cache/
|
| 54 |
+
cover/
|
| 55 |
+
|
| 56 |
+
# Translations
|
| 57 |
+
*.mo
|
| 58 |
+
*.pot
|
| 59 |
+
|
| 60 |
+
# Django stuff:
|
| 61 |
+
*.log
|
| 62 |
+
local_settings.py
|
| 63 |
+
db.sqlite3
|
| 64 |
+
db.sqlite3-journal
|
| 65 |
+
|
| 66 |
+
# Flask stuff:
|
| 67 |
+
instance/
|
| 68 |
+
.webassets-cache
|
| 69 |
+
|
| 70 |
+
# Scrapy stuff:
|
| 71 |
+
.scrapy
|
| 72 |
+
|
| 73 |
+
# Sphinx documentation
|
| 74 |
+
docs/_build/
|
| 75 |
+
|
| 76 |
+
# PyBuilder
|
| 77 |
+
.pybuilder/
|
| 78 |
+
target/
|
| 79 |
+
|
| 80 |
+
# Jupyter Notebook
|
| 81 |
+
.ipynb_checkpoints
|
| 82 |
+
|
| 83 |
+
# IPython
|
| 84 |
+
profile_default/
|
| 85 |
+
ipython_config.py
|
| 86 |
+
|
| 87 |
+
# pyenv
|
| 88 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 89 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 90 |
+
.python-version
|
| 91 |
+
|
| 92 |
+
# pipenv
|
| 93 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 94 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 95 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 96 |
+
# install all needed dependencies.
|
| 97 |
+
#Pipfile.lock
|
| 98 |
+
|
| 99 |
+
# poetry
|
| 100 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 101 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 102 |
+
# commonly ignored for libraries.
|
| 103 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 104 |
+
#poetry.lock
|
| 105 |
+
|
| 106 |
+
# pdm
|
| 107 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 108 |
+
#pdm.lock
|
| 109 |
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
| 110 |
+
# in version control.
|
| 111 |
+
# https://pdm.fming.dev/#use-with-ide
|
| 112 |
+
.pdm.toml
|
| 113 |
+
|
| 114 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 115 |
+
__pypackages__/
|
| 116 |
+
|
| 117 |
+
# Celery stuff
|
| 118 |
+
celerybeat-schedule
|
| 119 |
+
celerybeat.pid
|
| 120 |
+
|
| 121 |
+
# SageMath parsed files
|
| 122 |
+
*.sage.py
|
| 123 |
+
|
| 124 |
+
# Environments
|
| 125 |
+
.env
|
| 126 |
+
.venv
|
| 127 |
+
env/
|
| 128 |
+
venv/
|
| 129 |
+
ENV/
|
| 130 |
+
env.bak/
|
| 131 |
+
venv.bak/
|
| 132 |
+
|
| 133 |
+
# Spyder project settings
|
| 134 |
+
.spyderproject
|
| 135 |
+
.spyproject
|
| 136 |
+
|
| 137 |
+
# Rope project settings
|
| 138 |
+
.ropeproject
|
| 139 |
+
|
| 140 |
+
# mkdocs documentation
|
| 141 |
+
/site
|
| 142 |
+
|
| 143 |
+
# mypy
|
| 144 |
+
.mypy_cache/
|
| 145 |
+
.dmypy.json
|
| 146 |
+
dmypy.json
|
| 147 |
+
|
| 148 |
+
# Pyre type checker
|
| 149 |
+
.pyre/
|
| 150 |
+
|
| 151 |
+
# pytype static type analyzer
|
| 152 |
+
.pytype/
|
| 153 |
+
|
| 154 |
+
# Cython debug symbols
|
| 155 |
+
cython_debug/
|
| 156 |
+
|
| 157 |
+
# PyCharm
|
| 158 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 159 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 160 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 161 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 162 |
+
#.idea/
|
| 163 |
+
|
| 164 |
+
# Node modules
|
| 165 |
+
node_modules/
|
| 166 |
+
|
| 167 |
+
# Log files
|
| 168 |
+
*.log
|
| 169 |
+
|
| 170 |
+
# Temporary files
|
| 171 |
+
*.tmp
|
| 172 |
+
*.temp
|
| 173 |
+
|
| 174 |
+
# OS generated files
|
| 175 |
+
.DS_Store
|
| 176 |
+
.DS_Store?
|
| 177 |
+
._*
|
| 178 |
+
.Spotlight-V100
|
| 179 |
+
.Trashes
|
| 180 |
+
ehthumbs.db
|
| 181 |
+
Thumbs.db
|
| 182 |
+
|
| 183 |
+
# Obsidian
|
| 184 |
+
.obsidian/
|
| 185 |
+
|
| 186 |
+
# Binary/media assets
|
| 187 |
+
*.png
|
| 188 |
+
*.jpg
|
| 189 |
+
*.jpeg
|
| 190 |
+
*.gif
|
| 191 |
+
*.avif
|
| 192 |
+
*.xlsx
|
| 193 |
+
*.pdf
|
| 194 |
+
|
| 195 |
+
# Storybook assets
|
| 196 |
+
frontend/src/stories/assets/
|
| 197 |
+
|
| 198 |
+
# Draft files
|
| 199 |
+
process_flow.*
|
BUSINESS_README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Instant SWOT Agent
|
| 2 |
+
|
| 3 |
+
## Executive Summary
|
| 4 |
+
|
| 5 |
+
Instant SWOT Agent is a proof-of-concept demonstrating how to build **reliable, enterprise-grade AI systems** that solve the core challenge plaguing most GenAI deployments: inconsistent output quality.
|
| 6 |
+
|
| 7 |
+
This project showcases a multi-agent AI architecture that autonomously generates strategic SWOT analyses for publicly-traded companies—with built-in quality control that ensures outputs meet a defined standard before delivery. The system aggregates real-time data from six different sources, orchestrates specialized AI agents, and implements a self-correcting feedback loop that eliminates the "first draft = final draft" problem endemic to most LLM applications.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Problem Statement
|
| 12 |
+
|
| 13 |
+
Enterprise AI deployments consistently fail not because of model capability, but because of **quality unpredictability**. Strategic analysis tools face three compounding challenges:
|
| 14 |
+
|
| 15 |
+
1. **Quality variance:** LLM outputs range from exceptional to unusable, with no systematic mechanism to detect or correct poor results before they reach end users.
|
| 16 |
+
|
| 17 |
+
2. **Data fragmentation:** Strategic decisions require synthesizing financial data, market conditions, competitive intelligence, and sentiment—typically scattered across multiple systems and formats.
|
| 18 |
+
|
| 19 |
+
3. **Time-to-insight gap:** Manual analysis processes that take hours or days cannot support the pace of modern business decision-making.
|
| 20 |
+
|
| 21 |
+
The result: organizations either accept inconsistent AI outputs or abandon GenAI initiatives entirely, forfeiting competitive advantage.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Solution Overview
|
| 26 |
+
|
| 27 |
+
Instant SWOT Agent addresses these challenges through a **multi-agent workflow with autonomous quality control**:
|
| 28 |
+
|
| 29 |
+
**Specialized Agent Roles:**
|
| 30 |
+
- **Researcher Agent** — Aggregates real-time data from financial filings, market indicators, news sources, and sentiment signals
|
| 31 |
+
- **Analyst Agent** — Synthesizes research into structured SWOT analysis aligned with specified strategic frameworks
|
| 32 |
+
- **Critic Agent** — Evaluates output quality using a hybrid scoring system (objective metrics + subjective assessment)
|
| 33 |
+
- **Editor Agent** — Revises drafts based on specific critique feedback until quality thresholds are met
|
| 34 |
+
|
| 35 |
+
**The Quality Loop:** The system operates as a closed feedback loop. Analysis outputs are automatically evaluated against defined criteria. If quality falls below threshold, targeted revisions are made and re-evaluated—up to three iterations—ensuring consistent, board-ready deliverables.
|
| 36 |
+
|
| 37 |
+
**Data Integration:** Six specialized data services aggregate 38+ metrics spanning fundamentals, valuation, volatility, macroeconomic indicators, news coverage, and market sentiment—all from free, publicly-available sources.
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## Strategic AI Value
|
| 42 |
+
|
| 43 |
+
This architecture addresses what enterprises struggle with most when deploying GenAI: **building trust through reliability**.
|
| 44 |
+
|
| 45 |
+
**Quality gates enable business adoption.** By implementing systematic evaluation before output delivery, organizations can deploy AI-assisted analysis with confidence that quality standards will be maintained—critical for regulated industries and high-stakes decisions.
|
| 46 |
+
|
| 47 |
+
**Self-correction reduces human overhead.** Rather than requiring human review of every output, the system handles routine quality issues autonomously, escalating only when necessary. This shifts human effort from review to exception-handling.
|
| 48 |
+
|
| 49 |
+
**Modular data architecture supports customization.** The standardized data service layer allows organizations to swap in proprietary data sources (internal financials, CRM data, competitive intelligence) without modifying the core workflow—reducing integration complexity.
|
| 50 |
+
|
| 51 |
+
**Cascading resilience prevents single points of failure.** The system gracefully degrades across multiple AI providers and data sources, maintaining availability even when individual services experience issues.
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## Product & System Thinking
|
| 56 |
+
|
| 57 |
+
**Design decisions reflect enterprise deployment priorities:**
|
| 58 |
+
|
| 59 |
+
| Challenge | Design Choice | Reasoning |
|
| 60 |
+
|-----------|---------------|-----------|
|
| 61 |
+
| Output quality variance | Hybrid scoring (40% objective + 60% subjective) | Objective checks catch structural issues; subjective evaluation assesses insight quality |
|
| 62 |
+
| Revision efficiency | Maximum three iterations | Empirical testing showed quality plateaus after 2-3 cycles; prevents wasted computation |
|
| 63 |
+
| Quality threshold | Score of 7/10 to pass | Balances output quality against latency; lower thresholds cause excessive loops |
|
| 64 |
+
| Provider reliability | Cascading fallback across three LLM providers | Ensures availability; automatically routes around provider outages |
|
| 65 |
+
| Data integration complexity | Standardized MCP server interface | Agents call tools without knowing underlying APIs; sources can be swapped transparently |
|
| 66 |
+
|
| 67 |
+
**Trade-offs acknowledged:**
|
| 68 |
+
|
| 69 |
+
The demonstration uses the same model for both analysis and evaluation—a known limitation where self-evaluation can introduce bias. Production deployment would use a more capable model for evaluation or incorporate human-in-the-loop review for high-stakes outputs. This trade-off was intentional: demonstrating the architectural pattern while managing demo infrastructure costs.
|
| 70 |
+
|
| 71 |
+
---
|
| 72 |
+
|
| 73 |
+
## PoC Capabilities
|
| 74 |
+
|
| 75 |
+
- **Multi-agent workflow orchestration** — Coordinating specialized agents with clear handoffs and state management
|
| 76 |
+
- **Self-correcting feedback loops** — Implementing autonomous quality control with defined exit criteria
|
| 77 |
+
- **Hybrid evaluation systems** — Combining deterministic checks with LLM-based assessment for robust scoring
|
| 78 |
+
- **Real-time data pipeline integration** — Aggregating structured and unstructured data from multiple external sources
|
| 79 |
+
- **Provider resilience patterns** — Building fallback chains for reliability across AI and data services
|
| 80 |
+
- **Prompt engineering for specialized roles** — Designing role-specific prompts that produce consistent, structured outputs
|
| 81 |
+
- **Full-stack AI application development** — Backend orchestration, API layer, and interactive frontend
|
| 82 |
+
- **Rapid PoC execution** — Concept-to-deployment using vibe coding practices and modern tooling
|
| 83 |
+
- **Observability integration** — Tracing and monitoring for debugging and performance optimization
|
| 84 |
+
|
| 85 |
+
---
|
| 86 |
+
|
| 87 |
+
**Live Demo:** [huggingface.co/spaces/vn6295337/Instant-SWOT-Agent](https://huggingface.co/spaces/vn6295337/Instant-SWOT-Agent)
|
| 88 |
+
|
| 89 |
+
**Technical Documentation:** See [README.md](README.md) for architecture details and setup instructions.
|
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for HF Spaces (Docker SDK)
|
| 2 |
+
# Uses pre-built frontend from static/ directory
|
| 3 |
+
|
| 4 |
+
FROM python:3.11-slim
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Copy Python requirements and install
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
# Copy application code
|
| 13 |
+
COPY src/ ./src/
|
| 14 |
+
COPY a2a/ ./a2a/
|
| 15 |
+
COPY data/ ./data/
|
| 16 |
+
COPY .env.example ./.env
|
| 17 |
+
|
| 18 |
+
# Copy pre-built frontend (built locally and committed)
|
| 19 |
+
COPY static/ ./static/
|
| 20 |
+
|
| 21 |
+
# Verify static files exist
|
| 22 |
+
RUN ls -la /app/static/ && ls -la /app/static/assets/
|
| 23 |
+
|
| 24 |
+
# Expose port (HF Spaces uses 7860)
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
# Environment variables
|
| 28 |
+
ENV PYTHONUNBUFFERED=1
|
| 29 |
+
ENV PYTHONPATH=/app
|
| 30 |
+
|
| 31 |
+
# Start server (using new consolidated path)
|
| 32 |
+
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
Makefile
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Makefile for Instant SWOT Agent
|
| 2 |
+
|
| 3 |
+
# Variables
|
| 4 |
+
PYTHON := python3
|
| 5 |
+
PIP := pip3
|
| 6 |
+
VENV := .venv
|
| 7 |
+
TEST_DIR := tests
|
| 8 |
+
SRC_DIR := src
|
| 9 |
+
|
| 10 |
+
# Default target
|
| 11 |
+
.PHONY: help
|
| 12 |
+
help:
|
| 13 |
+
@echo "Instant SWOT Agent - Makefile Commands"
|
| 14 |
+
@echo ""
|
| 15 |
+
@echo "Usage:"
|
| 16 |
+
@echo " make install Install dependencies"
|
| 17 |
+
@echo " make test Run tests"
|
| 18 |
+
@echo " make api Run the FastAPI backend"
|
| 19 |
+
@echo " make ui Run the Streamlit UI"
|
| 20 |
+
@echo " make analyze TICKER=X Run CLI analysis for ticker X"
|
| 21 |
+
@echo " make clean Clean generated files"
|
| 22 |
+
@echo " make docs Generate documentation"
|
| 23 |
+
@echo " make lint Run code linting"
|
| 24 |
+
@echo " make format Format code with black"
|
| 25 |
+
@echo " make help Show this help message"
|
| 26 |
+
|
| 27 |
+
# Install dependencies
|
| 28 |
+
.PHONY: install
|
| 29 |
+
install:
|
| 30 |
+
$(PIP) install -r requirements.txt
|
| 31 |
+
|
| 32 |
+
# Run tests
|
| 33 |
+
.PHONY: test
|
| 34 |
+
test:
|
| 35 |
+
$(PYTHON) -m pytest $(TEST_DIR) -v
|
| 36 |
+
|
| 37 |
+
# Run the Streamlit UI
|
| 38 |
+
.PHONY: ui
|
| 39 |
+
ui:
|
| 40 |
+
$(PYTHON) -m src.main streamlit
|
| 41 |
+
|
| 42 |
+
# Run the FastAPI backend
|
| 43 |
+
.PHONY: api
|
| 44 |
+
api:
|
| 45 |
+
$(PYTHON) -m src.main api
|
| 46 |
+
|
| 47 |
+
# Run the new React frontend with FastAPI backend
|
| 48 |
+
.PHONY: frontend
|
| 49 |
+
frontend:
|
| 50 |
+
./run_frontend.sh
|
| 51 |
+
|
| 52 |
+
# Run CLI analysis (example: make analyze TICKER=AAPL)
|
| 53 |
+
.PHONY: analyze
|
| 54 |
+
analyze:
|
| 55 |
+
$(PYTHON) -m src.main analyze $(TICKER)
|
| 56 |
+
|
| 57 |
+
# Clean generated files
|
| 58 |
+
.PHONY: clean
|
| 59 |
+
clean:
|
| 60 |
+
rm -rf *.log
|
| 61 |
+
rm -rf data/logs/
|
| 62 |
+
find . -type f -name "*.pyc" -delete
|
| 63 |
+
find . -type d -name "__pycache__" -delete
|
| 64 |
+
|
| 65 |
+
# Lint code
|
| 66 |
+
.PHONY: lint
|
| 67 |
+
lint:
|
| 68 |
+
flake8 $(SRC_DIR) $(TEST_DIR)
|
| 69 |
+
pylint $(SRC_DIR) $(TEST_DIR)
|
| 70 |
+
|
| 71 |
+
# Format code
|
| 72 |
+
.PHONY: format
|
| 73 |
+
format:
|
| 74 |
+
black $(SRC_DIR) $(TEST_DIR)
|
| 75 |
+
|
| 76 |
+
# Generate documentation
|
| 77 |
+
.PHONY: docs
|
| 78 |
+
docs:
|
| 79 |
+
@echo "Documentation is available in the docs/ directory"
|
| 80 |
+
|
| 81 |
+
# Setup development environment
|
| 82 |
+
.PHONY: setup-dev
|
| 83 |
+
setup-dev:
|
| 84 |
+
$(PYTHON) -m venv $(VENV)
|
| 85 |
+
. $(VENV)/bin/activate && $(PIP) install -r requirements.txt
|
| 86 |
+
@echo "Development environment setup complete. Activate with: source $(VENV)/bin/activate"
|
| 87 |
+
|
| 88 |
+
# Run with coverage
|
| 89 |
+
.PHONY: coverage
|
| 90 |
+
coverage:
|
| 91 |
+
$(PYTHON) -m pytest $(TEST_DIR) --cov=$(SRC_DIR) --cov-report=html
|
| 92 |
+
|
| 93 |
+
# Run specific test
|
| 94 |
+
.PHONY: test-unit
|
| 95 |
+
test-unit:
|
| 96 |
+
$(PYTHON) -m pytest $(TEST_DIR)/graph_test.py -v
|
| 97 |
+
|
| 98 |
+
.PHONY: test-integration
|
| 99 |
+
test-integration:
|
| 100 |
+
$(PYTHON) -m pytest $(TEST_DIR)/test_mcp_comprehensive.py -v
|
| 101 |
+
|
| 102 |
+
.PHONY: test-ui
|
| 103 |
+
test-ui:
|
| 104 |
+
$(PYTHON) -m pytest $(TEST_DIR)/test_streamlit.py -v
|
README.md
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Instant SWOT Agent
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: Instant SWOT Agent with self-correcting feedback
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Instant SWOT Agent
|
| 12 |
+
|
| 13 |
+
**Multi-agent workflow with self-correcting quality control for strategic analysis.**
|
| 14 |
+
|
| 15 |
+
[](https://www.python.org/downloads/)
|
| 16 |
+
[](https://opensource.org/licenses/MIT)
|
| 17 |
+
|
| 18 |
+
| Resource | Link |
|
| 19 |
+
|----------|------|
|
| 20 |
+
| Live Demo | [huggingface.co/spaces/vn6295337/Instant-SWOT-Agent](https://huggingface.co/spaces/vn6295337/Instant-SWOT-Agent) |
|
| 21 |
+
| Product Demo Video | [Pre-recorded Demo](https://github.com/vn6295337/Instant-SWOT-Agent/issues/1) |
|
| 22 |
+
| Business Guide | [BUSINESS_README.md](BUSINESS_README.md) |
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## The Problem
|
| 27 |
+
|
| 28 |
+
Strategic analysis is time-consuming and quality varies widely. Analysts spend hours gathering data and drafting reports, with no systematic quality checks until peer review—often too late in the process.
|
| 29 |
+
|
| 30 |
+
## The Solution
|
| 31 |
+
|
| 32 |
+
This demo implements an **agentic AI pattern** where specialized agents collaborate autonomously: one gathers data, another drafts analysis, a third evaluates quality, and a fourth revises until standards are met. The self-correcting loop eliminates the "first draft = final draft" problem common in LLM applications.
|
| 33 |
+
|
| 34 |
+
## Why This Matters
|
| 35 |
+
|
| 36 |
+
Most enterprise AI deployments fail not from bad models, but from lack of quality gates. This architecture demonstrates how to build reliability into AI workflows—a pattern applicable to any domain requiring consistent output quality.
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## Architecture
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 44 |
+
│ USER INTERFACE │
|
| 45 |
+
│ (React + Vite) │
|
| 46 |
+
└─────────────────────────────────┬───────────────────────────────────────────┘
|
| 47 |
+
│
|
| 48 |
+
▼
|
| 49 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 50 |
+
│ ORCHESTRATION (LangGraph) │
|
| 51 |
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
| 52 |
+
│ │ Researcher │─▶│ Analyst │─▶│ Critic │─▶│ Editor │ │
|
| 53 |
+
│ │ │ │ (SWOT Gen) │ │ (Scoring) │ │ (Revision) │ │
|
| 54 |
+
│ └──────────────┘ └──────────────┘ └──────┬───────┘ └───────┬──────┘ │
|
| 55 |
+
│ │ score < 7 │ │
|
| 56 |
+
│ │◀─────────────────┘ │
|
| 57 |
+
│ ▼ │
|
| 58 |
+
│ score ≥ 7 or 3 revisions → [END] │
|
| 59 |
+
└─────────────────────────────────────────────┬───────────────────────────────┘
|
| 60 |
+
│
|
| 61 |
+
┌─────────────────────────┼─────────────────────────┐
|
| 62 |
+
│ │ │
|
| 63 |
+
▼ ▼ ▼
|
| 64 |
+
┌───────────────────────────┐ ┌───────────────────────────┐ ┌───────────────────────────┐
|
| 65 |
+
│ 1. FINANCIALS BASKET │ │ 2. VOLATILITY BASKET │ │ 3. MACRO BASKET │
|
| 66 |
+
│ (MCP Server) ✓ │ │ (MCP Server) ✓ │ │ (MCP Server) ✓ │
|
| 67 |
+
├───────────────────────────┤ ├───────────────────────────┤ ├─────���─────────────────────┤
|
| 68 |
+
│ • get_financials │ │ • get_vix │ │ • get_gdp │
|
| 69 |
+
│ • get_debt_metrics │ │ • get_beta │ │ • get_interest_rates │
|
| 70 |
+
│ • get_cash_flow │ │ • get_historical_vol │ │ • get_cpi │
|
| 71 |
+
│ • get_material_events │ │ • get_implied_vol │ │ • get_unemployment │
|
| 72 |
+
│ • get_ownership_filings │ │ • get_volatility_basket │ │ • get_macro_basket │
|
| 73 |
+
│ • get_going_concern │ │ │ │ │
|
| 74 |
+
│ • get_sec_fundamentals │ │ │ │ │
|
| 75 |
+
└─────────────┬─────────────┘ └─────────────┬─────────────┘ └─────────────┬─────────────┘
|
| 76 |
+
│ │ │
|
| 77 |
+
▼ ▼ ▼
|
| 78 |
+
┌───────────────────────────┐ ┌───────────────────────────┐ ┌───────────────────────────┐
|
| 79 |
+
│ SEC EDGAR API │ │ FRED API + Yahoo Finance │ │ FRED API │
|
| 80 |
+
│ (Free, Public) │ │ (Free with API Key) │ │ (Free with Key) │
|
| 81 |
+
└───────────────────────────┘ └───────────────────────────┘ └───────────────────────────┘
|
| 82 |
+
|
| 83 |
+
┌───────────────────────────┐ ┌───────────────────────────┐ ┌───────────────────────────┐
|
| 84 |
+
│ 4. VALUATION BASKET │ │ 5. NEWS BASKET │ │ 6. SENTIMENT BASKET │
|
| 85 |
+
│ (MCP Server) ✓ │ │ (MCP Server) ✓ │ │ (MCP Server) ✓ │
|
| 86 |
+
├───────────────────────────┤ ├───────────────────────────┤ ├───────────────────────────┤
|
| 87 |
+
│ • get_pe_ratio │ │ • search_company_news │ │ • get_social_sentiment │
|
| 88 |
+
│ • get_ps_ratio │ │ • search_going_concern │ │ • get_analyst_ratings │
|
| 89 |
+
│ • get_pb_ratio │ │ • search_industry_trends │ │ │
|
| 90 |
+
│ • get_ev_ebitda │ │ • search_competitor_news │ │ │
|
| 91 |
+
│ • get_valuation_basket │ │ • tavily_search │ │ │
|
| 92 |
+
└─────────────┬─────────────┘ └─────────────┬─────────────┘ └─────────────┬─────────────┘
|
| 93 |
+
│ │ │
|
| 94 |
+
▼ ▼ ▼
|
| 95 |
+
┌───────────────────────────┐ ┌───────────────────────────┐ ┌───────────────────────────┐
|
| 96 |
+
│ Yahoo + SEC EDGAR │ │ Tavily API │ │ Finnhub API │
|
| 97 |
+
│ (Free/Public) │ │ (Free 1,000/month) │ │ (Free with API Key) │
|
| 98 |
+
└───────────────────────────┘ └───────────────────────────┘ └───────────────────────────┘
|
| 99 |
+
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### MCP Baskets Summary
|
| 103 |
+
|
| 104 |
+
| # | Basket | Status | Source | Key Metrics |
|
| 105 |
+
|---|--------|--------|--------|-------------|
|
| 106 |
+
| 1 | Financials | ✓ Done | SEC EDGAR | Revenue, Margins, Debt, Cash Flow, 8-K, Ownership |
|
| 107 |
+
| 2 | Volatility | ✓ Done | FRED + Yahoo | VIX, Beta, Historical Vol, Implied Vol |
|
| 108 |
+
| 3 | Macro | ✓ Done | FRED | GDP, CPI, Interest Rates, Unemployment |
|
| 109 |
+
| 4 | Valuation | ✓ Done | Yahoo + SEC | P/E, P/S, P/B, EV/EBITDA, PEG |
|
| 110 |
+
| 5 | News | ✓ Done | Tavily | Company News, Industry Trends, Competitors |
|
| 111 |
+
| 6 | Sentiment | ✓ Done | Finnhub | Social Sentiment, Analyst Ratings |
|
| 112 |
+
|
| 113 |
+
### Data Flow
|
| 114 |
+
|
| 115 |
+
```
|
| 116 |
+
User Input (Company) → Researcher → [MCP Servers] → Raw Data
|
| 117 |
+
↓
|
| 118 |
+
Raw Data → Analyst → SWOT Draft → Critic → Score
|
| 119 |
+
↓
|
| 120 |
+
Score < 7 → Editor → Revised Draft → Critic
|
| 121 |
+
Score ≥ 7 → Final Output → User
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## Features
|
| 125 |
+
|
| 126 |
+
| Agent | Role | Implementation |
|
| 127 |
+
|-------|------|----------------|
|
| 128 |
+
| **Researcher** | Gathers real-time company data | 6 MCP servers (financials, volatility, macro, valuation, news, sentiment) |
|
| 129 |
+
| **Analyst** | Drafts SWOT based on selected strategy | Prompt-engineered generation |
|
| 130 |
+
| **Critic** | Scores output 1-10 with reasoning | Rubric-based evaluation |
|
| 131 |
+
| **Editor** | Revises based on critique | Targeted improvement |
|
| 132 |
+
|
| 133 |
+
**Supported Strategies:** Cost Leadership, Differentiation, Focus/Niche
|
| 134 |
+
|
| 135 |
+
## MCP Data Servers
|
| 136 |
+
|
| 137 |
+
Model Context Protocol (MCP) servers provide structured data access for AI agents. See [BUSINESS_README.md](BUSINESS_README.md) for detailed explanation.
|
| 138 |
+
|
| 139 |
+
### Financials Basket
|
| 140 |
+
|
| 141 |
+
| Location | Metric | Tool |
|
| 142 |
+
|----------|--------|------|
|
| 143 |
+
| `mcp-servers/financials-basket/` | Revenue, Net Income, Margins | `get_financials` |
|
| 144 |
+
| | Debt, Debt-to-Equity | `get_debt_metrics` |
|
| 145 |
+
| | Operating CF, CapEx, FCF, R&D | `get_cash_flow` |
|
| 146 |
+
| | 8-K Material Events | `get_material_events` |
|
| 147 |
+
| | 13D/13G, Form 4 (Ownership) | `get_ownership_filings` |
|
| 148 |
+
| | Going Concern Warnings | `get_going_concern` |
|
| 149 |
+
| | All Metrics + SWOT | `get_sec_fundamentals` |
|
| 150 |
+
|
| 151 |
+
### Volatility Basket
|
| 152 |
+
|
| 153 |
+
| Location | Metric | Tool |
|
| 154 |
+
|----------|--------|------|
|
| 155 |
+
| `mcp-servers/volatility-basket/` | VIX Index | `get_vix` |
|
| 156 |
+
| | Beta (vs S&P 500) | `get_beta` |
|
| 157 |
+
| | Historical Volatility | `get_historical_volatility` |
|
| 158 |
+
| | Implied Volatility | `get_implied_volatility` |
|
| 159 |
+
| | All Metrics + SWOT | `get_volatility_basket` |
|
| 160 |
+
|
| 161 |
+
### Macro Basket
|
| 162 |
+
|
| 163 |
+
| Location | Metric | Tool |
|
| 164 |
+
|----------|--------|------|
|
| 165 |
+
| `mcp-servers/macro-basket/` | GDP Growth Rate | `get_gdp` |
|
| 166 |
+
| | Federal Funds Rate | `get_interest_rates` |
|
| 167 |
+
| | CPI / Inflation | `get_cpi` |
|
| 168 |
+
| | Unemployment Rate | `get_unemployment` |
|
| 169 |
+
| | All Metrics + SWOT | `get_macro_basket` |
|
| 170 |
+
|
| 171 |
+
### Valuation Basket
|
| 172 |
+
|
| 173 |
+
| Location | Metric | Tool |
|
| 174 |
+
|----------|--------|------|
|
| 175 |
+
| `mcp-servers/valuation-basket/` | P/E Ratio | `get_pe_ratio` |
|
| 176 |
+
| | P/S Ratio | `get_ps_ratio` |
|
| 177 |
+
| | P/B Ratio | `get_pb_ratio` |
|
| 178 |
+
| | EV/EBITDA | `get_ev_ebitda` |
|
| 179 |
+
| | PEG Ratio | `get_peg_ratio` |
|
| 180 |
+
| | All Metrics + SWOT | `get_valuation_basket` |
|
| 181 |
+
|
| 182 |
+
### News Basket
|
| 183 |
+
|
| 184 |
+
| Location | Metric | Tool |
|
| 185 |
+
|----------|--------|------|
|
| 186 |
+
| `mcp-servers/news-basket/` | General Web Search | `tavily_search` |
|
| 187 |
+
| | Company News | `search_company_news` |
|
| 188 |
+
| | Going Concern News | `search_going_concern_news` |
|
| 189 |
+
| | Industry Trends | `search_industry_trends` |
|
| 190 |
+
| | Competitor News | `search_competitor_news` |
|
| 191 |
+
|
| 192 |
+
### Sentiment Basket
|
| 193 |
+
|
| 194 |
+
| Location | Metric | Tool |
|
| 195 |
+
|----------|--------|------|
|
| 196 |
+
| `mcp-servers/sentiment-basket/` | Social Sentiment | `get_social_sentiment` |
|
| 197 |
+
| | Analyst Ratings | `get_analyst_ratings` |
|
| 198 |
+
|
| 199 |
+
### API Endpoints
|
| 200 |
+
|
| 201 |
+
| MCP Server | API | Endpoint | Auth |
|
| 202 |
+
|------------|-----|----------|------|
|
| 203 |
+
| **Financials Basket** | SEC EDGAR | `https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json` | None (free) |
|
| 204 |
+
| | | `https://data.sec.gov/submissions/CIK{cik}.json` | |
|
| 205 |
+
| **Volatility Basket** | FRED | `https://api.stlouisfed.org/fred/series/observations` | API Key |
|
| 206 |
+
| | Yahoo Finance | `https://query1.finance.yahoo.com/v8/finance/chart/{ticker}` | None |
|
| 207 |
+
| **Macro Basket** | FRED | `https://api.stlouisfed.org/fred/series/observations` | API Key |
|
| 208 |
+
| **Valuation Basket** | Yahoo Finance | `https://query1.finance.yahoo.com/v10/finance/quoteSummary/{ticker}` | None |
|
| 209 |
+
| **News Basket** | Tavily | `https://api.tavily.com/search` | API Key |
|
| 210 |
+
| **Sentiment Basket** | Finnhub | `https://finnhub.io/api/v1/` | API Key |
|
| 211 |
+
|
| 212 |
+
### API Keys
|
| 213 |
+
|
| 214 |
+
| Key | Environment Variable | Get From |
|
| 215 |
+
|-----|---------------------|----------|
|
| 216 |
+
| FRED | `FRED_VIX_API_KEY` | https://fred.stlouisfed.org/docs/api/api_key.html |
|
| 217 |
+
| Tavily | `TAVILY_API_KEY` | https://tavily.com |
|
| 218 |
+
| Finnhub | `FINNHUB_API_KEY` | https://finnhub.io |
|
| 219 |
+
|
| 220 |
+
Store in `.env`:
|
| 221 |
+
```
|
| 222 |
+
FRED_VIX_API_KEY=your_key
|
| 223 |
+
TAVILY_API_KEY=tvly-your_key
|
| 224 |
+
FINNHUB_API_KEY=your_key
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
## Installation & Setup
|
| 228 |
+
|
| 229 |
+
### Local Development
|
| 230 |
+
|
| 231 |
+
```bash
|
| 232 |
+
# Clone the repository
|
| 233 |
+
git clone https://github.com/vn6295337/Instant-SWOT-Agent.git
|
| 234 |
+
cd Instant-SWOT-Agent
|
| 235 |
+
|
| 236 |
+
# Create and activate virtual environment
|
| 237 |
+
python3 -m venv .venv
|
| 238 |
+
source .venv/bin/activate
|
| 239 |
+
|
| 240 |
+
# Install dependencies
|
| 241 |
+
pip install -r requirements.txt
|
| 242 |
+
|
| 243 |
+
# Set up environment variables
|
| 244 |
+
cp .env.example .env
|
| 245 |
+
# Edit .env with your API keys
|
| 246 |
+
|
| 247 |
+
# Run the application (FastAPI + React UI)
|
| 248 |
+
python -m src.main api
|
| 249 |
+
# Or use make
|
| 250 |
+
make api
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
### Hugging Face Spaces Deployment
|
| 254 |
+
|
| 255 |
+
1. **Create a new Space** (Docker SDK)
|
| 256 |
+
2. **Add this repository** as the source
|
| 257 |
+
3. **Set up Secrets** (at least one LLM provider required):
|
| 258 |
+
- `GROQ_API_KEY` (primary, recommended)
|
| 259 |
+
- `GEMINI_API_KEY` (fallback)
|
| 260 |
+
- `OPENROUTER_API_KEY` (fallback)
|
| 261 |
+
- `TAVILY_API_KEY` (for live search data)
|
| 262 |
+
4. The system automatically falls back through providers if one fails
|
| 263 |
+
|
| 264 |
+
## Requirements
|
| 265 |
+
|
| 266 |
+
- Python 3.11+
|
| 267 |
+
- At least one LLM API key (Groq, Gemini, or OpenRouter)
|
| 268 |
+
- Tavily API key (optional, for live search data)
|
| 269 |
+
|
| 270 |
+
## Usage Examples
|
| 271 |
+
|
| 272 |
+
### Web UI
|
| 273 |
+
```bash
|
| 274 |
+
# Start the FastAPI server with React frontend
|
| 275 |
+
python -m src.main api
|
| 276 |
+
```
|
| 277 |
+
Open http://localhost:7860, enter a company name (e.g., "Tesla", "NVIDIA", "Microsoft") and click "Generate SWOT".
|
| 278 |
+
|
| 279 |
+
### CLI Usage
|
| 280 |
+
```bash
|
| 281 |
+
# Analyze a company from command line
|
| 282 |
+
python -m src.main analyze --company "Apple" --strategy "Differentiation"
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
### Programmatic Usage
|
| 286 |
+
```python
|
| 287 |
+
from src.workflow.runner import run_self_correcting_workflow
|
| 288 |
+
|
| 289 |
+
# Generate SWOT analysis with specific strategy
|
| 290 |
+
result = run_self_correcting_workflow(company_name="Apple", strategy_focus="Differentiation")
|
| 291 |
+
|
| 292 |
+
print(f"Score: {result['score']}/10")
|
| 293 |
+
print(f"Revisions: {result['revision_count']}")
|
| 294 |
+
print(f"SWOT Analysis:\n{result['draft_report']}")
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
## Testing
|
| 298 |
+
|
| 299 |
+
```bash
|
| 300 |
+
# Run tests
|
| 301 |
+
make test
|
| 302 |
+
# Or directly
|
| 303 |
+
python3 tests/test_self_correcting_loop.py
|
| 304 |
+
```
|
| 305 |
+
|
| 306 |
+
## Technical Characteristics
|
| 307 |
+
|
| 308 |
+
- **Analysis Time**: Typically under 10 seconds (depends on API latency)
|
| 309 |
+
- **Quality Loop**: Iterates until score ≥ 7/10 or max 3 revisions
|
| 310 |
+
- **LLM Providers**: Groq (primary) → Gemini → OpenRouter (cascading fallback)
|
| 311 |
+
- **Data Sources**: 6 MCP servers aggregating SEC EDGAR, FRED, Yahoo Finance, Tavily, and Finnhub APIs
|
| 312 |
+
- **Frontend**: React + TypeScript + Vite + Tailwind CSS
|
| 313 |
+
- **Backend**: FastAPI with async workflow execution
|
| 314 |
+
|
| 315 |
+
## Design Decisions
|
| 316 |
+
|
| 317 |
+
| Decision | Choice | Rationale |
|
| 318 |
+
|----------|--------|-----------|
|
| 319 |
+
| **Orchestration** | LangGraph | Native support for cyclic workflows; cleaner than raw LangChain for multi-agent patterns |
|
| 320 |
+
| **LLM Provider** | Groq (Llama 3.1 8B) | Sub-second inference enables tight feedback loops; cost-effective for demos |
|
| 321 |
+
| **Quality Threshold** | 7/10 | Balances quality vs. latency; lower values cause excessive loops, higher values rarely achievable |
|
| 322 |
+
| **Max Revisions** | 3 | Empirically, quality plateaus after 2-3 iterations; prevents infinite loops |
|
| 323 |
+
| **Same Model for Critic** | Intentional tradeoff | Production would use a stronger model for evaluation; kept simple for demo cost management |
|
| 324 |
+
| **Web Search** | Tavily API | Purpose-built for LLM applications; returns clean, structured content |
|
| 325 |
+
|
| 326 |
+
### Known Limitations
|
| 327 |
+
|
| 328 |
+
- **Self-evaluation bias**: The critic uses the same model family as the analyst. A production system would use a more capable evaluator model or human-in-the-loop for high-stakes decisions.
|
| 329 |
+
- **Mock data visibility**: When Tavily API is unavailable, the UI clearly indicates cached data is being used.
|
| 330 |
+
|
| 331 |
+
## Contributing
|
| 332 |
+
|
| 333 |
+
Contributions are welcome! Please follow these steps:
|
| 334 |
+
|
| 335 |
+
1. Fork the repository
|
| 336 |
+
2. Create a feature branch
|
| 337 |
+
3. Implement your changes
|
| 338 |
+
4. Add tests for new functionality
|
| 339 |
+
5. Submit a pull request
|
| 340 |
+
|
| 341 |
+
## License
|
| 342 |
+
|
| 343 |
+
This project is licensed under the MIT License.
|
| 344 |
+
|
| 345 |
+
---
|
a2a/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
A2A Protocol Implementation
|
| 3 |
+
|
| 4 |
+
Researcher A2A Server - Standalone data pipeline server
|
| 5 |
+
that communicates with the main LangGraph orchestrator
|
| 6 |
+
via Google A2A protocol (JSON-RPC 2.0 over HTTP).
|
| 7 |
+
"""
|
a2a/agent_card.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "swot-researcher",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Financial research agent that collects data from 6 MCP servers (Financials, Volatility, Macro, Valuation, News, Sentiment) for SWOT analysis.",
|
| 5 |
+
"url": "http://localhost:8003",
|
| 6 |
+
"capabilities": {
|
| 7 |
+
"streaming": false,
|
| 8 |
+
"pushNotifications": false,
|
| 9 |
+
"stateTransitionHistory": false
|
| 10 |
+
},
|
| 11 |
+
"authentication": {
|
| 12 |
+
"schemes": []
|
| 13 |
+
},
|
| 14 |
+
"defaultInputModes": ["text"],
|
| 15 |
+
"defaultOutputModes": ["data"],
|
| 16 |
+
"skills": [
|
| 17 |
+
{
|
| 18 |
+
"id": "research-company",
|
| 19 |
+
"name": "Company Research",
|
| 20 |
+
"description": "Fetch comprehensive financial data for a company from multiple sources including SEC EDGAR, Yahoo Finance, FRED, Tavily, and Finnhub.",
|
| 21 |
+
"inputModes": ["text"],
|
| 22 |
+
"outputModes": ["data"],
|
| 23 |
+
"examples": [
|
| 24 |
+
{
|
| 25 |
+
"input": "Research Tesla",
|
| 26 |
+
"output": "Aggregated financial data for TSLA including financials, volatility, macro indicators, valuation, news, and sentiment"
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"input": "Research AAPL Apple Inc",
|
| 30 |
+
"output": "Aggregated financial data for Apple Inc"
|
| 31 |
+
}
|
| 32 |
+
],
|
| 33 |
+
"inputSchema": {
|
| 34 |
+
"type": "object",
|
| 35 |
+
"properties": {
|
| 36 |
+
"company": {
|
| 37 |
+
"type": "string",
|
| 38 |
+
"description": "Company name or ticker symbol"
|
| 39 |
+
}
|
| 40 |
+
},
|
| 41 |
+
"required": ["company"]
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
],
|
| 45 |
+
"dataSources": [
|
| 46 |
+
{
|
| 47 |
+
"name": "SEC EDGAR",
|
| 48 |
+
"description": "SEC filings, fundamentals, material events",
|
| 49 |
+
"mcp": "financials-basket"
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"name": "Yahoo Finance",
|
| 53 |
+
"description": "Stock prices, volatility, valuation ratios",
|
| 54 |
+
"mcp": ["volatility-basket", "valuation-basket"]
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"name": "FRED",
|
| 58 |
+
"description": "Federal Reserve economic data, macro indicators",
|
| 59 |
+
"mcp": ["volatility-basket", "macro-basket"]
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"name": "Tavily",
|
| 63 |
+
"description": "Web search for company news",
|
| 64 |
+
"mcp": "news-basket"
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"name": "Finnhub",
|
| 68 |
+
"description": "News sentiment analysis",
|
| 69 |
+
"mcp": "sentiment-basket"
|
| 70 |
+
}
|
| 71 |
+
]
|
| 72 |
+
}
|
data/cache/us_stocks.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/strategy.db
ADDED
|
Binary file (24.6 kB). View file
|
|
|
docs/a2a_architecture.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A2A Researcher Agent Architecture
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
The Researcher agent supports two modes:
|
| 6 |
+
- **Direct Mode** (default): Calls MCP servers directly from the main process
|
| 7 |
+
- **A2A Mode**: Delegates to a standalone A2A server for parallel data fetching
|
| 8 |
+
|
| 9 |
+
Enable A2A mode by setting `USE_A2A_RESEARCHER=true` in your environment.
|
| 10 |
+
|
| 11 |
+
## Architecture
|
| 12 |
+
|
| 13 |
+
```
|
| 14 |
+
┌─────────────────────────────────────────────────────────┐
|
| 15 |
+
│ Main Orchestrator (LangGraph) │
|
| 16 |
+
│ │
|
| 17 |
+
│ ┌──────────────────────────────────────────────────┐ │
|
| 18 |
+
│ │ Researcher Node │ │
|
| 19 |
+
│ │ (src/nodes/researcher.py) │ │
|
| 20 |
+
│ │ │ │
|
| 21 |
+
│ │ if USE_A2A_RESEARCHER: │ │
|
| 22 |
+
│ │ → A2A Client (researcher_a2a_client.py) │ │
|
| 23 |
+
│ │ else: │ │
|
| 24 |
+
│ │ → Direct MCP calls │ │
|
| 25 |
+
│ └──────────────────────────────────────────────────┘ │
|
| 26 |
+
│ │ │
|
| 27 |
+
│ │ JSON-RPC 2.0 over HTTP (A2A mode only) │
|
| 28 |
+
│ ↓ │
|
| 29 |
+
└───────┼─────────────────────────────────────────────────┘
|
| 30 |
+
│
|
| 31 |
+
┌───────┴─────────────────────────────────────────────────┐
|
| 32 |
+
│ Researcher A2A Server (optional, external) │
|
| 33 |
+
│ (a2a/researcher_server.py) │
|
| 34 |
+
│ │
|
| 35 |
+
│ Endpoints: │
|
| 36 |
+
│ - GET /.well-known/agent.json (Agent Card) │
|
| 37 |
+
│ - POST / (JSON-RPC: message/send, tasks/get) │
|
| 38 |
+
│ │
|
| 39 |
+
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
| 40 |
+
│ │ Financials │ │ Sentiment │ │ News │ ... │
|
| 41 |
+
│ │ MCP Server │ │ MCP Server │ │ MCP Server │ │
|
| 42 |
+
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
| 43 |
+
└─────────────────────────────────────────────────────────┘
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
## A2A Protocol
|
| 47 |
+
|
| 48 |
+
A2A (Agent-to-Agent) is Google's open protocol for agent interoperability.
|
| 49 |
+
|
| 50 |
+
Reference: https://github.com/google-a2a/A2A
|
| 51 |
+
|
| 52 |
+
### Agent Card
|
| 53 |
+
|
| 54 |
+
Served at `/.well-known/agent.json`:
|
| 55 |
+
|
| 56 |
+
```json
|
| 57 |
+
{
|
| 58 |
+
"name": "swot-researcher",
|
| 59 |
+
"version": "1.0.0",
|
| 60 |
+
"description": "Financial research agent for SWOT analysis",
|
| 61 |
+
"capabilities": {
|
| 62 |
+
"streaming": false,
|
| 63 |
+
"pushNotifications": false
|
| 64 |
+
},
|
| 65 |
+
"skills": [{
|
| 66 |
+
"id": "research-company",
|
| 67 |
+
"name": "Company Research",
|
| 68 |
+
"inputModes": ["text"],
|
| 69 |
+
"outputModes": ["text", "data"]
|
| 70 |
+
}]
|
| 71 |
+
}
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
### JSON-RPC Methods
|
| 75 |
+
|
| 76 |
+
| Method | Description |
|
| 77 |
+
|--------|-------------|
|
| 78 |
+
| `message/send` | Submit research task |
|
| 79 |
+
| `tasks/get` | Get task status/results |
|
| 80 |
+
| `tasks/cancel` | Cancel running task |
|
| 81 |
+
|
| 82 |
+
### Task Lifecycle
|
| 83 |
+
|
| 84 |
+
```
|
| 85 |
+
SUBMITTED → WORKING → COMPLETED
|
| 86 |
+
↘ FAILED
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
## File Structure
|
| 90 |
+
|
| 91 |
+
```
|
| 92 |
+
a2a/
|
| 93 |
+
├── researcher_server.py # A2A server implementation
|
| 94 |
+
├── agent_card.json # Agent capabilities metadata
|
| 95 |
+
└── mcp_aggregator.py # Calls MCP servers in parallel
|
| 96 |
+
|
| 97 |
+
src/nodes/
|
| 98 |
+
├── researcher.py # Mode switch (A2A vs Direct)
|
| 99 |
+
└── researcher_a2a_client.py # A2A client wrapper
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## Benefits of A2A Mode
|
| 103 |
+
|
| 104 |
+
| Aspect | Direct Mode | A2A Mode |
|
| 105 |
+
|--------|-------------|----------|
|
| 106 |
+
| Latency | Sequential MCP calls | Parallel MCP calls |
|
| 107 |
+
| Scaling | Coupled with orchestrator | Independent scaling |
|
| 108 |
+
| Fault Isolation | Shared process | Separate process |
|
| 109 |
+
| Reusability | Single workflow | Any A2A client |
|
| 110 |
+
|
| 111 |
+
## Fallback Behavior
|
| 112 |
+
|
| 113 |
+
If the A2A server is unavailable, the system falls back to direct mode automatically.
|
docs/architecture.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture Documentation
|
| 2 |
+
|
| 3 |
+
## System Overview
|
| 4 |
+
|
| 5 |
+
Instant SWOT Agent is an AI-powered strategic analysis system that generates comprehensive SWOT analyses for companies with automatic quality improvement through a self-correcting loop.
|
| 6 |
+
|
| 7 |
+
## High-Level Architecture
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
User Input (Company Name)
|
| 11 |
+
↓
|
| 12 |
+
┌────────────────────────────────────────┐
|
| 13 |
+
│ USER INTERFACE │
|
| 14 |
+
│ Streamlit (streamlit_app.py) │
|
| 15 |
+
│ React (frontend/) via FastAPI │
|
| 16 |
+
└────────────────────────────────────────┘
|
| 17 |
+
↓
|
| 18 |
+
┌────────────────────────────────────────┐
|
| 19 |
+
│ FastAPI Backend (src/api/app.py) │
|
| 20 |
+
│ Routes: analysis.py, stocks.py │
|
| 21 |
+
└────────────────────────────────────────┘
|
| 22 |
+
↓
|
| 23 |
+
Workflow Engine (LangGraph)
|
| 24 |
+
src/workflow/graph.py
|
| 25 |
+
↓
|
| 26 |
+
Node Orchestration
|
| 27 |
+
↙ ↓ ↓ ↓ ↘
|
| 28 |
+
Researcher → Analyzer → Critic → Editor
|
| 29 |
+
↘________↗ (loop until score ≥7 or 3 revisions)
|
| 30 |
+
↓
|
| 31 |
+
Final SWOT Analysis
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Directory Structure
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
src/
|
| 38 |
+
├── api/ # FastAPI backend
|
| 39 |
+
│ ├── app.py # Application factory
|
| 40 |
+
│ ├── schemas.py # Pydantic models
|
| 41 |
+
│ └── routes/
|
| 42 |
+
│ ├── analysis.py # Workflow endpoints
|
| 43 |
+
│ └── stocks.py # Stock search endpoint
|
| 44 |
+
├── workflow/ # LangGraph workflow
|
| 45 |
+
│ ├── graph.py # Workflow definition
|
| 46 |
+
│ └── runner.py # Execution wrapper
|
| 47 |
+
├── nodes/ # Workflow nodes
|
| 48 |
+
├── services/ # Shared services
|
| 49 |
+
│ ├── swot_parser.py # SWOT text parsing
|
| 50 |
+
│ ├── confidence.py # Confidence calculation
|
| 51 |
+
│ └── workflow_store.py # Workflow state management
|
| 52 |
+
├── utils/ # Utilities
|
| 53 |
+
└── main.py # CLI entry point
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## Core Components
|
| 57 |
+
|
| 58 |
+
### 1. Workflow Engine
|
| 59 |
+
|
| 60 |
+
Located in `src/workflow/graph.py`, implements the self-correcting workflow:
|
| 61 |
+
|
| 62 |
+
- **Entry**: Researcher node
|
| 63 |
+
- **Flow**: Researcher → Analyzer → Critic → (conditional) Editor
|
| 64 |
+
- **Exit**: Score ≥ 7 OR revision_count ≥ 3
|
| 65 |
+
|
| 66 |
+
### 2. Workflow Nodes
|
| 67 |
+
|
| 68 |
+
Located in `src/nodes/`:
|
| 69 |
+
|
| 70 |
+
| Node | File | Responsibility |
|
| 71 |
+
|------|------|----------------|
|
| 72 |
+
| Researcher | `researcher.py` | Gathers data via MCP servers, summarizes with LLM |
|
| 73 |
+
| Analyzer | `analyzer.py` | Generates SWOT analysis draft |
|
| 74 |
+
| Critic | `critic.py` | Evaluates quality (1-10 score) using rubric |
|
| 75 |
+
| Editor | `editor.py` | Revises draft based on critique |
|
| 76 |
+
|
| 77 |
+
### 3. MCP Servers
|
| 78 |
+
|
| 79 |
+
Located in `mcp-servers/`, providing data aggregation:
|
| 80 |
+
|
| 81 |
+
| Server | Data Source | Output |
|
| 82 |
+
|--------|-------------|--------|
|
| 83 |
+
| financials-basket | SEC EDGAR | Financial statements |
|
| 84 |
+
| volatility-basket | Yahoo Finance, FRED | VIX, Beta, IV |
|
| 85 |
+
| macro-basket | FRED | GDP, rates, CPI |
|
| 86 |
+
| valuation-basket | Yahoo Finance, SEC | P/E, P/B, EV/EBITDA |
|
| 87 |
+
| news-basket | Tavily | News articles |
|
| 88 |
+
| sentiment-basket | Finnhub | Sentiment scores |
|
| 89 |
+
|
| 90 |
+
### 4. State Management
|
| 91 |
+
|
| 92 |
+
Defined in `src/state.py`, the workflow state flows through each node:
|
| 93 |
+
|
| 94 |
+
```python
|
| 95 |
+
state = {
|
| 96 |
+
"company_name": str,
|
| 97 |
+
"strategy_focus": str,
|
| 98 |
+
"raw_data": str,
|
| 99 |
+
"draft_report": str,
|
| 100 |
+
"critique": str,
|
| 101 |
+
"score": int,
|
| 102 |
+
"revision_count": int,
|
| 103 |
+
"error": str | None
|
| 104 |
+
}
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
## Data Flow
|
| 108 |
+
|
| 109 |
+
1. **Input**: User enters company name via Streamlit UI
|
| 110 |
+
2. **Research**: Researcher node queries MCP servers for financial data
|
| 111 |
+
3. **Analysis**: Analyzer generates initial SWOT draft
|
| 112 |
+
4. **Evaluation**: Critic scores draft (1-10) against rubric
|
| 113 |
+
5. **Improvement**: If score < 7 and revisions < 3, Editor revises
|
| 114 |
+
6. **Output**: Final SWOT displayed with quality metrics
|
| 115 |
+
|
| 116 |
+
## Quality Evaluation
|
| 117 |
+
|
| 118 |
+
The Critic node uses a rubric-based system:
|
| 119 |
+
|
| 120 |
+
- **Completeness** (25%): All SWOT sections populated
|
| 121 |
+
- **Specificity** (25%): Concrete, actionable insights with data
|
| 122 |
+
- **Relevance** (25%): Aligned with company context
|
| 123 |
+
- **Depth** (25%): Strategic sophistication
|
| 124 |
+
|
| 125 |
+
Threshold: Score ≥ 7/10 to pass without revision.
|
| 126 |
+
|
| 127 |
+
## Extending the System
|
| 128 |
+
|
| 129 |
+
### Adding a New Node
|
| 130 |
+
|
| 131 |
+
1. Create `src/nodes/new_node.py`:
|
| 132 |
+
```python
|
| 133 |
+
def new_node(state: dict) -> dict:
|
| 134 |
+
# Process state
|
| 135 |
+
state["new_field"] = result
|
| 136 |
+
return state
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
2. Register in `src/workflow/graph.py`:
|
| 140 |
+
```python
|
| 141 |
+
workflow.add_node("NewNode", RunnableLambda(new_node))
|
| 142 |
+
workflow.add_edge("PreviousNode", "NewNode")
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
### Adding a New MCP Server
|
| 146 |
+
|
| 147 |
+
1. Create directory `mcp-servers/new-basket/`
|
| 148 |
+
2. Implement server with tool registration
|
| 149 |
+
3. Update Researcher node to call new server
|
| 150 |
+
|
| 151 |
+
## Observability
|
| 152 |
+
|
| 153 |
+
- **LangSmith**: End-to-end workflow tracing (configure via environment variables)
|
| 154 |
+
- **Logging**: Python standard logging at INFO/DEBUG levels
|
| 155 |
+
|
| 156 |
+
## Error Handling
|
| 157 |
+
|
| 158 |
+
- MCP server failures: Graceful degradation, continue with available data
|
| 159 |
+
- LLM failures: Retry with fallback providers
|
| 160 |
+
- Quality failures: Maximum 3 revision attempts before accepting result
|
docs/configuration.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Configuration Guide
|
| 2 |
+
|
| 3 |
+
## Quick Start
|
| 4 |
+
|
| 5 |
+
```bash
|
| 6 |
+
cp .env.example .env
|
| 7 |
+
# Edit .env with your API keys
|
| 8 |
+
```
|
| 9 |
+
|
| 10 |
+
## Environment Variables
|
| 11 |
+
|
| 12 |
+
### LLM Providers (at least one required)
|
| 13 |
+
|
| 14 |
+
The system uses a fallback chain: Groq → Gemini → OpenRouter
|
| 15 |
+
|
| 16 |
+
| Variable | Description | Required |
|
| 17 |
+
|----------|-------------|----------|
|
| 18 |
+
| `GROQ_API_KEY` | Groq API key (primary, fastest) | Recommended |
|
| 19 |
+
| `GROQ_MODEL` | Model name (default: `llama-3.1-8b-instant`) | No |
|
| 20 |
+
| `GEMINI_API_KEY` | Google Gemini API key (fallback 1) | No |
|
| 21 |
+
| `GEMINI_MODEL` | Model name (default: `gemini-2.0-flash-exp`) | No |
|
| 22 |
+
| `OPENROUTER_API_KEY` | OpenRouter API key (fallback 2) | No |
|
| 23 |
+
| `OPENROUTER_MODEL` | Model name (default: `google/gemini-2.0-flash-exp:free`) | No |
|
| 24 |
+
|
| 25 |
+
### Search API
|
| 26 |
+
|
| 27 |
+
| Variable | Description | Required |
|
| 28 |
+
|----------|-------------|----------|
|
| 29 |
+
| `TAVILY_API_KEY` | Tavily search API for live company data | Yes |
|
| 30 |
+
|
| 31 |
+
### MCP Server APIs (optional)
|
| 32 |
+
|
| 33 |
+
| Variable | Description | Source |
|
| 34 |
+
|----------|-------------|--------|
|
| 35 |
+
| `FRED_API_KEY` | Federal Reserve data (VIX) | [fred.stlouisfed.org](https://fred.stlouisfed.org/docs/api/api_key.html) |
|
| 36 |
+
| `ALPHA_VANTAGE_API_KEY` | Options implied volatility | [alphavantage.co](https://www.alphavantage.co/support/#api-key) |
|
| 37 |
+
| `FINNHUB_API_KEY` | News sentiment data | [finnhub.io](https://finnhub.io/register) |
|
| 38 |
+
|
| 39 |
+
### A2A Protocol (optional)
|
| 40 |
+
|
| 41 |
+
| Variable | Default | Description |
|
| 42 |
+
|----------|---------|-------------|
|
| 43 |
+
| `USE_A2A_RESEARCHER` | `false` | Enable A2A mode for Researcher |
|
| 44 |
+
| `A2A_RESEARCHER_URL` | HuggingFace Spaces URL | Researcher A2A server endpoint |
|
| 45 |
+
| `A2A_TIMEOUT` | `60` | Request timeout in seconds |
|
| 46 |
+
|
| 47 |
+
### Observability (optional)
|
| 48 |
+
|
| 49 |
+
| Variable | Description |
|
| 50 |
+
|----------|-------------|
|
| 51 |
+
| `LANGCHAIN_API_KEY` | LangSmith API key |
|
| 52 |
+
| `LANGCHAIN_TRACING_V2` | Enable tracing (`true`/`false`) |
|
| 53 |
+
| `LANGCHAIN_PROJECT` | Project name in LangSmith |
|
| 54 |
+
|
| 55 |
+
## Deployment Environments
|
| 56 |
+
|
| 57 |
+
### Local Development
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
cp .env.example .env
|
| 61 |
+
# Add your API keys
|
| 62 |
+
|
| 63 |
+
# Run Streamlit UI
|
| 64 |
+
streamlit run streamlit_app.py
|
| 65 |
+
|
| 66 |
+
# Or run FastAPI backend (serves React frontend at localhost:8002)
|
| 67 |
+
python -m src.main api
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Docker
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
docker run --env-file .env -p 7860:7860 ai-strategy-copilot
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### Hugging Face Spaces
|
| 77 |
+
|
| 78 |
+
Add secrets in Space Settings → Repository secrets:
|
| 79 |
+
- `GROQ_API_KEY`
|
| 80 |
+
- `TAVILY_API_KEY`
|
| 81 |
+
- (other optional keys)
|
| 82 |
+
|
| 83 |
+
## Troubleshooting
|
| 84 |
+
|
| 85 |
+
| Error | Solution |
|
| 86 |
+
|-------|----------|
|
| 87 |
+
| `No LLM provider configured` | Set at least one of: GROQ_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY |
|
| 88 |
+
| `TAVILY_API_KEY missing` | Required for live company research |
|
| 89 |
+
| `MCP server timeout` | Check individual API keys for MCP servers |
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/.storybook/main.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { StorybookConfig } from '@storybook/react-vite';
|
| 2 |
+
|
| 3 |
+
const config: StorybookConfig = {
|
| 4 |
+
"stories": [
|
| 5 |
+
"../src/**/*.mdx",
|
| 6 |
+
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
| 7 |
+
],
|
| 8 |
+
"addons": [
|
| 9 |
+
"@chromatic-com/storybook",
|
| 10 |
+
"@storybook/addon-vitest",
|
| 11 |
+
"@storybook/addon-a11y",
|
| 12 |
+
"@storybook/addon-docs",
|
| 13 |
+
"@storybook/addon-onboarding"
|
| 14 |
+
],
|
| 15 |
+
"framework": "@storybook/react-vite"
|
| 16 |
+
};
|
| 17 |
+
export default config;
|
frontend/.storybook/preview.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Preview } from '@storybook/react-vite'
|
| 2 |
+
import '../src/index.css'
|
| 3 |
+
|
| 4 |
+
const preview: Preview = {
|
| 5 |
+
parameters: {
|
| 6 |
+
controls: {
|
| 7 |
+
matchers: {
|
| 8 |
+
color: /(background|color)$/i,
|
| 9 |
+
date: /Date$/i,
|
| 10 |
+
},
|
| 11 |
+
},
|
| 12 |
+
backgrounds: {
|
| 13 |
+
default: 'dark',
|
| 14 |
+
values: [
|
| 15 |
+
{ name: 'dark', value: '#0a0a0a' },
|
| 16 |
+
{ name: 'light', value: '#ffffff' },
|
| 17 |
+
],
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
decorators: [
|
| 21 |
+
(Story) => (
|
| 22 |
+
<div className="dark">
|
| 23 |
+
<Story />
|
| 24 |
+
</div>
|
| 25 |
+
),
|
| 26 |
+
],
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export default preview;
|
frontend/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Instant SWOT Agent - React Frontend
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
React-based frontend for Instant SWOT Agent, providing a modern UI for SWOT analysis with real-time workflow visualization.
|
| 6 |
+
|
| 7 |
+
## Tech Stack
|
| 8 |
+
|
| 9 |
+
- **React 18** with TypeScript
|
| 10 |
+
- **Vite** build system
|
| 11 |
+
- **Tailwind CSS** + shadcn/ui components
|
| 12 |
+
- **TanStack Query** for data fetching
|
| 13 |
+
- **React Router** for routing
|
| 14 |
+
|
| 15 |
+
## Core Components
|
| 16 |
+
|
| 17 |
+
| Component | Purpose |
|
| 18 |
+
|-----------|---------|
|
| 19 |
+
| `App.tsx` | Main layout, workflow orchestration, state management |
|
| 20 |
+
| `ProcessFlow.tsx` | SVG-based visual workflow diagram |
|
| 21 |
+
| `StockSearch.tsx` | Autocomplete search with keyboard navigation |
|
| 22 |
+
| `ActivityLog.tsx` | Real-time log viewer with auto-scroll |
|
| 23 |
+
|
| 24 |
+
## Installation
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
cd frontend
|
| 28 |
+
npm install
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Development
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
# Start dev server (port 5173)
|
| 35 |
+
npm run dev
|
| 36 |
+
|
| 37 |
+
# Run tests
|
| 38 |
+
npm test
|
| 39 |
+
|
| 40 |
+
# Type check
|
| 41 |
+
npx tsc --noEmit
|
| 42 |
+
|
| 43 |
+
# Build for production
|
| 44 |
+
npm run build
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## API Integration
|
| 48 |
+
|
| 49 |
+
The frontend connects to the FastAPI backend. API endpoints:
|
| 50 |
+
|
| 51 |
+
| Endpoint | Method | Purpose |
|
| 52 |
+
|----------|--------|---------|
|
| 53 |
+
| `/api/stocks/search` | GET | Stock autocomplete |
|
| 54 |
+
| `/analyze` | POST | Start analysis workflow |
|
| 55 |
+
| `/workflow/{id}/status` | GET | Workflow status polling |
|
| 56 |
+
| `/workflow/{id}/result` | GET | Get final results |
|
| 57 |
+
|
| 58 |
+
### Environment Variables
|
| 59 |
+
|
| 60 |
+
Create `.env` for local development:
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
VITE_API_URL=http://localhost:8002
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Production builds auto-detect the API URL.
|
| 67 |
+
|
| 68 |
+
## Production Deployment
|
| 69 |
+
|
| 70 |
+
The frontend is pre-built and served from `/static/` by the FastAPI backend:
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
# Build production bundle
|
| 74 |
+
npm run build
|
| 75 |
+
|
| 76 |
+
# Copy to static directory
|
| 77 |
+
cp -r dist/* ../static/
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
The Dockerfile uses pre-built static files for HuggingFace Spaces deployment.
|
| 81 |
+
|
| 82 |
+
## Troubleshooting
|
| 83 |
+
|
| 84 |
+
| Issue | Solution |
|
| 85 |
+
|-------|----------|
|
| 86 |
+
| Dependencies fail | `rm -rf node_modules && npm install` |
|
| 87 |
+
| TypeScript errors | `npx tsc --noEmit` to check |
|
| 88 |
+
| Port in use | `npm run dev -- --port 3000` |
|
frontend/e2e/app.spec.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
|
| 3 |
+
test.describe('Instant SWOT Agent', () => {
|
| 4 |
+
test.beforeEach(async ({ page }) => {
|
| 5 |
+
await page.goto('/')
|
| 6 |
+
})
|
| 7 |
+
|
| 8 |
+
test('should display the home page', async ({ page }) => {
|
| 9 |
+
await expect(page.getByText('Instant SWOT Agent')).toBeVisible()
|
| 10 |
+
await expect(page.getByPlaceholder('Search U.S. listed companies...')).toBeVisible()
|
| 11 |
+
})
|
| 12 |
+
|
| 13 |
+
test('should show empty state message', async ({ page }) => {
|
| 14 |
+
await expect(page.getByText('Enter a company name to begin')).toBeVisible()
|
| 15 |
+
})
|
| 16 |
+
|
| 17 |
+
test('should toggle between Executive and Full view modes', async ({ page }) => {
|
| 18 |
+
const executiveBtn = page.getByRole('button', { name: 'Executive' })
|
| 19 |
+
const fullBtn = page.getByRole('button', { name: 'Full' })
|
| 20 |
+
|
| 21 |
+
await expect(executiveBtn).toBeVisible()
|
| 22 |
+
await expect(fullBtn).toBeVisible()
|
| 23 |
+
|
| 24 |
+
// Click Executive
|
| 25 |
+
await executiveBtn.click()
|
| 26 |
+
await expect(executiveBtn).toHaveClass(/bg-primary/)
|
| 27 |
+
|
| 28 |
+
// Click Full
|
| 29 |
+
await fullBtn.click()
|
| 30 |
+
await expect(fullBtn).toHaveClass(/bg-primary/)
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
test('should toggle dark mode', async ({ page }) => {
|
| 34 |
+
const darkModeBtn = page.locator('button').filter({ has: page.locator('svg') }).last()
|
| 35 |
+
|
| 36 |
+
// Check initial state (dark mode)
|
| 37 |
+
await expect(page.locator('html')).toHaveClass(/dark/)
|
| 38 |
+
|
| 39 |
+
// Toggle to light mode
|
| 40 |
+
await darkModeBtn.click()
|
| 41 |
+
await expect(page.locator('html')).not.toHaveClass(/dark/)
|
| 42 |
+
|
| 43 |
+
// Toggle back to dark mode
|
| 44 |
+
await darkModeBtn.click()
|
| 45 |
+
await expect(page.locator('html')).toHaveClass(/dark/)
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
test('should search for stocks', async ({ page }) => {
|
| 49 |
+
const searchInput = page.getByPlaceholder('Search U.S. listed companies...')
|
| 50 |
+
|
| 51 |
+
await searchInput.fill('AAPL')
|
| 52 |
+
|
| 53 |
+
// Wait for autocomplete dropdown
|
| 54 |
+
await expect(page.getByText('Apple')).toBeVisible({ timeout: 5000 })
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
test('should select a stock and show Generate button', async ({ page }) => {
|
| 58 |
+
const searchInput = page.getByPlaceholder('Search U.S. listed companies...')
|
| 59 |
+
|
| 60 |
+
await searchInput.fill('TSLA')
|
| 61 |
+
|
| 62 |
+
// Wait for and click the Tesla option
|
| 63 |
+
await page.getByText('Tesla').first().click()
|
| 64 |
+
|
| 65 |
+
// Generate button should appear
|
| 66 |
+
await expect(page.getByRole('button', { name: /Generate SWOT/i })).toBeVisible()
|
| 67 |
+
})
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
test.describe('SWOT Analysis Workflow', () => {
|
| 71 |
+
test('should complete full analysis workflow', async ({ page }) => {
|
| 72 |
+
// This test requires the backend to be running
|
| 73 |
+
test.skip(!!process.env.CI, 'Skipping in CI - requires backend')
|
| 74 |
+
|
| 75 |
+
await page.goto('/')
|
| 76 |
+
|
| 77 |
+
// Search and select a stock
|
| 78 |
+
const searchInput = page.getByPlaceholder('Search U.S. listed companies...')
|
| 79 |
+
await searchInput.fill('AAPL')
|
| 80 |
+
await page.getByText('Apple').first().click()
|
| 81 |
+
|
| 82 |
+
// Click Generate SWOT
|
| 83 |
+
await page.getByRole('button', { name: /Generate SWOT/i }).click()
|
| 84 |
+
|
| 85 |
+
// Wait for loading state
|
| 86 |
+
await expect(page.getByText(/Analyzing/i)).toBeVisible({ timeout: 5000 })
|
| 87 |
+
|
| 88 |
+
// Wait for results (up to 2 minutes for full analysis)
|
| 89 |
+
await expect(page.getByText('Strengths')).toBeVisible({ timeout: 120000 })
|
| 90 |
+
await expect(page.getByText('Weaknesses')).toBeVisible()
|
| 91 |
+
await expect(page.getByText('Opportunities')).toBeVisible()
|
| 92 |
+
await expect(page.getByText('Threats')).toBeVisible()
|
| 93 |
+
})
|
| 94 |
+
})
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
|
| 7 |
+
export default tseslint.config(
|
| 8 |
+
{ ignores: ['dist', 'node_modules', '.storybook', 'e2e'] },
|
| 9 |
+
{
|
| 10 |
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
languageOptions: {
|
| 13 |
+
ecmaVersion: 2020,
|
| 14 |
+
globals: globals.browser,
|
| 15 |
+
},
|
| 16 |
+
plugins: {
|
| 17 |
+
'react-hooks': reactHooks,
|
| 18 |
+
'react-refresh': reactRefresh,
|
| 19 |
+
},
|
| 20 |
+
rules: {
|
| 21 |
+
...reactHooks.configs.recommended.rules,
|
| 22 |
+
'react-refresh/only-export-components': [
|
| 23 |
+
'warn',
|
| 24 |
+
{ allowConstantExport: true },
|
| 25 |
+
],
|
| 26 |
+
'@typescript-eslint/no-unused-vars': 'warn',
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
)
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/install_dependencies.sh
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
echo "🚀 Starting frontend dependency installation..."
|
| 4 |
+
echo "This may take several minutes depending on your network speed."
|
| 5 |
+
|
| 6 |
+
echo "📦 Installing core dependencies..."
|
| 7 |
+
npm install --no-audit --no-fund
|
| 8 |
+
|
| 9 |
+
if [ $? -eq 0 ]; then
|
| 10 |
+
echo "✅ Dependencies installed successfully!"
|
| 11 |
+
|
| 12 |
+
echo "🧪 Running syntax check..."
|
| 13 |
+
npx tsc --noEmit
|
| 14 |
+
|
| 15 |
+
if [ $? -eq 0 ]; then
|
| 16 |
+
echo "✅ TypeScript compilation successful!"
|
| 17 |
+
|
| 18 |
+
echo "🚀 Starting development server..."
|
| 19 |
+
npm run dev
|
| 20 |
+
else
|
| 21 |
+
echo "❌ TypeScript compilation failed. Please check for errors."
|
| 22 |
+
exit 1
|
| 23 |
+
fi
|
| 24 |
+
else
|
| 25 |
+
echo "❌ Dependency installation failed. Please try again."
|
| 26 |
+
exit 1
|
| 27 |
+
fi
|
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,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"build:dev": "vite build --mode development",
|
| 10 |
+
"lint": "eslint .",
|
| 11 |
+
"preview": "vite preview",
|
| 12 |
+
"test": "vitest",
|
| 13 |
+
"test:run": "vitest run",
|
| 14 |
+
"test:ui": "vitest --ui",
|
| 15 |
+
"test:coverage": "vitest run --coverage",
|
| 16 |
+
"test:e2e": "playwright test",
|
| 17 |
+
"test:e2e:ui": "playwright test --ui",
|
| 18 |
+
"storybook": "storybook dev -p 6006",
|
| 19 |
+
"build-storybook": "storybook build"
|
| 20 |
+
},
|
| 21 |
+
"dependencies": {
|
| 22 |
+
"@hookform/resolvers": "^3.10.0",
|
| 23 |
+
"@radix-ui/react-accordion": "^1.2.11",
|
| 24 |
+
"@radix-ui/react-alert-dialog": "^1.1.14",
|
| 25 |
+
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
| 26 |
+
"@radix-ui/react-avatar": "^1.1.10",
|
| 27 |
+
"@radix-ui/react-checkbox": "^1.3.2",
|
| 28 |
+
"@radix-ui/react-collapsible": "^1.1.11",
|
| 29 |
+
"@radix-ui/react-context-menu": "^2.2.15",
|
| 30 |
+
"@radix-ui/react-dialog": "^1.1.14",
|
| 31 |
+
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
| 32 |
+
"@radix-ui/react-hover-card": "^1.1.14",
|
| 33 |
+
"@radix-ui/react-label": "^2.1.7",
|
| 34 |
+
"@radix-ui/react-menubar": "^1.1.15",
|
| 35 |
+
"@radix-ui/react-navigation-menu": "^1.2.13",
|
| 36 |
+
"@radix-ui/react-popover": "^1.1.14",
|
| 37 |
+
"@radix-ui/react-progress": "^1.1.7",
|
| 38 |
+
"@radix-ui/react-radio-group": "^1.3.7",
|
| 39 |
+
"@radix-ui/react-scroll-area": "^1.2.9",
|
| 40 |
+
"@radix-ui/react-select": "^2.2.5",
|
| 41 |
+
"@radix-ui/react-separator": "^1.1.7",
|
| 42 |
+
"@radix-ui/react-slider": "^1.3.5",
|
| 43 |
+
"@radix-ui/react-slot": "^1.2.3",
|
| 44 |
+
"@radix-ui/react-switch": "^1.2.5",
|
| 45 |
+
"@radix-ui/react-tabs": "^1.1.12",
|
| 46 |
+
"@radix-ui/react-toast": "^1.2.14",
|
| 47 |
+
"@radix-ui/react-toggle": "^1.1.9",
|
| 48 |
+
"@radix-ui/react-toggle-group": "^1.1.10",
|
| 49 |
+
"@radix-ui/react-tooltip": "^1.2.7",
|
| 50 |
+
"@tanstack/react-query": "^5.83.0",
|
| 51 |
+
"class-variance-authority": "^0.7.1",
|
| 52 |
+
"clsx": "^2.1.1",
|
| 53 |
+
"cmdk": "^1.1.1",
|
| 54 |
+
"date-fns": "^3.6.0",
|
| 55 |
+
"embla-carousel-react": "^8.6.0",
|
| 56 |
+
"input-otp": "^1.4.2",
|
| 57 |
+
"lucide-react": "^0.462.0",
|
| 58 |
+
"nanoid": "^5.1.6",
|
| 59 |
+
"react": "^18.3.1",
|
| 60 |
+
"react-day-picker": "^8.10.1",
|
| 61 |
+
"react-dom": "^18.3.1",
|
| 62 |
+
"react-hook-form": "^7.61.1",
|
| 63 |
+
"react-resizable-panels": "^2.1.9",
|
| 64 |
+
"react-router-dom": "^6.30.1",
|
| 65 |
+
"recharts": "^2.15.4",
|
| 66 |
+
"sonner": "^1.7.4",
|
| 67 |
+
"tailwind-merge": "^2.6.0",
|
| 68 |
+
"tailwindcss-animate": "^1.0.7",
|
| 69 |
+
"vaul": "^0.9.9",
|
| 70 |
+
"zod": "^3.25.76"
|
| 71 |
+
},
|
| 72 |
+
"devDependencies": {
|
| 73 |
+
"@chromatic-com/storybook": "^4.1.3",
|
| 74 |
+
"@eslint/js": "^9.32.0",
|
| 75 |
+
"@playwright/test": "^1.57.0",
|
| 76 |
+
"@storybook/addon-a11y": "^10.1.10",
|
| 77 |
+
"@storybook/addon-docs": "^10.1.10",
|
| 78 |
+
"@storybook/addon-onboarding": "^10.1.10",
|
| 79 |
+
"@storybook/addon-vitest": "^10.1.10",
|
| 80 |
+
"@storybook/react-vite": "^10.1.10",
|
| 81 |
+
"@tailwindcss/typography": "^0.5.16",
|
| 82 |
+
"@testing-library/jest-dom": "^6.9.1",
|
| 83 |
+
"@testing-library/react": "^16.3.1",
|
| 84 |
+
"@testing-library/user-event": "^14.6.1",
|
| 85 |
+
"@types/node": "^22.16.5",
|
| 86 |
+
"@types/react": "^18.3.23",
|
| 87 |
+
"@types/react-dom": "^18.3.7",
|
| 88 |
+
"@vitejs/plugin-react-swc": "^3.11.0",
|
| 89 |
+
"@vitest/browser-playwright": "^4.0.16",
|
| 90 |
+
"@vitest/coverage-v8": "^4.0.16",
|
| 91 |
+
"@vitest/ui": "^4.0.16",
|
| 92 |
+
"autoprefixer": "^10.4.21",
|
| 93 |
+
"eslint": "^9.32.0",
|
| 94 |
+
"eslint-plugin-react-hooks": "^5.2.0",
|
| 95 |
+
"eslint-plugin-react-refresh": "^0.4.20",
|
| 96 |
+
"eslint-plugin-storybook": "^10.1.10",
|
| 97 |
+
"globals": "^15.15.0",
|
| 98 |
+
"jsdom": "^27.3.0",
|
| 99 |
+
"playwright": "^1.57.0",
|
| 100 |
+
"postcss": "^8.5.6",
|
| 101 |
+
"storybook": "^10.1.10",
|
| 102 |
+
"tailwindcss": "^3.4.17",
|
| 103 |
+
"typescript": "^5.8.3",
|
| 104 |
+
"typescript-eslint": "^8.38.0",
|
| 105 |
+
"vite": "^5.4.19",
|
| 106 |
+
"vitest": "^4.0.16"
|
| 107 |
+
}
|
| 108 |
+
}
|
frontend/playwright.config.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, devices } from '@playwright/test'
|
| 2 |
+
|
| 3 |
+
export default defineConfig({
|
| 4 |
+
testDir: './e2e',
|
| 5 |
+
fullyParallel: true,
|
| 6 |
+
forbidOnly: !!process.env.CI,
|
| 7 |
+
retries: process.env.CI ? 2 : 0,
|
| 8 |
+
workers: process.env.CI ? 1 : undefined,
|
| 9 |
+
reporter: 'html',
|
| 10 |
+
|
| 11 |
+
use: {
|
| 12 |
+
baseURL: 'http://localhost:5173',
|
| 13 |
+
trace: 'on-first-retry',
|
| 14 |
+
screenshot: 'only-on-failure',
|
| 15 |
+
},
|
| 16 |
+
|
| 17 |
+
projects: [
|
| 18 |
+
{
|
| 19 |
+
name: 'chromium',
|
| 20 |
+
use: { ...devices['Desktop Chrome'] },
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
name: 'firefox',
|
| 24 |
+
use: { ...devices['Desktop Firefox'] },
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
name: 'webkit',
|
| 28 |
+
use: { ...devices['Desktop Safari'] },
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
name: 'Mobile Chrome',
|
| 32 |
+
use: { ...devices['Pixel 5'] },
|
| 33 |
+
},
|
| 34 |
+
],
|
| 35 |
+
|
| 36 |
+
webServer: {
|
| 37 |
+
command: 'npm run dev',
|
| 38 |
+
url: 'http://localhost:5173',
|
| 39 |
+
reuseExistingServer: !process.env.CI,
|
| 40 |
+
timeout: 120000,
|
| 41 |
+
},
|
| 42 |
+
})
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/public/vite.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#root {
|
| 2 |
+
max-width: 1280px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 2rem;
|
| 5 |
+
text-align: center;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.logo {
|
| 9 |
+
height: 6em;
|
| 10 |
+
padding: 1.5em;
|
| 11 |
+
will-change: filter;
|
| 12 |
+
transition: filter 300ms;
|
| 13 |
+
}
|
| 14 |
+
.logo:hover {
|
| 15 |
+
filter: drop-shadow(0 0 2em #646cffaa);
|
| 16 |
+
}
|
| 17 |
+
.logo.react:hover {
|
| 18 |
+
filter: drop-shadow(0 0 2em #61dafbaa);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@keyframes logo-spin {
|
| 22 |
+
from {
|
| 23 |
+
transform: rotate(0deg);
|
| 24 |
+
}
|
| 25 |
+
to {
|
| 26 |
+
transform: rotate(360deg);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 31 |
+
a:nth-of-type(2) .logo {
|
| 32 |
+
animation: logo-spin infinite 20s linear;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.card {
|
| 37 |
+
padding: 2em;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.read-the-docs {
|
| 41 |
+
color: #888;
|
| 42 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef, useMemo } from "react"
|
| 2 |
+
import { Button } from "@/components/ui/button"
|
| 3 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 4 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 5 |
+
import { Badge } from "@/components/ui/badge"
|
| 6 |
+
import { Toaster } from "@/components/ui/toaster"
|
| 7 |
+
import { Toaster as Sonner } from "@/components/ui/sonner"
|
| 8 |
+
import { TooltipProvider } from "@/components/ui/tooltip"
|
| 9 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
| 10 |
+
import { BrowserRouter, Routes, Route } from "react-router-dom"
|
| 11 |
+
import {
|
| 12 |
+
startAnalysis,
|
| 13 |
+
getWorkflowStatus,
|
| 14 |
+
getWorkflowResult,
|
| 15 |
+
StockResult,
|
| 16 |
+
WorkflowStatus,
|
| 17 |
+
ActivityLogEntry,
|
| 18 |
+
MCPStatus,
|
| 19 |
+
LLMStatus,
|
| 20 |
+
MetricEntry,
|
| 21 |
+
} from "@/lib/api"
|
| 22 |
+
import { AnalysisResponse } from "@/lib/types"
|
| 23 |
+
import {
|
| 24 |
+
TrendingUp,
|
| 25 |
+
TrendingDown,
|
| 26 |
+
Target,
|
| 27 |
+
AlertTriangle,
|
| 28 |
+
CheckCircle,
|
| 29 |
+
XCircle,
|
| 30 |
+
AlertCircle,
|
| 31 |
+
BarChart3,
|
| 32 |
+
RefreshCw,
|
| 33 |
+
Zap,
|
| 34 |
+
Play,
|
| 35 |
+
Copy,
|
| 36 |
+
Download,
|
| 37 |
+
Printer,
|
| 38 |
+
Check,
|
| 39 |
+
Pause,
|
| 40 |
+
X,
|
| 41 |
+
Loader2,
|
| 42 |
+
} from "lucide-react"
|
| 43 |
+
|
| 44 |
+
// Import new components
|
| 45 |
+
import { ProcessFlow } from "@/components/ProcessFlow"
|
| 46 |
+
import { ActivityLog } from "@/components/ActivityLog"
|
| 47 |
+
import { StockSearch } from "@/components/StockSearch"
|
| 48 |
+
import { MetricsPanel } from "@/components/MetricsPanel"
|
| 49 |
+
|
| 50 |
+
const queryClient = new QueryClient()
|
| 51 |
+
|
| 52 |
+
const App = () => (
|
| 53 |
+
<QueryClientProvider client={queryClient}>
|
| 54 |
+
<TooltipProvider>
|
| 55 |
+
<Toaster />
|
| 56 |
+
<Sonner />
|
| 57 |
+
<BrowserRouter>
|
| 58 |
+
<Routes>
|
| 59 |
+
<Route path="/" element={<Index />} />
|
| 60 |
+
<Route path="*" element={<NotFound />} />
|
| 61 |
+
</Routes>
|
| 62 |
+
</BrowserRouter>
|
| 63 |
+
</TooltipProvider>
|
| 64 |
+
</QueryClientProvider>
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
export default App
|
| 68 |
+
|
| 69 |
+
const defaultMCPStatus: MCPStatus = {
|
| 70 |
+
financials: 'idle',
|
| 71 |
+
valuation: 'idle',
|
| 72 |
+
volatility: 'idle',
|
| 73 |
+
macro: 'idle',
|
| 74 |
+
news: 'idle',
|
| 75 |
+
sentiment: 'idle',
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const defaultLLMStatus: LLMStatus = {
|
| 79 |
+
groq: 'idle',
|
| 80 |
+
gemini: 'idle',
|
| 81 |
+
openrouter: 'idle',
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const Index = () => {
|
| 85 |
+
const [selectedStock, setSelectedStock] = useState<StockResult | null>(null)
|
| 86 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 87 |
+
const [showResults, setShowResults] = useState(false)
|
| 88 |
+
const [mainTab, setMainTab] = useState<"flow" | "results">("flow")
|
| 89 |
+
const [analysisResult, setAnalysisResult] = useState<AnalysisResponse | null>(null)
|
| 90 |
+
const [workflowId, setWorkflowId] = useState<string | null>(null)
|
| 91 |
+
|
| 92 |
+
// Workflow tracking
|
| 93 |
+
const [currentStep, setCurrentStep] = useState<string>('idle')
|
| 94 |
+
const [completedSteps, setCompletedSteps] = useState<string[]>([])
|
| 95 |
+
const [mcpStatus, setMcpStatus] = useState<MCPStatus>(defaultMCPStatus)
|
| 96 |
+
const [llmStatus, setLlmStatus] = useState<LLMStatus>(defaultLLMStatus)
|
| 97 |
+
const [activityLog, setActivityLog] = useState<ActivityLogEntry[]>([])
|
| 98 |
+
const [metrics, setMetrics] = useState<MetricEntry[]>([])
|
| 99 |
+
const [revisionCount, setRevisionCount] = useState(0)
|
| 100 |
+
const [score, setScore] = useState(0)
|
| 101 |
+
const [llmProvider, setLlmProvider] = useState<string>('')
|
| 102 |
+
const [cacheHit, setCacheHit] = useState(false)
|
| 103 |
+
const [isSearching, setIsSearching] = useState(false)
|
| 104 |
+
const [isPaused, setIsPaused] = useState(false)
|
| 105 |
+
const [hasError, setHasError] = useState(false)
|
| 106 |
+
const [isAborted, setIsAborted] = useState(false)
|
| 107 |
+
const [abortReason, setAbortReason] = useState<string>('')
|
| 108 |
+
const [userEvents, setUserEvents] = useState<Array<{timestamp: string; message: string}>>([])
|
| 109 |
+
|
| 110 |
+
const [copied, setCopied] = useState(false)
|
| 111 |
+
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
| 112 |
+
|
| 113 |
+
// Helper to add user events to log
|
| 114 |
+
const addUserEvent = (message: string) => {
|
| 115 |
+
setUserEvents(prev => [...prev, { timestamp: new Date().toISOString(), message }])
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Extracted polling logic to avoid duplication
|
| 119 |
+
const startPolling = (workflowIdToUse: string) => {
|
| 120 |
+
if (pollingRef.current) {
|
| 121 |
+
clearInterval(pollingRef.current)
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
pollingRef.current = setInterval(async () => {
|
| 125 |
+
try {
|
| 126 |
+
const status = await getWorkflowStatus(workflowIdToUse)
|
| 127 |
+
setRevisionCount(status.revision_count)
|
| 128 |
+
setScore(status.score)
|
| 129 |
+
setActivityLog(status.activity_log || [])
|
| 130 |
+
setMetrics(status.metrics || [])
|
| 131 |
+
// Merge MCP status - preserve failed/partial states (they persist for session)
|
| 132 |
+
setMcpStatus(prev => {
|
| 133 |
+
const newStatus = status.mcp_status || defaultMCPStatus
|
| 134 |
+
return {
|
| 135 |
+
financials: prev.financials === 'failed' || prev.financials === 'partial' ? prev.financials : newStatus.financials,
|
| 136 |
+
valuation: prev.valuation === 'failed' || prev.valuation === 'partial' ? prev.valuation : newStatus.valuation,
|
| 137 |
+
volatility: prev.volatility === 'failed' || prev.volatility === 'partial' ? prev.volatility : newStatus.volatility,
|
| 138 |
+
macro: prev.macro === 'failed' || prev.macro === 'partial' ? prev.macro : newStatus.macro,
|
| 139 |
+
news: prev.news === 'failed' || prev.news === 'partial' ? prev.news : newStatus.news,
|
| 140 |
+
sentiment: prev.sentiment === 'failed' || prev.sentiment === 'partial' ? prev.sentiment : newStatus.sentiment,
|
| 141 |
+
}
|
| 142 |
+
})
|
| 143 |
+
// Merge LLM status - preserve failed states (they persist for session)
|
| 144 |
+
setLlmStatus(prev => {
|
| 145 |
+
const newStatus = status.llm_status || defaultLLMStatus
|
| 146 |
+
return {
|
| 147 |
+
groq: prev.groq === 'failed' ? prev.groq : newStatus.groq,
|
| 148 |
+
gemini: prev.gemini === 'failed' ? prev.gemini : newStatus.gemini,
|
| 149 |
+
openrouter: prev.openrouter === 'failed' ? prev.openrouter : newStatus.openrouter,
|
| 150 |
+
}
|
| 151 |
+
})
|
| 152 |
+
if (status.provider_used) setLlmProvider(status.provider_used)
|
| 153 |
+
|
| 154 |
+
// Update completed steps - accumulate rather than recalculate to handle loops
|
| 155 |
+
const stepOrder = ['input', 'cache', 'researcher', 'analyzer', 'critic', 'editor', 'output']
|
| 156 |
+
setCompletedSteps(prev => {
|
| 157 |
+
const newCompleted = new Set(prev)
|
| 158 |
+
const currentIdx = stepOrder.indexOf(status.current_step)
|
| 159 |
+
|
| 160 |
+
// Mark all steps before current as completed
|
| 161 |
+
for (let i = 0; i < currentIdx; i++) {
|
| 162 |
+
newCompleted.add(stepOrder[i])
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
// Handle Critic ↔ Editor loop: keep editor completed when looping back to critic
|
| 166 |
+
if (status.current_step === 'critic' && status.revision_count > 0) {
|
| 167 |
+
newCompleted.add('editor')
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
return Array.from(newCompleted)
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
// Only update currentStep for in-progress workflows to prevent output glow flash
|
| 174 |
+
if (status.status !== 'completed') {
|
| 175 |
+
setCurrentStep(status.current_step)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// Set cacheHit flag for ProcessFlow visualization
|
| 179 |
+
if (status.data_source === 'cache') {
|
| 180 |
+
setCacheHit(true)
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
if (status.status === "completed") {
|
| 184 |
+
clearInterval(pollingRef.current!)
|
| 185 |
+
pollingRef.current = null
|
| 186 |
+
|
| 187 |
+
// Check if this was a cache hit
|
| 188 |
+
if (status.data_source === 'cache') {
|
| 189 |
+
// Cache hit flow - only animate cache and output
|
| 190 |
+
setCacheHit(true)
|
| 191 |
+
setCurrentStep('cache')
|
| 192 |
+
setCompletedSteps(['input'])
|
| 193 |
+
|
| 194 |
+
setTimeout(() => {
|
| 195 |
+
setCompletedSteps(['input', 'cache'])
|
| 196 |
+
setCurrentStep('output')
|
| 197 |
+
}, 800)
|
| 198 |
+
|
| 199 |
+
setTimeout(async () => {
|
| 200 |
+
setCompletedSteps(['input', 'cache', 'output'])
|
| 201 |
+
setCurrentStep('completed')
|
| 202 |
+
const result = await getWorkflowResult(workflowIdToUse)
|
| 203 |
+
setAnalysisResult(result)
|
| 204 |
+
setIsLoading(false)
|
| 205 |
+
setShowResults(true)
|
| 206 |
+
setMainTab("results")
|
| 207 |
+
}, 1600)
|
| 208 |
+
return
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// Normal flow - all steps completed
|
| 212 |
+
// Set completed steps BEFORE the async fetch to prevent output from glowing prematurely
|
| 213 |
+
// Only mark 'editor' as completed if revisions actually occurred
|
| 214 |
+
const finalSteps = status.revision_count > 0
|
| 215 |
+
? stepOrder
|
| 216 |
+
: stepOrder.filter(s => s !== 'editor')
|
| 217 |
+
setCompletedSteps(finalSteps)
|
| 218 |
+
setCurrentStep('completed')
|
| 219 |
+
const result = await getWorkflowResult(workflowIdToUse)
|
| 220 |
+
setAnalysisResult(result)
|
| 221 |
+
setIsLoading(false)
|
| 222 |
+
setShowResults(true)
|
| 223 |
+
setMainTab("results")
|
| 224 |
+
} else if (status.status === "aborted") {
|
| 225 |
+
clearInterval(pollingRef.current!)
|
| 226 |
+
pollingRef.current = null
|
| 227 |
+
setIsLoading(false)
|
| 228 |
+
setIsAborted(true)
|
| 229 |
+
setAbortReason(status.error || 'Critical failure - workflow aborted')
|
| 230 |
+
} else if (status.status === "error") {
|
| 231 |
+
clearInterval(pollingRef.current!)
|
| 232 |
+
pollingRef.current = null
|
| 233 |
+
setIsLoading(false)
|
| 234 |
+
setHasError(true)
|
| 235 |
+
}
|
| 236 |
+
} catch (error) {
|
| 237 |
+
console.error("Polling error:", error)
|
| 238 |
+
}
|
| 239 |
+
}, 700)
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// Button state logic
|
| 243 |
+
const buttonState = useMemo(() => {
|
| 244 |
+
if (isAborted) return 'aborted'
|
| 245 |
+
if (hasError) return 'error'
|
| 246 |
+
if (analysisResult && !isLoading) return 'complete'
|
| 247 |
+
if (isPaused) return 'paused'
|
| 248 |
+
if (isLoading) return 'analyzing'
|
| 249 |
+
return 'ready'
|
| 250 |
+
}, [isAborted, hasError, analysisResult, isLoading, isPaused])
|
| 251 |
+
|
| 252 |
+
// Pause handler - stop polling
|
| 253 |
+
const handlePause = () => {
|
| 254 |
+
setIsPaused(true)
|
| 255 |
+
if (pollingRef.current) {
|
| 256 |
+
clearInterval(pollingRef.current)
|
| 257 |
+
pollingRef.current = null
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// Resume handler - restart polling
|
| 262 |
+
const handleResume = () => {
|
| 263 |
+
if (!workflowId) return
|
| 264 |
+
setIsPaused(false)
|
| 265 |
+
startPolling(workflowId)
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Abort handler - cancel workflow
|
| 269 |
+
const handleAbort = () => {
|
| 270 |
+
if (pollingRef.current) {
|
| 271 |
+
clearInterval(pollingRef.current)
|
| 272 |
+
pollingRef.current = null
|
| 273 |
+
}
|
| 274 |
+
setIsLoading(false)
|
| 275 |
+
setIsPaused(false)
|
| 276 |
+
setHasError(false)
|
| 277 |
+
setIsAborted(false)
|
| 278 |
+
setAbortReason('')
|
| 279 |
+
setCurrentStep('idle')
|
| 280 |
+
setCompletedSteps([])
|
| 281 |
+
setAnalysisResult(null)
|
| 282 |
+
setShowResults(false)
|
| 283 |
+
setMcpStatus(defaultMCPStatus)
|
| 284 |
+
setLlmStatus(defaultLLMStatus)
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// Force dark mode
|
| 288 |
+
useEffect(() => {
|
| 289 |
+
document.documentElement.classList.add("dark")
|
| 290 |
+
}, [])
|
| 291 |
+
|
| 292 |
+
// Export functions
|
| 293 |
+
const formatSwotForClipboard = () => {
|
| 294 |
+
if (!analysisResult) return ''
|
| 295 |
+
return `SWOT Analysis: ${analysisResult.company_name}
|
| 296 |
+
Quality Score: ${analysisResult.score}/10
|
| 297 |
+
Revisions: ${analysisResult.revision_count}
|
| 298 |
+
|
| 299 |
+
STRENGTHS:
|
| 300 |
+
${analysisResult.swot_data.strengths.map(s => `- ${s}`).join('\n')}
|
| 301 |
+
|
| 302 |
+
WEAKNESSES:
|
| 303 |
+
${analysisResult.swot_data.weaknesses.map(w => `- ${w}`).join('\n')}
|
| 304 |
+
|
| 305 |
+
OPPORTUNITIES:
|
| 306 |
+
${analysisResult.swot_data.opportunities.map(o => `- ${o}`).join('\n')}
|
| 307 |
+
|
| 308 |
+
THREATS:
|
| 309 |
+
${analysisResult.swot_data.threats.map(t => `- ${t}`).join('\n')}
|
| 310 |
+
|
| 311 |
+
QUALITY EVALUATION:
|
| 312 |
+
${analysisResult.critique}
|
| 313 |
+
|
| 314 |
+
---
|
| 315 |
+
Generated by Instant SWOT Agent`
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
const copyToClipboard = async () => {
|
| 319 |
+
try {
|
| 320 |
+
await navigator.clipboard.writeText(formatSwotForClipboard())
|
| 321 |
+
setCopied(true)
|
| 322 |
+
setTimeout(() => setCopied(false), 2000)
|
| 323 |
+
} catch (err) {
|
| 324 |
+
console.error('Failed to copy:', err)
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
const downloadAsJson = () => {
|
| 329 |
+
if (!analysisResult) return
|
| 330 |
+
const exportData = {
|
| 331 |
+
...analysisResult,
|
| 332 |
+
exported_at: new Date().toISOString()
|
| 333 |
+
}
|
| 334 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
| 335 |
+
const url = URL.createObjectURL(blob)
|
| 336 |
+
const a = document.createElement('a')
|
| 337 |
+
a.href = url
|
| 338 |
+
a.download = `swot-analysis-${analysisResult.company_name.toLowerCase().replace(/\s+/g, '-')}.json`
|
| 339 |
+
document.body.appendChild(a)
|
| 340 |
+
a.click()
|
| 341 |
+
document.body.removeChild(a)
|
| 342 |
+
URL.revokeObjectURL(url)
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
const handleGenerate = async () => {
|
| 346 |
+
if (!selectedStock) return
|
| 347 |
+
|
| 348 |
+
addUserEvent(`Analysis started for ${selectedStock.symbol}`)
|
| 349 |
+
setIsLoading(true)
|
| 350 |
+
setShowResults(false)
|
| 351 |
+
setCurrentStep('input')
|
| 352 |
+
setCompletedSteps([])
|
| 353 |
+
setMcpStatus(defaultMCPStatus)
|
| 354 |
+
setLlmStatus(defaultLLMStatus)
|
| 355 |
+
setActivityLog([])
|
| 356 |
+
setMetrics([])
|
| 357 |
+
setRevisionCount(0)
|
| 358 |
+
setScore(0)
|
| 359 |
+
setCacheHit(false)
|
| 360 |
+
setIsPaused(false)
|
| 361 |
+
setHasError(false)
|
| 362 |
+
setIsAborted(false)
|
| 363 |
+
setAbortReason('')
|
| 364 |
+
setAnalysisResult(null)
|
| 365 |
+
|
| 366 |
+
try {
|
| 367 |
+
const { workflow_id } = await startAnalysis(
|
| 368 |
+
selectedStock.name,
|
| 369 |
+
selectedStock.symbol,
|
| 370 |
+
'Competitive Position'
|
| 371 |
+
)
|
| 372 |
+
setWorkflowId(workflow_id)
|
| 373 |
+
setCompletedSteps(['input'])
|
| 374 |
+
setCurrentStep('cache')
|
| 375 |
+
startPolling(workflow_id)
|
| 376 |
+
|
| 377 |
+
} catch (error) {
|
| 378 |
+
console.error("Error starting analysis:", error)
|
| 379 |
+
setIsLoading(false)
|
| 380 |
+
setHasError(true)
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
useEffect(() => {
|
| 385 |
+
return () => {
|
| 386 |
+
if (pollingRef.current) {
|
| 387 |
+
clearInterval(pollingRef.current)
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
}, [])
|
| 391 |
+
|
| 392 |
+
const getScoreColor = (score: number) => {
|
| 393 |
+
if (score >= 7) return "text-emerald-400"
|
| 394 |
+
if (score >= 5) return "text-yellow-400"
|
| 395 |
+
return "text-red-400"
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
const getScoreBadge = (score: number) => {
|
| 399 |
+
if (score >= 7)
|
| 400 |
+
return { label: "Board-ready", variant: "default" as const, icon: CheckCircle }
|
| 401 |
+
if (score >= 5)
|
| 402 |
+
return { label: "Acceptable", variant: "secondary" as const, icon: AlertCircle }
|
| 403 |
+
return { label: "Needs Review", variant: "destructive" as const, icon: XCircle }
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
const handleStockClear = () => {
|
| 407 |
+
setSelectedStock(null)
|
| 408 |
+
setShowResults(false)
|
| 409 |
+
setAnalysisResult(null)
|
| 410 |
+
setCurrentStep('idle')
|
| 411 |
+
setCompletedSteps([])
|
| 412 |
+
setActivityLog([])
|
| 413 |
+
setMetrics([])
|
| 414 |
+
setUserEvents([])
|
| 415 |
+
setHasError(false)
|
| 416 |
+
setIsAborted(false)
|
| 417 |
+
setAbortReason('')
|
| 418 |
+
setMcpStatus(defaultMCPStatus)
|
| 419 |
+
setLlmStatus(defaultLLMStatus)
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
return (
|
| 423 |
+
<Tabs value={mainTab} onValueChange={(v) => setMainTab(v as "flow" | "results")} className="min-h-screen bg-background">
|
| 424 |
+
{/* Header */}
|
| 425 |
+
<header className="border-b border-border bg-card sticky top-0 z-40">
|
| 426 |
+
<div className="container mx-auto px-4 sm:px-6 py-3">
|
| 427 |
+
<div className="flex items-center gap-3">
|
| 428 |
+
<div className="shrink-0">
|
| 429 |
+
<h1 className="text-lg font-semibold text-foreground">
|
| 430 |
+
Instant SWOT Agent
|
| 431 |
+
</h1>
|
| 432 |
+
<p className="text-xs text-muted-foreground hidden sm:block">
|
| 433 |
+
with self-correcting feedback
|
| 434 |
+
</p>
|
| 435 |
+
</div>
|
| 436 |
+
<div className="flex-1 max-w-xl">
|
| 437 |
+
<StockSearch
|
| 438 |
+
onSelect={(stock) => {
|
| 439 |
+
setSelectedStock(stock)
|
| 440 |
+
addUserEvent(`Selected: ${stock.name} (${stock.symbol})`)
|
| 441 |
+
}}
|
| 442 |
+
selectedStock={selectedStock}
|
| 443 |
+
onClear={handleStockClear}
|
| 444 |
+
disabled={isLoading}
|
| 445 |
+
onSearchChange={setIsSearching}
|
| 446 |
+
/>
|
| 447 |
+
</div>
|
| 448 |
+
{/* Dynamic Submit/Control Buttons */}
|
| 449 |
+
<div className="flex items-center gap-2 shrink-0">
|
| 450 |
+
{buttonState === 'ready' && (
|
| 451 |
+
<Button
|
| 452 |
+
onClick={handleGenerate}
|
| 453 |
+
disabled={!selectedStock}
|
| 454 |
+
className="gap-2"
|
| 455 |
+
>
|
| 456 |
+
<Play className="h-4 w-4" />
|
| 457 |
+
Submit
|
| 458 |
+
</Button>
|
| 459 |
+
)}
|
| 460 |
+
|
| 461 |
+
{buttonState === 'analyzing' && (
|
| 462 |
+
<>
|
| 463 |
+
<Button onClick={handlePause} className="gap-2 btn-amber btn-amber-pulse">
|
| 464 |
+
<Pause className="h-4 w-4" />
|
| 465 |
+
Pause
|
| 466 |
+
</Button>
|
| 467 |
+
<Button variant="destructive" onClick={handleAbort} className="gap-2">
|
| 468 |
+
<X className="h-4 w-4" />
|
| 469 |
+
Abort
|
| 470 |
+
</Button>
|
| 471 |
+
</>
|
| 472 |
+
)}
|
| 473 |
+
|
| 474 |
+
{buttonState === 'paused' && (
|
| 475 |
+
<>
|
| 476 |
+
<Button onClick={handleResume} className="gap-2 btn-amber">
|
| 477 |
+
<Play className="h-4 w-4" />
|
| 478 |
+
Resume
|
| 479 |
+
</Button>
|
| 480 |
+
<Button variant="destructive" onClick={handleAbort} className="gap-2">
|
| 481 |
+
<X className="h-4 w-4" />
|
| 482 |
+
Abort
|
| 483 |
+
</Button>
|
| 484 |
+
</>
|
| 485 |
+
)}
|
| 486 |
+
|
| 487 |
+
{buttonState === 'complete' && (
|
| 488 |
+
<Button className="gap-2 btn-green" disabled>
|
| 489 |
+
<Check className="h-4 w-4" />
|
| 490 |
+
Complete
|
| 491 |
+
</Button>
|
| 492 |
+
)}
|
| 493 |
+
|
| 494 |
+
{buttonState === 'error' && (
|
| 495 |
+
<Button variant="destructive" onClick={handleGenerate} className="gap-2">
|
| 496 |
+
<X className="h-4 w-4" />
|
| 497 |
+
Failed - Retry
|
| 498 |
+
</Button>
|
| 499 |
+
)}
|
| 500 |
+
|
| 501 |
+
{buttonState === 'aborted' && (
|
| 502 |
+
<Button variant="destructive" onClick={handleStockClear} className="gap-2" title={abortReason}>
|
| 503 |
+
<AlertTriangle className="h-4 w-4" />
|
| 504 |
+
Aborted
|
| 505 |
+
</Button>
|
| 506 |
+
)}
|
| 507 |
+
</div>
|
| 508 |
+
</div>
|
| 509 |
+
</div>
|
| 510 |
+
</header>
|
| 511 |
+
|
| 512 |
+
<main className="container mx-auto px-4 sm:px-6 pt-4 pb-6 space-y-6 overflow-visible">
|
| 513 |
+
|
| 514 |
+
{/* Process Flow + Metrics Panel */}
|
| 515 |
+
<div className="flex gap-4">
|
| 516 |
+
<div className="shrink-0">
|
| 517 |
+
<ProcessFlow
|
| 518 |
+
currentStep={currentStep}
|
| 519 |
+
completedSteps={completedSteps}
|
| 520 |
+
mcpStatus={mcpStatus}
|
| 521 |
+
llmStatus={llmStatus}
|
| 522 |
+
llmProvider={llmProvider}
|
| 523 |
+
cacheHit={cacheHit}
|
| 524 |
+
stockSelected={!!selectedStock}
|
| 525 |
+
isSearching={isSearching}
|
| 526 |
+
revisionCount={revisionCount}
|
| 527 |
+
isAborted={isAborted || hasError}
|
| 528 |
+
/>
|
| 529 |
+
</div>
|
| 530 |
+
<div className="flex-1 min-w-0 h-[260px]">
|
| 531 |
+
<MetricsPanel
|
| 532 |
+
metrics={metrics}
|
| 533 |
+
activityLog={activityLog}
|
| 534 |
+
currentStep={currentStep}
|
| 535 |
+
revisionCount={revisionCount}
|
| 536 |
+
score={score}
|
| 537 |
+
isTyping={isSearching}
|
| 538 |
+
userEvents={userEvents}
|
| 539 |
+
/>
|
| 540 |
+
</div>
|
| 541 |
+
</div>
|
| 542 |
+
|
| 543 |
+
{/* Flow Tab - Activity Log during loading */}
|
| 544 |
+
{(isLoading || showResults) && (
|
| 545 |
+
<TabsContent value="flow" className="space-y-6 mt-0">
|
| 546 |
+
{isLoading && <ActivityLog entries={activityLog} />}
|
| 547 |
+
</TabsContent>
|
| 548 |
+
)}
|
| 549 |
+
|
| 550 |
+
{/* Results Tab - SWOT cards + metrics */}
|
| 551 |
+
{(isLoading || showResults) && (
|
| 552 |
+
<TabsContent value="results" className="mt-0">
|
| 553 |
+
{analysisResult && (
|
| 554 |
+
<div className="space-y-6 animate-slide-up">
|
| 555 |
+
{/* Results Header */}
|
| 556 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
| 557 |
+
<div>
|
| 558 |
+
<h2 className="text-2xl font-semibold text-foreground">
|
| 559 |
+
{analysisResult.company_name} ({selectedStock?.symbol})
|
| 560 |
+
</h2>
|
| 561 |
+
<p className="text-sm text-muted-foreground">
|
| 562 |
+
{selectedStock?.exchange}
|
| 563 |
+
</p>
|
| 564 |
+
</div>
|
| 565 |
+
<div className="flex flex-wrap items-center gap-4">
|
| 566 |
+
{/* Metrics */}
|
| 567 |
+
<div className="flex items-center gap-4">
|
| 568 |
+
<div className="text-center px-4 py-2 bg-card rounded-lg border">
|
| 569 |
+
<p className="text-xs text-muted-foreground">Score</p>
|
| 570 |
+
<p className={`text-xl font-bold ${getScoreColor(analysisResult.score)}`}>
|
| 571 |
+
{analysisResult.score}/10
|
| 572 |
+
</p>
|
| 573 |
+
</div>
|
| 574 |
+
<div className="text-center px-4 py-2 bg-card rounded-lg border">
|
| 575 |
+
<p className="text-xs text-muted-foreground">Revisions</p>
|
| 576 |
+
<p className="text-xl font-bold text-foreground">
|
| 577 |
+
{analysisResult.revision_count}
|
| 578 |
+
</p>
|
| 579 |
+
</div>
|
| 580 |
+
</div>
|
| 581 |
+
<Badge variant={getScoreBadge(analysisResult.score).variant} className="gap-1.5">
|
| 582 |
+
{(() => {
|
| 583 |
+
const BadgeIcon = getScoreBadge(analysisResult.score).icon
|
| 584 |
+
return <BadgeIcon className="h-4 w-4" />
|
| 585 |
+
})()}
|
| 586 |
+
{getScoreBadge(analysisResult.score).label}
|
| 587 |
+
</Badge>
|
| 588 |
+
</div>
|
| 589 |
+
</div>
|
| 590 |
+
|
| 591 |
+
{/* Export Buttons */}
|
| 592 |
+
<div className="flex flex-wrap gap-2 print:hidden">
|
| 593 |
+
<Button variant="outline" size="sm" onClick={copyToClipboard} className="gap-1.5">
|
| 594 |
+
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
|
| 595 |
+
{copied ? "Copied!" : "Copy"}
|
| 596 |
+
</Button>
|
| 597 |
+
<Button variant="outline" size="sm" onClick={downloadAsJson} className="gap-1.5">
|
| 598 |
+
<Download className="h-4 w-4" />
|
| 599 |
+
Export JSON
|
| 600 |
+
</Button>
|
| 601 |
+
<Button variant="outline" size="sm" onClick={() => window.print()} className="gap-1.5">
|
| 602 |
+
<Printer className="h-4 w-4" />
|
| 603 |
+
Print
|
| 604 |
+
</Button>
|
| 605 |
+
</div>
|
| 606 |
+
|
| 607 |
+
{/* SWOT Cards */}
|
| 608 |
+
<div className="grid gap-4 md:grid-cols-2">
|
| 609 |
+
{/* Strengths Card */}
|
| 610 |
+
<Card className="border-l-4 border-l-emerald-500">
|
| 611 |
+
<CardHeader className="pb-3">
|
| 612 |
+
<CardTitle className="flex items-center gap-2 text-base text-emerald-500">
|
| 613 |
+
<TrendingUp className="h-5 w-5" />
|
| 614 |
+
Strengths
|
| 615 |
+
</CardTitle>
|
| 616 |
+
</CardHeader>
|
| 617 |
+
<CardContent>
|
| 618 |
+
<ul className="space-y-2">
|
| 619 |
+
{analysisResult.swot_data.strengths.map((item, i) => (
|
| 620 |
+
<li key={i} className="flex gap-2 text-sm text-foreground">
|
| 621 |
+
<CheckCircle className="h-4 w-4 text-emerald-500 shrink-0 mt-0.5" />
|
| 622 |
+
<span>{item}</span>
|
| 623 |
+
</li>
|
| 624 |
+
))}
|
| 625 |
+
</ul>
|
| 626 |
+
</CardContent>
|
| 627 |
+
</Card>
|
| 628 |
+
|
| 629 |
+
{/* Weaknesses Card */}
|
| 630 |
+
<Card className="border-l-4 border-l-red-500">
|
| 631 |
+
<CardHeader className="pb-3">
|
| 632 |
+
<CardTitle className="flex items-center gap-2 text-base text-red-500">
|
| 633 |
+
<TrendingDown className="h-5 w-5" />
|
| 634 |
+
Weaknesses
|
| 635 |
+
</CardTitle>
|
| 636 |
+
</CardHeader>
|
| 637 |
+
<CardContent>
|
| 638 |
+
<ul className="space-y-2">
|
| 639 |
+
{analysisResult.swot_data.weaknesses.map((item, i) => (
|
| 640 |
+
<li key={i} className="flex gap-2 text-sm text-foreground">
|
| 641 |
+
<XCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
| 642 |
+
<span>{item}</span>
|
| 643 |
+
</li>
|
| 644 |
+
))}
|
| 645 |
+
</ul>
|
| 646 |
+
</CardContent>
|
| 647 |
+
</Card>
|
| 648 |
+
|
| 649 |
+
{/* Opportunities Card */}
|
| 650 |
+
<Card className="border-l-4 border-l-blue-500">
|
| 651 |
+
<CardHeader className="pb-3">
|
| 652 |
+
<CardTitle className="flex items-center gap-2 text-base text-blue-500">
|
| 653 |
+
<Target className="h-5 w-5" />
|
| 654 |
+
Opportunities
|
| 655 |
+
</CardTitle>
|
| 656 |
+
</CardHeader>
|
| 657 |
+
<CardContent>
|
| 658 |
+
<ul className="space-y-2">
|
| 659 |
+
{analysisResult.swot_data.opportunities.map((item, i) => (
|
| 660 |
+
<li key={i} className="flex gap-2 text-sm text-foreground">
|
| 661 |
+
<Zap className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
|
| 662 |
+
<span>{item}</span>
|
| 663 |
+
</li>
|
| 664 |
+
))}
|
| 665 |
+
</ul>
|
| 666 |
+
</CardContent>
|
| 667 |
+
</Card>
|
| 668 |
+
|
| 669 |
+
{/* Threats Card */}
|
| 670 |
+
<Card className="border-l-4 border-l-yellow-500">
|
| 671 |
+
<CardHeader className="pb-3">
|
| 672 |
+
<CardTitle className="flex items-center gap-2 text-base text-yellow-500">
|
| 673 |
+
<AlertTriangle className="h-5 w-5" />
|
| 674 |
+
Threats
|
| 675 |
+
</CardTitle>
|
| 676 |
+
</CardHeader>
|
| 677 |
+
<CardContent>
|
| 678 |
+
<ul className="space-y-2">
|
| 679 |
+
{analysisResult.swot_data.threats.map((item, i) => (
|
| 680 |
+
<li key={i} className="flex gap-2 text-sm text-foreground">
|
| 681 |
+
<AlertCircle className="h-4 w-4 text-yellow-500 shrink-0 mt-0.5" />
|
| 682 |
+
<span>{item}</span>
|
| 683 |
+
</li>
|
| 684 |
+
))}
|
| 685 |
+
</ul>
|
| 686 |
+
</CardContent>
|
| 687 |
+
</Card>
|
| 688 |
+
</div>
|
| 689 |
+
|
| 690 |
+
{/* Critic Evaluation */}
|
| 691 |
+
<Card>
|
| 692 |
+
<CardHeader>
|
| 693 |
+
<CardTitle className="text-base flex items-center gap-2">
|
| 694 |
+
<Target className="h-4 w-4" />
|
| 695 |
+
Quality Evaluation
|
| 696 |
+
</CardTitle>
|
| 697 |
+
</CardHeader>
|
| 698 |
+
<CardContent>
|
| 699 |
+
<p className="text-sm text-muted-foreground leading-relaxed">
|
| 700 |
+
{analysisResult.critique}
|
| 701 |
+
</p>
|
| 702 |
+
</CardContent>
|
| 703 |
+
</Card>
|
| 704 |
+
</div>
|
| 705 |
+
)}
|
| 706 |
+
</TabsContent>
|
| 707 |
+
)}
|
| 708 |
+
</main>
|
| 709 |
+
|
| 710 |
+
</Tabs>
|
| 711 |
+
)
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
const NotFound = () => (
|
| 715 |
+
<div className="min-h-screen bg-background flex flex-col items-center justify-center">
|
| 716 |
+
<div className="text-center space-y-4">
|
| 717 |
+
<h1 className="text-4xl font-bold text-foreground">404</h1>
|
| 718 |
+
<p className="text-xl text-muted-foreground">Page Not Found</p>
|
| 719 |
+
<Button onClick={() => window.location.href = '/'}>Go Home</Button>
|
| 720 |
+
</div>
|
| 721 |
+
</div>
|
| 722 |
+
)
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/components/ActivityLog.test.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest'
|
| 2 |
+
import { render, screen } from '@testing-library/react'
|
| 3 |
+
import { ActivityLog } from './ActivityLog'
|
| 4 |
+
import type { ActivityLogEntry } from '@/lib/api'
|
| 5 |
+
|
| 6 |
+
describe('ActivityLog', () => {
|
| 7 |
+
const mockEntries: ActivityLogEntry[] = [
|
| 8 |
+
{
|
| 9 |
+
timestamp: '2024-01-15T10:30:00.000Z',
|
| 10 |
+
step: 'input',
|
| 11 |
+
message: 'User selected Tesla, Inc. (TSLA)',
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
timestamp: '2024-01-15T10:30:01.000Z',
|
| 15 |
+
step: 'researcher',
|
| 16 |
+
message: 'Fetching data from 6 MCP servers',
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
timestamp: '2024-01-15T10:30:05.000Z',
|
| 20 |
+
step: 'analyzer',
|
| 21 |
+
message: 'Synthesizing SWOT analysis',
|
| 22 |
+
},
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
it('renders the Activity Log header', () => {
|
| 26 |
+
render(<ActivityLog entries={[]} />)
|
| 27 |
+
expect(screen.getByText('Activity Log')).toBeInTheDocument()
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
it('shows empty state when no entries', () => {
|
| 31 |
+
render(<ActivityLog entries={[]} />)
|
| 32 |
+
expect(screen.getByText(/No activity yet/i)).toBeInTheDocument()
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
it('renders log entries', () => {
|
| 36 |
+
render(<ActivityLog entries={mockEntries} />)
|
| 37 |
+
|
| 38 |
+
expect(screen.getByText('User selected Tesla, Inc. (TSLA)')).toBeInTheDocument()
|
| 39 |
+
expect(screen.getByText('Fetching data from 6 MCP servers')).toBeInTheDocument()
|
| 40 |
+
expect(screen.getByText('Synthesizing SWOT analysis')).toBeInTheDocument()
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
it('displays step labels in brackets', () => {
|
| 44 |
+
render(<ActivityLog entries={mockEntries} />)
|
| 45 |
+
|
| 46 |
+
expect(screen.getByText('[input]')).toBeInTheDocument()
|
| 47 |
+
expect(screen.getByText('[researcher]')).toBeInTheDocument()
|
| 48 |
+
expect(screen.getByText('[analyzer]')).toBeInTheDocument()
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
it('formats timestamps in local time', () => {
|
| 52 |
+
render(<ActivityLog entries={mockEntries} />)
|
| 53 |
+
|
| 54 |
+
// Timestamps should be formatted (exact format depends on locale)
|
| 55 |
+
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}:\d{2}/i)
|
| 56 |
+
expect(timeElements.length).toBeGreaterThan(0)
|
| 57 |
+
})
|
| 58 |
+
})
|
frontend/src/components/ActivityLog.tsx
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from "react"
|
| 2 |
+
import { cn } from "@/lib/utils"
|
| 3 |
+
import {
|
| 4 |
+
User,
|
| 5 |
+
Database,
|
| 6 |
+
Search,
|
| 7 |
+
Brain,
|
| 8 |
+
MessageSquare,
|
| 9 |
+
Edit3,
|
| 10 |
+
FileOutput,
|
| 11 |
+
Server,
|
| 12 |
+
AlertCircle,
|
| 13 |
+
} from "lucide-react"
|
| 14 |
+
import type { ActivityLogEntry } from "@/lib/api"
|
| 15 |
+
|
| 16 |
+
interface ActivityLogProps {
|
| 17 |
+
entries: ActivityLogEntry[]
|
| 18 |
+
className?: string
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const stepIcons: Record<string, React.ElementType> = {
|
| 22 |
+
input: User,
|
| 23 |
+
cache: Database,
|
| 24 |
+
researcher: Search,
|
| 25 |
+
analyzer: Brain,
|
| 26 |
+
critic: MessageSquare,
|
| 27 |
+
editor: Edit3,
|
| 28 |
+
output: FileOutput,
|
| 29 |
+
financials: Server,
|
| 30 |
+
valuation: Server,
|
| 31 |
+
volatility: Server,
|
| 32 |
+
macro: Server,
|
| 33 |
+
news: Server,
|
| 34 |
+
sentiment: Server,
|
| 35 |
+
error: AlertCircle,
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const stepColors: Record<string, string> = {
|
| 39 |
+
input: 'text-blue-400',
|
| 40 |
+
cache: 'text-yellow-400',
|
| 41 |
+
researcher: 'text-purple-400',
|
| 42 |
+
analyzer: 'text-cyan-400',
|
| 43 |
+
critic: 'text-orange-400',
|
| 44 |
+
editor: 'text-pink-400',
|
| 45 |
+
output: 'text-emerald-400',
|
| 46 |
+
financials: 'text-gray-400',
|
| 47 |
+
valuation: 'text-gray-400',
|
| 48 |
+
volatility: 'text-gray-400',
|
| 49 |
+
macro: 'text-gray-400',
|
| 50 |
+
news: 'text-gray-400',
|
| 51 |
+
sentiment: 'text-gray-400',
|
| 52 |
+
error: 'text-red-400',
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function formatTimestamp(isoTimestamp: string): string {
|
| 56 |
+
try {
|
| 57 |
+
const date = new Date(isoTimestamp)
|
| 58 |
+
return date.toLocaleTimeString(undefined, {
|
| 59 |
+
hour: '2-digit',
|
| 60 |
+
minute: '2-digit',
|
| 61 |
+
second: '2-digit',
|
| 62 |
+
hour12: true,
|
| 63 |
+
})
|
| 64 |
+
} catch {
|
| 65 |
+
return '--:--:--'
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function LogEntry({ entry }: { entry: ActivityLogEntry }) {
|
| 70 |
+
const Icon = stepIcons[entry.step.toLowerCase()] || AlertCircle
|
| 71 |
+
const colorClass = stepColors[entry.step.toLowerCase()] || 'text-gray-400'
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<div className="flex items-start gap-3 py-2 px-3 hover:bg-gray-800/50 rounded transition-colors">
|
| 75 |
+
<span className="text-xs text-gray-500 font-mono whitespace-nowrap mt-0.5">
|
| 76 |
+
{formatTimestamp(entry.timestamp)}
|
| 77 |
+
</span>
|
| 78 |
+
<div className={cn('mt-0.5', colorClass)}>
|
| 79 |
+
<Icon className="w-4 h-4" />
|
| 80 |
+
</div>
|
| 81 |
+
<div className="flex-1 min-w-0">
|
| 82 |
+
<span className={cn('text-xs font-medium mr-2', colorClass)}>
|
| 83 |
+
[{entry.step}]
|
| 84 |
+
</span>
|
| 85 |
+
<span className="text-sm text-gray-300">
|
| 86 |
+
{entry.message}
|
| 87 |
+
</span>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export function ActivityLog({ entries, className }: ActivityLogProps) {
|
| 94 |
+
const scrollRef = useRef<HTMLDivElement>(null)
|
| 95 |
+
|
| 96 |
+
// Auto-scroll to bottom when new entries are added
|
| 97 |
+
useEffect(() => {
|
| 98 |
+
if (scrollRef.current) {
|
| 99 |
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
| 100 |
+
}
|
| 101 |
+
}, [entries])
|
| 102 |
+
|
| 103 |
+
return (
|
| 104 |
+
<div className={cn('bg-gray-900/50 border border-gray-800 rounded-xl', className)}>
|
| 105 |
+
<div className="px-4 py-3 border-b border-gray-800">
|
| 106 |
+
<h3 className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
| 107 |
+
Activity Log
|
| 108 |
+
</h3>
|
| 109 |
+
</div>
|
| 110 |
+
<div
|
| 111 |
+
ref={scrollRef}
|
| 112 |
+
className="max-h-64 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-gray-900"
|
| 113 |
+
>
|
| 114 |
+
{entries.length === 0 ? (
|
| 115 |
+
<div className="px-4 py-8 text-center text-sm text-gray-500">
|
| 116 |
+
No activity yet. Start an analysis to see real-time updates.
|
| 117 |
+
</div>
|
| 118 |
+
) : (
|
| 119 |
+
<div className="divide-y divide-gray-800/50">
|
| 120 |
+
{entries.map((entry, index) => (
|
| 121 |
+
<LogEntry key={`${entry.timestamp}-${index}`} entry={entry} />
|
| 122 |
+
))}
|
| 123 |
+
</div>
|
| 124 |
+
)}
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
export default ActivityLog
|
frontend/src/components/MetricsPanel.tsx
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useRef } from "react"
|
| 2 |
+
import { Download } from "lucide-react"
|
| 3 |
+
import type { MetricEntry, ActivityLogEntry } from "@/lib/api"
|
| 4 |
+
|
| 5 |
+
interface UserEvent {
|
| 6 |
+
timestamp: string
|
| 7 |
+
message: string
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
interface MetricsPanelProps {
|
| 11 |
+
metrics: MetricEntry[]
|
| 12 |
+
activityLog: ActivityLogEntry[]
|
| 13 |
+
currentStep: string
|
| 14 |
+
revisionCount: number
|
| 15 |
+
score: number
|
| 16 |
+
isTyping?: boolean
|
| 17 |
+
userEvents?: UserEvent[]
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function formatTime(timestamp: string): string {
|
| 21 |
+
try {
|
| 22 |
+
const date = new Date(timestamp)
|
| 23 |
+
return date.toLocaleTimeString('en-US', {
|
| 24 |
+
hour12: false,
|
| 25 |
+
hour: '2-digit',
|
| 26 |
+
minute: '2-digit',
|
| 27 |
+
second: '2-digit'
|
| 28 |
+
})
|
| 29 |
+
} catch {
|
| 30 |
+
return ''
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function formatValue(value: string | number): string {
|
| 35 |
+
if (typeof value === "number") {
|
| 36 |
+
if (Math.abs(value) >= 1e9) return `$${(value / 1e9).toFixed(1)}B`
|
| 37 |
+
if (Math.abs(value) >= 1e6) return `$${(value / 1e6).toFixed(1)}M`
|
| 38 |
+
if (Math.abs(value) < 100) return value.toFixed(2)
|
| 39 |
+
return value.toLocaleString()
|
| 40 |
+
}
|
| 41 |
+
return String(value)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function getCurrentTime(): string {
|
| 45 |
+
return new Date().toLocaleTimeString('en-US', {
|
| 46 |
+
hour12: false,
|
| 47 |
+
hour: '2-digit',
|
| 48 |
+
minute: '2-digit',
|
| 49 |
+
second: '2-digit'
|
| 50 |
+
})
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function MetricsPanel({
|
| 54 |
+
metrics,
|
| 55 |
+
activityLog,
|
| 56 |
+
currentStep,
|
| 57 |
+
revisionCount,
|
| 58 |
+
score,
|
| 59 |
+
isTyping = false,
|
| 60 |
+
userEvents = [],
|
| 61 |
+
}: MetricsPanelProps) {
|
| 62 |
+
const logRef = useRef<HTMLDivElement>(null)
|
| 63 |
+
|
| 64 |
+
// Auto-scroll to bottom when new entries arrive
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
if (logRef.current) {
|
| 67 |
+
logRef.current.scrollTop = logRef.current.scrollHeight
|
| 68 |
+
}
|
| 69 |
+
}, [metrics, activityLog, isTyping, userEvents])
|
| 70 |
+
|
| 71 |
+
// Combine and sort all log entries
|
| 72 |
+
const allEntries = React.useMemo(() => {
|
| 73 |
+
const entries: Array<{ time: string; text: string; type: 'metric' | 'activity' | 'user' }> = []
|
| 74 |
+
|
| 75 |
+
// Add user events first
|
| 76 |
+
for (const e of userEvents) {
|
| 77 |
+
entries.push({
|
| 78 |
+
time: formatTime(e.timestamp),
|
| 79 |
+
text: `[user] ${e.message}`,
|
| 80 |
+
type: 'user'
|
| 81 |
+
})
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Add metrics
|
| 85 |
+
for (const m of metrics) {
|
| 86 |
+
entries.push({
|
| 87 |
+
time: formatTime(m.timestamp),
|
| 88 |
+
text: `[${m.source}] ${m.metric}: ${formatValue(m.value)}`,
|
| 89 |
+
type: 'metric'
|
| 90 |
+
})
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Add activity log
|
| 94 |
+
for (const a of activityLog) {
|
| 95 |
+
entries.push({
|
| 96 |
+
time: formatTime(a.timestamp),
|
| 97 |
+
text: `[${a.step}] ${a.message}`,
|
| 98 |
+
type: 'activity'
|
| 99 |
+
})
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Sort by timestamp
|
| 103 |
+
return entries.sort((a, b) => a.time.localeCompare(b.time))
|
| 104 |
+
}, [metrics, activityLog, userEvents])
|
| 105 |
+
|
| 106 |
+
// Download log as text file
|
| 107 |
+
const handleDownload = () => {
|
| 108 |
+
const logContent = allEntries.map(e => `${e.time} ${e.text}`).join('\n')
|
| 109 |
+
const blob = new Blob([logContent], { type: 'text/plain' })
|
| 110 |
+
const url = URL.createObjectURL(blob)
|
| 111 |
+
const a = document.createElement('a')
|
| 112 |
+
a.href = url
|
| 113 |
+
a.download = `analysis-log-${new Date().toISOString().split('T')[0]}.txt`
|
| 114 |
+
document.body.appendChild(a)
|
| 115 |
+
a.click()
|
| 116 |
+
document.body.removeChild(a)
|
| 117 |
+
URL.revokeObjectURL(url)
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return (
|
| 121 |
+
<div className="h-full w-full flex flex-col bg-black/90 rounded border border-zinc-700 font-mono text-[11px]">
|
| 122 |
+
{/* Header */}
|
| 123 |
+
<div className="px-2 py-1 border-b border-zinc-700 text-zinc-400 flex justify-between items-center">
|
| 124 |
+
<span>Log</span>
|
| 125 |
+
<button
|
| 126 |
+
onClick={handleDownload}
|
| 127 |
+
disabled={allEntries.length === 0}
|
| 128 |
+
className="p-1 hover:bg-zinc-700 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 129 |
+
title="Download log"
|
| 130 |
+
>
|
| 131 |
+
<Download className="h-3 w-3" />
|
| 132 |
+
</button>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
{/* Log content */}
|
| 136 |
+
<div
|
| 137 |
+
ref={logRef}
|
| 138 |
+
className="flex-1 overflow-y-auto overflow-x-auto p-2 space-y-0.5"
|
| 139 |
+
>
|
| 140 |
+
{allEntries.length === 0 && !isTyping ? (
|
| 141 |
+
<div className="text-zinc-500">Waiting for input...</div>
|
| 142 |
+
) : (
|
| 143 |
+
<>
|
| 144 |
+
{allEntries.map((entry, i) => (
|
| 145 |
+
<div key={i} className="text-zinc-300 whitespace-nowrap">
|
| 146 |
+
<span className="text-zinc-500">{entry.time}</span>
|
| 147 |
+
{' '}
|
| 148 |
+
<span className={
|
| 149 |
+
entry.type === 'metric' ? 'text-green-400' :
|
| 150 |
+
entry.type === 'user' ? 'text-blue-400' :
|
| 151 |
+
'text-zinc-300'
|
| 152 |
+
}>
|
| 153 |
+
{entry.text}
|
| 154 |
+
</span>
|
| 155 |
+
</div>
|
| 156 |
+
))}
|
| 157 |
+
{/* Live typing indicator */}
|
| 158 |
+
{isTyping && (
|
| 159 |
+
<div className="text-zinc-300 whitespace-nowrap">
|
| 160 |
+
<span className="text-zinc-500">{getCurrentTime()}</span>
|
| 161 |
+
{' '}
|
| 162 |
+
<span className="text-blue-400">[user] Typing...</span>
|
| 163 |
+
</div>
|
| 164 |
+
)}
|
| 165 |
+
</>
|
| 166 |
+
)}
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
{/* Footer */}
|
| 170 |
+
<div className="px-2 py-1 border-t border-zinc-700 text-zinc-500 flex justify-between">
|
| 171 |
+
<span>Step: {currentStep}</span>
|
| 172 |
+
{revisionCount > 0 && <span>Rev #{revisionCount}</span>}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
export default MetricsPanel
|
frontend/src/components/ProcessFlow.tsx
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo } from "react"
|
| 2 |
+
import { cn } from "@/lib/utils"
|
| 3 |
+
import {
|
| 4 |
+
User,
|
| 5 |
+
Database,
|
| 6 |
+
Search,
|
| 7 |
+
Brain,
|
| 8 |
+
MessageSquare,
|
| 9 |
+
Edit3,
|
| 10 |
+
FileOutput,
|
| 11 |
+
Server,
|
| 12 |
+
Loader2,
|
| 13 |
+
Network,
|
| 14 |
+
GitBranch,
|
| 15 |
+
TrendingUp,
|
| 16 |
+
DollarSign,
|
| 17 |
+
BarChart3,
|
| 18 |
+
Globe,
|
| 19 |
+
Newspaper,
|
| 20 |
+
Heart,
|
| 21 |
+
} from "lucide-react"
|
| 22 |
+
import type { MCPStatus, LLMStatus } from "@/lib/api"
|
| 23 |
+
|
| 24 |
+
// === TYPES ===
|
| 25 |
+
|
| 26 |
+
type NodeStatus = 'idle' | 'executing' | 'completed' | 'failed' | 'skipped'
|
| 27 |
+
type CacheState = 'idle' | 'hit' | 'miss' | 'checking'
|
| 28 |
+
|
| 29 |
+
interface ProcessFlowProps {
|
| 30 |
+
currentStep: string
|
| 31 |
+
completedSteps: string[]
|
| 32 |
+
mcpStatus: MCPStatus
|
| 33 |
+
llmStatus?: LLMStatus
|
| 34 |
+
llmProvider?: string
|
| 35 |
+
cacheHit?: boolean
|
| 36 |
+
stockSelected?: boolean
|
| 37 |
+
isSearching?: boolean
|
| 38 |
+
revisionCount?: number
|
| 39 |
+
isAborted?: boolean
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// === CONSTANTS ===
|
| 43 |
+
|
| 44 |
+
const NODE_SIZE = 44
|
| 45 |
+
const ICON_SIZE = 24
|
| 46 |
+
const MCP_SIZE = 36
|
| 47 |
+
const MCP_ICON_SIZE = 20
|
| 48 |
+
const LLM_WIDTH = 64
|
| 49 |
+
const LLM_HEIGHT = 24
|
| 50 |
+
|
| 51 |
+
const GAP = 72
|
| 52 |
+
const CONNECTOR_PAD = 2
|
| 53 |
+
const GROUP_PAD = 4
|
| 54 |
+
|
| 55 |
+
// ADJUSTED VALUES FOR TIGHT FIT
|
| 56 |
+
const ROW_GAP = 68 // Slight reduction to tighten vertical flow
|
| 57 |
+
const ROW1_Y = 48 // Increased for labels above containers
|
| 58 |
+
const ROW2_Y = ROW1_Y + ROW_GAP
|
| 59 |
+
const ROW3_Y = ROW2_Y + ROW_GAP
|
| 60 |
+
// SVG dimensions
|
| 61 |
+
const SVG_HEIGHT = 218 // Exact content height - scales to fill container
|
| 62 |
+
const NODE_COUNT = 7
|
| 63 |
+
const FLOW_WIDTH = GAP * (NODE_COUNT - 1) + NODE_SIZE
|
| 64 |
+
const SVG_WIDTH = FLOW_WIDTH // Match content width exactly
|
| 65 |
+
const FLOW_START_X = NODE_SIZE / 2 // Left-aligned with half-node margin
|
| 66 |
+
|
| 67 |
+
const NODES = {
|
| 68 |
+
input: { x: FLOW_START_X, y: ROW1_Y },
|
| 69 |
+
cache: { x: FLOW_START_X + GAP, y: ROW1_Y },
|
| 70 |
+
a2a: { x: FLOW_START_X + GAP * 2, y: ROW1_Y },
|
| 71 |
+
analyzer: { x: FLOW_START_X + GAP * 3, y: ROW1_Y },
|
| 72 |
+
critic: { x: FLOW_START_X + GAP * 4, y: ROW1_Y },
|
| 73 |
+
editor: { x: FLOW_START_X + GAP * 5, y: ROW1_Y },
|
| 74 |
+
output: { x: FLOW_START_X + GAP * 6, y: ROW1_Y },
|
| 75 |
+
exchange: { x: FLOW_START_X, y: ROW2_Y },
|
| 76 |
+
researcher: { x: FLOW_START_X + GAP * 2, y: ROW3_Y },
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const MCP_START_X = NODES.researcher.x + NODE_SIZE / 2 + 40
|
| 80 |
+
const MCP_GAP = 38
|
| 81 |
+
const MCP_SERVERS = [
|
| 82 |
+
{ id: 'financials', label: 'Financials', icon: TrendingUp, x: MCP_START_X },
|
| 83 |
+
{ id: 'valuation', label: 'Valuation', icon: DollarSign, x: MCP_START_X + MCP_GAP },
|
| 84 |
+
{ id: 'volatility', label: 'Volatility', icon: BarChart3, x: MCP_START_X + MCP_GAP * 2 },
|
| 85 |
+
{ id: 'macro', label: 'Macro', icon: Globe, x: MCP_START_X + MCP_GAP * 3 },
|
| 86 |
+
{ id: 'news', label: 'News', icon: Newspaper, x: MCP_START_X + MCP_GAP * 4 },
|
| 87 |
+
{ id: 'sentiment', label: 'Sentiment', icon: Heart, x: MCP_START_X + MCP_GAP * 5 },
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
const AGENTS_CENTER_X = (NODES.analyzer.x + NODES.editor.x) / 2
|
| 91 |
+
const LLM_GAP = 68 // LLM_WIDTH (64) + 4px spacing
|
| 92 |
+
const LLM_PROVIDERS = [
|
| 93 |
+
{ id: 'groq', name: 'Groq', x: AGENTS_CENTER_X - LLM_GAP },
|
| 94 |
+
{ id: 'gemini', name: 'Gemini', x: AGENTS_CENTER_X },
|
| 95 |
+
{ id: 'openrouter', name: 'OpenRouter', x: AGENTS_CENTER_X + LLM_GAP },
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
const AGENTS_GROUP = {
|
| 99 |
+
x: NODES.analyzer.x - NODE_SIZE / 2 - GROUP_PAD,
|
| 100 |
+
y: ROW1_Y - NODE_SIZE / 2 - GROUP_PAD,
|
| 101 |
+
width: NODES.editor.x - NODES.analyzer.x + NODE_SIZE + GROUP_PAD * 2,
|
| 102 |
+
height: NODE_SIZE + GROUP_PAD * 2,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const LLM_GROUP = {
|
| 106 |
+
x: LLM_PROVIDERS[0].x - LLM_WIDTH / 2 - GROUP_PAD,
|
| 107 |
+
y: ROW2_Y - LLM_HEIGHT / 2 - GROUP_PAD,
|
| 108 |
+
width: LLM_PROVIDERS[2].x - LLM_PROVIDERS[0].x + LLM_WIDTH + GROUP_PAD * 2,
|
| 109 |
+
height: LLM_HEIGHT + GROUP_PAD * 2,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const MCP_GROUP = {
|
| 113 |
+
x: MCP_SERVERS[0].x - MCP_SIZE / 2 - GROUP_PAD,
|
| 114 |
+
y: ROW3_Y - MCP_SIZE / 2 - GROUP_PAD,
|
| 115 |
+
width: MCP_SERVERS[5].x - MCP_SERVERS[0].x + MCP_SIZE + GROUP_PAD * 2,
|
| 116 |
+
height: MCP_SIZE + GROUP_PAD * 2,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// === HELPER FUNCTIONS ===
|
| 120 |
+
|
| 121 |
+
function normalizeStep(step: string): string {
|
| 122 |
+
const lower = step.toLowerCase()
|
| 123 |
+
if (lower === 'completed') return 'output'
|
| 124 |
+
return lower
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function getNodeStatus(
|
| 128 |
+
stepId: string,
|
| 129 |
+
currentStep: string,
|
| 130 |
+
completedSteps: string[],
|
| 131 |
+
cacheHit?: boolean
|
| 132 |
+
): NodeStatus {
|
| 133 |
+
const normalizedCurrent = normalizeStep(currentStep)
|
| 134 |
+
const normalizedCompleted = completedSteps.map(normalizeStep)
|
| 135 |
+
|
| 136 |
+
// On cache hit, intermediate steps stay idle (not completed)
|
| 137 |
+
if (cacheHit && ['researcher', 'analyzer', 'critic', 'editor', 'a2a'].includes(stepId)) {
|
| 138 |
+
return 'idle'
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
if (normalizedCompleted.includes(stepId)) return 'completed'
|
| 142 |
+
if (normalizedCurrent === stepId) return 'executing'
|
| 143 |
+
return 'idle'
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// === SVG SUB-COMPONENTS ===
|
| 147 |
+
|
| 148 |
+
function ArrowMarkers() {
|
| 149 |
+
return (
|
| 150 |
+
<defs>
|
| 151 |
+
{['idle', 'executing', 'completed', 'failed'].map((status) => (
|
| 152 |
+
<React.Fragment key={status}>
|
| 153 |
+
{/* Forward arrow (end) */}
|
| 154 |
+
<marker
|
| 155 |
+
id={`arrow-${status}`}
|
| 156 |
+
markerWidth="5"
|
| 157 |
+
markerHeight="5"
|
| 158 |
+
refX="4"
|
| 159 |
+
refY="2.5"
|
| 160 |
+
orient="auto"
|
| 161 |
+
markerUnits="userSpaceOnUse"
|
| 162 |
+
>
|
| 163 |
+
<path d="M0,0 L0,5 L5,2.5 z" fill={`var(--pf-connector-${status})`} />
|
| 164 |
+
</marker>
|
| 165 |
+
{/* Reverse arrow (start) for bidirectional */}
|
| 166 |
+
<marker
|
| 167 |
+
id={`arrow-start-${status}`}
|
| 168 |
+
markerWidth="5"
|
| 169 |
+
markerHeight="5"
|
| 170 |
+
refX="1"
|
| 171 |
+
refY="2.5"
|
| 172 |
+
orient="auto"
|
| 173 |
+
markerUnits="userSpaceOnUse"
|
| 174 |
+
>
|
| 175 |
+
<path d="M5,0 L5,5 L0,2.5 z" fill={`var(--pf-connector-${status})`} />
|
| 176 |
+
</marker>
|
| 177 |
+
</React.Fragment>
|
| 178 |
+
))}
|
| 179 |
+
</defs>
|
| 180 |
+
)
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function SVGNode({
|
| 184 |
+
x,
|
| 185 |
+
y,
|
| 186 |
+
icon: Icon,
|
| 187 |
+
label,
|
| 188 |
+
label2,
|
| 189 |
+
status,
|
| 190 |
+
isDiamond = false,
|
| 191 |
+
cacheState,
|
| 192 |
+
isAgent = false,
|
| 193 |
+
hasBorder = true,
|
| 194 |
+
labelPosition = 'below',
|
| 195 |
+
flipIcon = false,
|
| 196 |
+
}: {
|
| 197 |
+
x: number
|
| 198 |
+
y: number
|
| 199 |
+
icon: React.ElementType
|
| 200 |
+
label: string
|
| 201 |
+
label2?: string
|
| 202 |
+
status: NodeStatus
|
| 203 |
+
isDiamond?: boolean
|
| 204 |
+
cacheState?: CacheState
|
| 205 |
+
isAgent?: boolean
|
| 206 |
+
hasBorder?: boolean
|
| 207 |
+
labelPosition?: 'above' | 'below'
|
| 208 |
+
flipIcon?: boolean
|
| 209 |
+
}) {
|
| 210 |
+
const isExecuting = status === 'executing' || cacheState === 'checking'
|
| 211 |
+
const opacity = status === 'idle' && !cacheState ? 0.7 : status === 'skipped' ? 0.7 : 1
|
| 212 |
+
const strokeWidth = hasBorder ? 1 : 0
|
| 213 |
+
|
| 214 |
+
// Label positioning
|
| 215 |
+
const labelY = labelPosition === 'above'
|
| 216 |
+
? y - NODE_SIZE / 2 - (label2 ? 16 : 8)
|
| 217 |
+
: y + NODE_SIZE / 2 + 10
|
| 218 |
+
|
| 219 |
+
return (
|
| 220 |
+
<g opacity={opacity} className="transition-opacity duration-300">
|
| 221 |
+
<rect
|
| 222 |
+
x={x - NODE_SIZE / 2}
|
| 223 |
+
y={y - NODE_SIZE / 2}
|
| 224 |
+
width={NODE_SIZE}
|
| 225 |
+
height={NODE_SIZE}
|
| 226 |
+
rx={isDiamond ? 4 : 8}
|
| 227 |
+
strokeWidth={strokeWidth}
|
| 228 |
+
className={cn(
|
| 229 |
+
"pf-node",
|
| 230 |
+
cacheState ? `pf-cache-${cacheState}` : `pf-node-${status}`,
|
| 231 |
+
isAgent && "pf-agent",
|
| 232 |
+
!hasBorder && "pf-no-border",
|
| 233 |
+
isExecuting && "pf-pulse"
|
| 234 |
+
)}
|
| 235 |
+
transform={isDiamond ? `rotate(45 ${x} ${y})` : undefined}
|
| 236 |
+
/>
|
| 237 |
+
<foreignObject
|
| 238 |
+
x={x - ICON_SIZE / 2}
|
| 239 |
+
y={y - ICON_SIZE / 2}
|
| 240 |
+
width={ICON_SIZE}
|
| 241 |
+
height={ICON_SIZE}
|
| 242 |
+
>
|
| 243 |
+
<div className="flex items-center justify-center w-full h-full">
|
| 244 |
+
{isExecuting ? (
|
| 245 |
+
<Loader2 className="w-5 h-5 pf-icon animate-spin" />
|
| 246 |
+
) : (
|
| 247 |
+
<Icon className="w-5 h-5 pf-icon" style={flipIcon ? { transform: 'scaleX(-1)' } : undefined} />
|
| 248 |
+
)}
|
| 249 |
+
</div>
|
| 250 |
+
</foreignObject>
|
| 251 |
+
<text
|
| 252 |
+
x={x}
|
| 253 |
+
y={labelY}
|
| 254 |
+
textAnchor="middle"
|
| 255 |
+
className={cn(
|
| 256 |
+
"font-medium",
|
| 257 |
+
isAgent ? "text-[9px] pf-text-agent" : "text-[8px] pf-text-label"
|
| 258 |
+
)}
|
| 259 |
+
>
|
| 260 |
+
{label}
|
| 261 |
+
{label2 && (
|
| 262 |
+
<tspan x={x} dy="10">{label2}</tspan>
|
| 263 |
+
)}
|
| 264 |
+
</text>
|
| 265 |
+
</g>
|
| 266 |
+
)
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// === MAIN COMPONENT ===
|
| 270 |
+
|
| 271 |
+
export function ProcessFlow({
|
| 272 |
+
currentStep,
|
| 273 |
+
completedSteps,
|
| 274 |
+
mcpStatus,
|
| 275 |
+
llmStatus,
|
| 276 |
+
llmProvider = 'groq',
|
| 277 |
+
cacheHit = false,
|
| 278 |
+
stockSelected = false,
|
| 279 |
+
isSearching = false,
|
| 280 |
+
revisionCount = 0,
|
| 281 |
+
isAborted = false,
|
| 282 |
+
}: ProcessFlowProps) {
|
| 283 |
+
|
| 284 |
+
// Logic derivations - when aborted, stop all executing states
|
| 285 |
+
const inputStatus = stockSelected ? 'completed' : getNodeStatus('input', currentStep, completedSteps, cacheHit)
|
| 286 |
+
const exchangeStatus = stockSelected ? 'completed' : isSearching ? 'executing' : 'idle'
|
| 287 |
+
|
| 288 |
+
// When aborted, freeze agent nodes at their last completed state (no executing)
|
| 289 |
+
const analyzerStatus = isAborted
|
| 290 |
+
? (completedSteps.includes('analyzer') ? 'completed' : 'idle')
|
| 291 |
+
: getNodeStatus('analyzer', currentStep, completedSteps, cacheHit)
|
| 292 |
+
const criticStatus = isAborted
|
| 293 |
+
? (completedSteps.includes('critic') ? 'completed' : 'idle')
|
| 294 |
+
: getNodeStatus('critic', currentStep, completedSteps, cacheHit)
|
| 295 |
+
const editorStatus = isAborted
|
| 296 |
+
? (completedSteps.includes('editor') ? 'completed' : 'idle')
|
| 297 |
+
: getNodeStatus('editor', currentStep, completedSteps, cacheHit)
|
| 298 |
+
const outputStatus = isAborted
|
| 299 |
+
? (completedSteps.includes('output') ? 'completed' : 'idle')
|
| 300 |
+
: getNodeStatus('output', currentStep, completedSteps, cacheHit)
|
| 301 |
+
const researcherStatus = isAborted
|
| 302 |
+
? (completedSteps.includes('researcher') ? 'completed' : 'idle')
|
| 303 |
+
: getNodeStatus('researcher', currentStep, completedSteps, cacheHit)
|
| 304 |
+
const a2aStatus = isAborted
|
| 305 |
+
? (completedSteps.includes('researcher') ? 'completed' : 'idle')
|
| 306 |
+
: (researcherStatus === 'executing' ? 'executing' : researcherStatus === 'completed' ? 'completed' : 'idle')
|
| 307 |
+
|
| 308 |
+
const cacheState: CacheState = useMemo(() => {
|
| 309 |
+
if (currentStep === 'cache') return 'checking'
|
| 310 |
+
if (completedSteps.includes('cache')) return cacheHit ? 'hit' : 'miss'
|
| 311 |
+
return 'idle'
|
| 312 |
+
}, [currentStep, completedSteps, cacheHit])
|
| 313 |
+
|
| 314 |
+
// Completion halo: workflow completed successfully
|
| 315 |
+
// Editor is optional (only runs if score < 7), so we check for essential steps + output
|
| 316 |
+
const allDone = useMemo(() => {
|
| 317 |
+
const normalizedCompleted = completedSteps.map(normalizeStep)
|
| 318 |
+
const essentialSteps = ['input', 'cache', 'researcher', 'analyzer', 'critic', 'output']
|
| 319 |
+
return essentialSteps.every(s => normalizedCompleted.includes(s))
|
| 320 |
+
}, [completedSteps])
|
| 321 |
+
|
| 322 |
+
const conn = (from: NodeStatus | CacheState, to: NodeStatus): NodeStatus => {
|
| 323 |
+
if (from === 'completed' || from === 'miss' || from === 'hit') {
|
| 324 |
+
return to === 'idle' ? 'idle' : to === 'executing' ? 'executing' : 'completed'
|
| 325 |
+
}
|
| 326 |
+
return 'idle'
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
// Positioning helpers
|
| 330 |
+
const nodeRight = (n: { x: number }) => n.x + NODE_SIZE / 2 + CONNECTOR_PAD
|
| 331 |
+
const nodeLeft = (n: { x: number }) => n.x - NODE_SIZE / 2 - CONNECTOR_PAD
|
| 332 |
+
const nodeBottom = (n: { y: number }) => n.y + NODE_SIZE / 2 + CONNECTOR_PAD
|
| 333 |
+
const nodeTop = (n: { y: number }) => n.y - NODE_SIZE / 2 - CONNECTOR_PAD
|
| 334 |
+
// Diamond corners (rotated 45°, half-diagonal = NODE_SIZE * sqrt(2) / 2)
|
| 335 |
+
const diamondLeft = (n: { x: number }) => n.x - NODE_SIZE * Math.sqrt(2) / 2 - CONNECTOR_PAD
|
| 336 |
+
const diamondRight = (n: { x: number }) => n.x + NODE_SIZE * Math.sqrt(2) / 2 + CONNECTOR_PAD
|
| 337 |
+
|
| 338 |
+
return (
|
| 339 |
+
<div className="h-[260px]">
|
| 340 |
+
<div className="h-full">
|
| 341 |
+
<svg viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`} preserveAspectRatio="xMinYMin meet" className="h-full w-auto">
|
| 342 |
+
<ArrowMarkers />
|
| 343 |
+
|
| 344 |
+
{/* Group Backgrounds */}
|
| 345 |
+
<rect {...AGENTS_GROUP} rx={8} fill="none" stroke="var(--pf-group-stroke)" strokeWidth={1} strokeDasharray="4 3" opacity={0.35} />
|
| 346 |
+
<rect {...LLM_GROUP} rx={8} fill="none" stroke="var(--pf-group-stroke)" strokeWidth={1} strokeDasharray="4 3" opacity={0.35} />
|
| 347 |
+
<rect {...MCP_GROUP} rx={8} fill="none" stroke="var(--pf-group-stroke)" strokeWidth={1} strokeDasharray="4 3" opacity={0.35} />
|
| 348 |
+
|
| 349 |
+
{/* Completion Halo - around OUTPUT node when workflow completes successfully */}
|
| 350 |
+
{allDone && !isAborted && (
|
| 351 |
+
<circle
|
| 352 |
+
cx={NODES.output.x}
|
| 353 |
+
cy={NODES.output.y}
|
| 354 |
+
r={NODE_SIZE / 2 + 8}
|
| 355 |
+
className="pf-success-halo"
|
| 356 |
+
/>
|
| 357 |
+
)}
|
| 358 |
+
|
| 359 |
+
{/* Row 1 Rightward Connectors */}
|
| 360 |
+
<line x1={nodeRight(NODES.input)} y1={ROW1_Y} x2={diamondLeft(NODES.cache)} y2={ROW1_Y}
|
| 361 |
+
strokeWidth={1.4} markerEnd={`url(#arrow-${conn(inputStatus, cacheState === 'idle' ? 'idle' : 'completed')})`}
|
| 362 |
+
className={cn("pf-connector", `pf-connector-${conn(inputStatus, cacheState === 'idle' ? 'idle' : 'completed')}`)} />
|
| 363 |
+
<line x1={diamondRight(NODES.cache)} y1={ROW1_Y} x2={nodeLeft(NODES.a2a)} y2={ROW1_Y}
|
| 364 |
+
strokeWidth={1.4} markerEnd={`url(#arrow-${cacheState === 'miss' ? conn('miss', a2aStatus) : 'idle'})`}
|
| 365 |
+
className={cn("pf-connector", `pf-connector-${cacheState === 'miss' ? conn('miss', a2aStatus) : 'idle'}`)} />
|
| 366 |
+
<line x1={nodeRight(NODES.a2a)} y1={ROW1_Y} x2={nodeLeft(NODES.analyzer)} y2={ROW1_Y}
|
| 367 |
+
strokeWidth={1.4} markerEnd={`url(#arrow-${conn(a2aStatus, analyzerStatus)})`}
|
| 368 |
+
className={cn("pf-connector", `pf-connector-${conn(a2aStatus, analyzerStatus)}`)} />
|
| 369 |
+
<line x1={nodeRight(NODES.analyzer)} y1={ROW1_Y} x2={nodeLeft(NODES.critic)} y2={ROW1_Y}
|
| 370 |
+
strokeWidth={1.4} markerEnd={`url(#arrow-${conn(analyzerStatus, criticStatus)})`}
|
| 371 |
+
className={cn("pf-connector", `pf-connector-${conn(analyzerStatus, criticStatus)}`)} />
|
| 372 |
+
{/* Critic → Editor connector - only lights up when editor actually runs */}
|
| 373 |
+
<line x1={nodeRight(NODES.critic)} y1={ROW1_Y} x2={nodeLeft(NODES.editor)} y2={ROW1_Y}
|
| 374 |
+
strokeWidth={1.4} markerEnd={`url(#arrow-${editorStatus === 'executing' || editorStatus === 'completed' ? conn(criticStatus, editorStatus) : 'idle'})`}
|
| 375 |
+
className={cn("pf-connector", `pf-connector-${editorStatus === 'executing' || editorStatus === 'completed' ? conn(criticStatus, editorStatus) : 'idle'}`)} />
|
| 376 |
+
{/* Editor → Critic loop (curved path below) - shows when revision loop is active */}
|
| 377 |
+
<path
|
| 378 |
+
d={`M ${NODES.editor.x} ${nodeBottom(NODES.editor)}
|
| 379 |
+
Q ${NODES.editor.x} ${ROW1_Y + 38} ${(NODES.critic.x + NODES.editor.x) / 2} ${ROW1_Y + 38}
|
| 380 |
+
Q ${NODES.critic.x} ${ROW1_Y + 38} ${NODES.critic.x} ${nodeBottom(NODES.critic)}`}
|
| 381 |
+
fill="none"
|
| 382 |
+
strokeWidth={1.4}
|
| 383 |
+
markerEnd={`url(#arrow-${revisionCount > 0 && (editorStatus === 'completed' || criticStatus === 'executing') ? 'completed' : 'idle'})`}
|
| 384 |
+
className={cn("pf-connector", `pf-connector-${revisionCount > 0 && (editorStatus === 'completed' || criticStatus === 'executing') ? 'completed' : 'idle'}`)}
|
| 385 |
+
/>
|
| 386 |
+
{/* Editor → Output connector - only lights up when editor ran */}
|
| 387 |
+
<line x1={nodeRight(NODES.editor)} y1={ROW1_Y} x2={nodeLeft(NODES.output)} y2={ROW1_Y}
|
| 388 |
+
strokeWidth={1.4} markerEnd={`url(#arrow-${editorStatus === 'completed' ? conn(editorStatus, outputStatus) : 'idle'})`}
|
| 389 |
+
className={cn("pf-connector", `pf-connector-${editorStatus === 'completed' ? conn(editorStatus, outputStatus) : 'idle'}`)} />
|
| 390 |
+
{/* Critic → Output direct path (curved above) - shows when editor is skipped */}
|
| 391 |
+
<path
|
| 392 |
+
d={`M ${nodeRight(NODES.critic)} ${ROW1_Y - 8}
|
| 393 |
+
Q ${(NODES.critic.x + NODES.output.x) / 2} ${ROW1_Y - 28} ${nodeLeft(NODES.output)} ${ROW1_Y - 8}`}
|
| 394 |
+
fill="none"
|
| 395 |
+
strokeWidth={1.4}
|
| 396 |
+
markerEnd={`url(#arrow-${editorStatus === 'idle' && criticStatus === 'completed' ? conn(criticStatus, outputStatus) : 'idle'})`}
|
| 397 |
+
className={cn("pf-connector", `pf-connector-${editorStatus === 'idle' && criticStatus === 'completed' ? conn(criticStatus, outputStatus) : 'idle'}`)}
|
| 398 |
+
/>
|
| 399 |
+
|
| 400 |
+
{/* Researcher ↔ MCP block connector (bidirectional) */}
|
| 401 |
+
<line x1={nodeRight(NODES.researcher)} y1={ROW3_Y} x2={MCP_GROUP.x - 2} y2={ROW3_Y}
|
| 402 |
+
strokeWidth={1.4}
|
| 403 |
+
markerStart={`url(#arrow-start-${researcherStatus === 'executing' || researcherStatus === 'completed' ? 'completed' : 'idle'})`}
|
| 404 |
+
markerEnd={`url(#arrow-${researcherStatus === 'executing' || researcherStatus === 'completed' ? 'completed' : 'idle'})`}
|
| 405 |
+
className={cn("pf-connector", `pf-connector-${researcherStatus === 'executing' || researcherStatus === 'completed' ? 'completed' : 'idle'}`)} />
|
| 406 |
+
|
| 407 |
+
{/* Bidirectional Vertical Connectors */}
|
| 408 |
+
{/* User Input ↔ Exchange */}
|
| 409 |
+
<line x1={NODES.input.x} y1={nodeBottom(NODES.input)} x2={NODES.exchange.x} y2={nodeTop(NODES.exchange)}
|
| 410 |
+
strokeWidth={1.4}
|
| 411 |
+
markerStart={`url(#arrow-start-${conn(exchangeStatus, inputStatus)})`}
|
| 412 |
+
markerEnd={`url(#arrow-${conn(inputStatus, exchangeStatus)})`}
|
| 413 |
+
className={cn("pf-connector", `pf-connector-${inputStatus === 'completed' || exchangeStatus === 'completed' ? 'completed' : 'idle'}`)} />
|
| 414 |
+
|
| 415 |
+
{/* A2A ↔ Researcher */}
|
| 416 |
+
<line x1={NODES.a2a.x} y1={nodeBottom(NODES.a2a)} x2={NODES.researcher.x} y2={nodeTop(NODES.researcher)}
|
| 417 |
+
strokeWidth={1.4}
|
| 418 |
+
markerStart={`url(#arrow-start-${conn(researcherStatus, a2aStatus)})`}
|
| 419 |
+
markerEnd={`url(#arrow-${conn(a2aStatus, researcherStatus)})`}
|
| 420 |
+
className={cn("pf-connector", `pf-connector-${a2aStatus === 'completed' || researcherStatus === 'completed' ? 'completed' : a2aStatus === 'executing' || researcherStatus === 'executing' ? 'executing' : 'idle'}`)} />
|
| 421 |
+
|
| 422 |
+
{/* Agent Group ↔ LLM Group (Orchestration connector) */}
|
| 423 |
+
<line x1={AGENTS_CENTER_X} y1={AGENTS_GROUP.y + AGENTS_GROUP.height + 2} x2={AGENTS_CENTER_X} y2={LLM_GROUP.y - 2}
|
| 424 |
+
markerStart={`url(#arrow-start-${analyzerStatus === 'executing' || criticStatus === 'executing' || editorStatus === 'executing' ? 'executing' : analyzerStatus === 'completed' ? 'completed' : 'idle'})`}
|
| 425 |
+
markerEnd={`url(#arrow-${analyzerStatus === 'executing' || criticStatus === 'executing' || editorStatus === 'executing' ? 'executing' : analyzerStatus === 'completed' ? 'completed' : 'idle'})`}
|
| 426 |
+
className={cn("pf-connector pf-orchestration", `pf-connector-${analyzerStatus === 'executing' || criticStatus === 'executing' || editorStatus === 'executing' ? 'executing' : analyzerStatus === 'completed' ? 'completed' : 'idle'}`)} />
|
| 427 |
+
|
| 428 |
+
{/* Row 1 Nodes - labels above */}
|
| 429 |
+
<SVGNode x={NODES.input.x} y={NODES.input.y} icon={User} label="User Input" status={inputStatus} labelPosition="above" />
|
| 430 |
+
<SVGNode x={NODES.cache.x} y={NODES.cache.y} icon={Database} label="Cache" status={cacheState === 'idle' ? 'idle' : 'completed'} isDiamond cacheState={cacheState} labelPosition="above" />
|
| 431 |
+
<SVGNode x={NODES.a2a.x} y={NODES.a2a.y} icon={Network} label="A2A client" status={a2aStatus} labelPosition="above" />
|
| 432 |
+
<SVGNode x={NODES.analyzer.x} y={NODES.analyzer.y} icon={Brain} label="Analyzer" label2="Agent" status={analyzerStatus} isAgent labelPosition="above" />
|
| 433 |
+
<SVGNode x={NODES.critic.x} y={NODES.critic.y} icon={MessageSquare} label="Critic" label2="Agent" status={criticStatus} isAgent labelPosition="above" />
|
| 434 |
+
<SVGNode x={NODES.editor.x} y={NODES.editor.y} icon={Edit3} label="Editor" label2="Agent" status={editorStatus} isAgent labelPosition="above" />
|
| 435 |
+
<SVGNode x={NODES.output.x} y={NODES.output.y} icon={FileOutput} label="Output" status={outputStatus} labelPosition="above" flipIcon />
|
| 436 |
+
|
| 437 |
+
{/* Row 2 & 3 Nodes - labels below */}
|
| 438 |
+
<SVGNode x={NODES.exchange.x} y={NODES.exchange.y} icon={GitBranch} label="Exchange" label2="Database" status={exchangeStatus} />
|
| 439 |
+
<SVGNode x={NODES.researcher.x} y={NODES.researcher.y} icon={Search} label="Researcher" label2="Agent" status={researcherStatus} isAgent />
|
| 440 |
+
|
| 441 |
+
{/* LLM Providers - with borders */}
|
| 442 |
+
{LLM_PROVIDERS.map((llm) => {
|
| 443 |
+
// Check actual provider status from backend
|
| 444 |
+
const providerStatus = llmStatus?.[llm.id as keyof LLMStatus];
|
| 445 |
+
const isFailed = providerStatus === 'failed';
|
| 446 |
+
const isProviderCompleted = providerStatus === 'completed';
|
| 447 |
+
|
| 448 |
+
// Only show executing if agents are active AND this provider hasn't failed/completed yet
|
| 449 |
+
const agentsActive = analyzerStatus === 'executing' || criticStatus === 'executing' || editorStatus === 'executing';
|
| 450 |
+
const isActive = agentsActive && !isFailed && !isProviderCompleted;
|
| 451 |
+
|
| 452 |
+
// Only the actually used provider shows as completed (from backend llmStatus)
|
| 453 |
+
const status = isFailed ? 'failed' : isProviderCompleted ? 'completed' : isActive ? 'executing' : 'idle';
|
| 454 |
+
return (
|
| 455 |
+
<g key={llm.id}>
|
| 456 |
+
<rect
|
| 457 |
+
x={llm.x - LLM_WIDTH / 2}
|
| 458 |
+
y={ROW2_Y - LLM_HEIGHT / 2}
|
| 459 |
+
width={LLM_WIDTH}
|
| 460 |
+
height={LLM_HEIGHT}
|
| 461 |
+
rx={4}
|
| 462 |
+
strokeWidth={1}
|
| 463 |
+
className={cn("pf-llm", `pf-llm-${status}`, status === 'executing' && "pf-pulse")}
|
| 464 |
+
/>
|
| 465 |
+
<text
|
| 466 |
+
x={llm.x}
|
| 467 |
+
y={ROW2_Y + 4}
|
| 468 |
+
textAnchor="middle"
|
| 469 |
+
className={`text-[9px] font-medium pf-llm-text-${status}`}
|
| 470 |
+
>
|
| 471 |
+
{llm.name}
|
| 472 |
+
</text>
|
| 473 |
+
</g>
|
| 474 |
+
)
|
| 475 |
+
})}
|
| 476 |
+
|
| 477 |
+
{/* MCP Servers */}
|
| 478 |
+
{MCP_SERVERS.map((mcp) => {
|
| 479 |
+
// Check actual MCP status from backend
|
| 480 |
+
const serverStatus = mcpStatus[mcp.id as keyof MCPStatus];
|
| 481 |
+
const isFailed = serverStatus === 'failed';
|
| 482 |
+
const isPartial = serverStatus === 'partial';
|
| 483 |
+
const isServerCompleted = serverStatus === 'completed';
|
| 484 |
+
|
| 485 |
+
// Determine visual status: failed/partial take precedence (persist for session)
|
| 486 |
+
const status = isFailed ? 'failed' :
|
| 487 |
+
isPartial ? 'partial' :
|
| 488 |
+
isServerCompleted ? 'completed' :
|
| 489 |
+
researcherStatus === 'executing' ? 'executing' : 'idle';
|
| 490 |
+
const Icon = mcp.icon;
|
| 491 |
+
return (
|
| 492 |
+
<g key={mcp.id} opacity={status === 'failed' || status === 'partial' ? 0.9 : status === 'executing' ? 1 : status === 'completed' ? 0.85 : 0.6}>
|
| 493 |
+
<rect x={mcp.x - MCP_SIZE / 2} y={ROW3_Y - MCP_SIZE / 2} width={MCP_SIZE} height={MCP_SIZE} rx={4}
|
| 494 |
+
strokeWidth={1}
|
| 495 |
+
className={cn("pf-node pf-node-mcp",
|
| 496 |
+
status === 'failed' ? 'pf-node-failed' :
|
| 497 |
+
status === 'partial' ? 'pf-node-partial' :
|
| 498 |
+
status === 'executing' ? 'pf-node-executing pf-pulse' :
|
| 499 |
+
status === 'completed' ? 'pf-node-completed' : 'pf-node-idle')} />
|
| 500 |
+
<foreignObject
|
| 501 |
+
x={mcp.x - MCP_ICON_SIZE / 2}
|
| 502 |
+
y={ROW3_Y - MCP_ICON_SIZE / 2}
|
| 503 |
+
width={MCP_ICON_SIZE}
|
| 504 |
+
height={MCP_ICON_SIZE}
|
| 505 |
+
>
|
| 506 |
+
<div className="flex items-center justify-center w-full h-full">
|
| 507 |
+
<Icon className={cn("w-4 h-4",
|
| 508 |
+
status === 'failed' ? 'text-red-400' :
|
| 509 |
+
status === 'partial' ? 'text-amber-400' : 'pf-icon')} />
|
| 510 |
+
</div>
|
| 511 |
+
</foreignObject>
|
| 512 |
+
<text x={mcp.x} y={MCP_GROUP.y + MCP_GROUP.height + 12} textAnchor="middle"
|
| 513 |
+
className={cn("text-[8px] font-medium",
|
| 514 |
+
status === 'failed' ? 'fill-red-400' :
|
| 515 |
+
status === 'partial' ? 'fill-amber-400' : 'pf-text-label')}>{mcp.label}</text>
|
| 516 |
+
</g>
|
| 517 |
+
)
|
| 518 |
+
})}
|
| 519 |
+
|
| 520 |
+
{/* MCP Group Label */}
|
| 521 |
+
<text
|
| 522 |
+
x={MCP_GROUP.x + MCP_GROUP.width / 2}
|
| 523 |
+
y={MCP_GROUP.y - 6}
|
| 524 |
+
textAnchor="middle"
|
| 525 |
+
className="text-[9px] font-medium pf-group-label"
|
| 526 |
+
>
|
| 527 |
+
Custom MCP Servers
|
| 528 |
+
</text>
|
| 529 |
+
</svg>
|
| 530 |
+
|
| 531 |
+
</div>
|
| 532 |
+
</div>
|
| 533 |
+
)
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
export default ProcessFlow
|
frontend/src/components/StockSearch.tsx
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef, useCallback } from "react"
|
| 2 |
+
import { cn } from "@/lib/utils"
|
| 3 |
+
import { Input } from "@/components/ui/input"
|
| 4 |
+
import { Search, X, Check, Loader2, ChevronLeft, ChevronRight } from "lucide-react"
|
| 5 |
+
import { searchStocks, StockResult } from "@/lib/api"
|
| 6 |
+
|
| 7 |
+
interface StockSearchProps {
|
| 8 |
+
onSelect: (stock: StockResult) => void
|
| 9 |
+
disabled?: boolean
|
| 10 |
+
selectedStock?: StockResult | null
|
| 11 |
+
onClear?: () => void
|
| 12 |
+
onSearchChange?: (isSearching: boolean) => void
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function StockSearch({
|
| 16 |
+
onSelect,
|
| 17 |
+
disabled = false,
|
| 18 |
+
selectedStock,
|
| 19 |
+
onClear,
|
| 20 |
+
onSearchChange,
|
| 21 |
+
}: StockSearchProps) {
|
| 22 |
+
const [query, setQuery] = useState("")
|
| 23 |
+
const [results, setResults] = useState<StockResult[]>([])
|
| 24 |
+
const [isOpen, setIsOpen] = useState(false)
|
| 25 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 26 |
+
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
| 27 |
+
const inputRef = useRef<HTMLInputElement>(null)
|
| 28 |
+
const listRef = useRef<HTMLDivElement>(null)
|
| 29 |
+
const debounceRef = useRef<NodeJS.Timeout>()
|
| 30 |
+
const nameScrollRef = useRef<HTMLDivElement>(null)
|
| 31 |
+
|
| 32 |
+
const scrollName = (direction: 'left' | 'right') => {
|
| 33 |
+
if (nameScrollRef.current) {
|
| 34 |
+
const scrollAmount = direction === 'left' ? -80 : 80
|
| 35 |
+
nameScrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' })
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Debounced search
|
| 40 |
+
const performSearch = useCallback(async (searchQuery: string) => {
|
| 41 |
+
if (searchQuery.length < 1) {
|
| 42 |
+
setResults([])
|
| 43 |
+
setIsOpen(false)
|
| 44 |
+
return
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
setIsLoading(true)
|
| 48 |
+
try {
|
| 49 |
+
const response = await searchStocks(searchQuery)
|
| 50 |
+
setResults(response.results)
|
| 51 |
+
setIsOpen(response.results.length > 0)
|
| 52 |
+
setHighlightedIndex(0)
|
| 53 |
+
} catch (error) {
|
| 54 |
+
console.error("Stock search error:", error)
|
| 55 |
+
setResults([])
|
| 56 |
+
} finally {
|
| 57 |
+
setIsLoading(false)
|
| 58 |
+
}
|
| 59 |
+
}, [])
|
| 60 |
+
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
if (debounceRef.current) {
|
| 63 |
+
clearTimeout(debounceRef.current)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
debounceRef.current = setTimeout(() => {
|
| 67 |
+
performSearch(query)
|
| 68 |
+
}, 150) // 150ms debounce
|
| 69 |
+
|
| 70 |
+
return () => {
|
| 71 |
+
if (debounceRef.current) {
|
| 72 |
+
clearTimeout(debounceRef.current)
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}, [query, performSearch])
|
| 76 |
+
|
| 77 |
+
// Notify parent when search state changes
|
| 78 |
+
useEffect(() => {
|
| 79 |
+
onSearchChange?.(query.length > 0)
|
| 80 |
+
}, [query, onSearchChange])
|
| 81 |
+
|
| 82 |
+
// Keyboard navigation
|
| 83 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 84 |
+
if (!isOpen) return
|
| 85 |
+
|
| 86 |
+
switch (e.key) {
|
| 87 |
+
case "ArrowDown":
|
| 88 |
+
e.preventDefault()
|
| 89 |
+
setHighlightedIndex((prev) =>
|
| 90 |
+
prev < results.length - 1 ? prev + 1 : prev
|
| 91 |
+
)
|
| 92 |
+
break
|
| 93 |
+
case "ArrowUp":
|
| 94 |
+
e.preventDefault()
|
| 95 |
+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0))
|
| 96 |
+
break
|
| 97 |
+
case "Enter":
|
| 98 |
+
e.preventDefault()
|
| 99 |
+
if (results[highlightedIndex]) {
|
| 100 |
+
handleSelect(results[highlightedIndex])
|
| 101 |
+
}
|
| 102 |
+
break
|
| 103 |
+
case "Escape":
|
| 104 |
+
e.preventDefault()
|
| 105 |
+
setIsOpen(false)
|
| 106 |
+
inputRef.current?.blur()
|
| 107 |
+
break
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const handleSelect = (stock: StockResult) => {
|
| 112 |
+
onSelect(stock)
|
| 113 |
+
setQuery("")
|
| 114 |
+
setIsOpen(false)
|
| 115 |
+
setResults([])
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const handleClear = () => {
|
| 119 |
+
setQuery("")
|
| 120 |
+
setResults([])
|
| 121 |
+
setIsOpen(false)
|
| 122 |
+
onClear?.()
|
| 123 |
+
inputRef.current?.focus()
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Close on click outside
|
| 127 |
+
useEffect(() => {
|
| 128 |
+
const handleClickOutside = (e: MouseEvent) => {
|
| 129 |
+
if (
|
| 130 |
+
listRef.current &&
|
| 131 |
+
!listRef.current.contains(e.target as Node) &&
|
| 132 |
+
inputRef.current &&
|
| 133 |
+
!inputRef.current.contains(e.target as Node)
|
| 134 |
+
) {
|
| 135 |
+
setIsOpen(false)
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
document.addEventListener("mousedown", handleClickOutside)
|
| 140 |
+
return () => document.removeEventListener("mousedown", handleClickOutside)
|
| 141 |
+
}, [])
|
| 142 |
+
|
| 143 |
+
// Show selected stock
|
| 144 |
+
if (selectedStock) {
|
| 145 |
+
return (
|
| 146 |
+
<div className="relative">
|
| 147 |
+
<div className="flex items-center gap-1 px-2 py-2 bg-card border border-border rounded-lg">
|
| 148 |
+
<Check className="w-4 h-4 text-emerald-500 shrink-0" />
|
| 149 |
+
<button
|
| 150 |
+
onClick={() => scrollName('left')}
|
| 151 |
+
className="shrink-0 p-0.5 hover:bg-muted rounded transition-colors"
|
| 152 |
+
>
|
| 153 |
+
<ChevronLeft className="w-3 h-3 text-muted-foreground" />
|
| 154 |
+
</button>
|
| 155 |
+
<div
|
| 156 |
+
ref={nameScrollRef}
|
| 157 |
+
className="flex-1 overflow-x-auto whitespace-nowrap scrollbar-hide"
|
| 158 |
+
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
| 159 |
+
>
|
| 160 |
+
<span className="font-medium text-foreground text-sm">
|
| 161 |
+
{selectedStock.name}
|
| 162 |
+
</span>
|
| 163 |
+
<span className="text-muted-foreground text-sm ml-1">
|
| 164 |
+
({selectedStock.symbol})
|
| 165 |
+
</span>
|
| 166 |
+
</div>
|
| 167 |
+
<button
|
| 168 |
+
onClick={() => scrollName('right')}
|
| 169 |
+
className="shrink-0 p-0.5 hover:bg-muted rounded transition-colors"
|
| 170 |
+
>
|
| 171 |
+
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
| 172 |
+
</button>
|
| 173 |
+
<span className="text-xs text-muted-foreground px-1.5 py-0.5 bg-muted rounded shrink-0">
|
| 174 |
+
{selectedStock.exchange}
|
| 175 |
+
</span>
|
| 176 |
+
{!disabled && (
|
| 177 |
+
<button
|
| 178 |
+
onClick={handleClear}
|
| 179 |
+
className="p-0.5 hover:bg-muted rounded transition-colors shrink-0"
|
| 180 |
+
>
|
| 181 |
+
<X className="w-4 h-4 text-muted-foreground" />
|
| 182 |
+
</button>
|
| 183 |
+
)}
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
)
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
return (
|
| 190 |
+
<div className="relative">
|
| 191 |
+
<div className="relative">
|
| 192 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
| 193 |
+
<Input
|
| 194 |
+
ref={inputRef}
|
| 195 |
+
type="text"
|
| 196 |
+
value={query}
|
| 197 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 198 |
+
onKeyDown={handleKeyDown}
|
| 199 |
+
onFocus={() => query.length > 0 && results.length > 0 && setIsOpen(true)}
|
| 200 |
+
placeholder="Search U.S. listed companies..."
|
| 201 |
+
disabled={disabled}
|
| 202 |
+
className="pl-10 pr-10 bg-background border-input text-foreground focus:border-primary"
|
| 203 |
+
/>
|
| 204 |
+
{isLoading && (
|
| 205 |
+
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
|
| 206 |
+
)}
|
| 207 |
+
{!isLoading && query && (
|
| 208 |
+
<button
|
| 209 |
+
onClick={() => {
|
| 210 |
+
setQuery("")
|
| 211 |
+
setResults([])
|
| 212 |
+
setIsOpen(false)
|
| 213 |
+
}}
|
| 214 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 hover:bg-muted rounded"
|
| 215 |
+
>
|
| 216 |
+
<X className="w-4 h-4 text-muted-foreground" />
|
| 217 |
+
</button>
|
| 218 |
+
)}
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{/* Dropdown */}
|
| 222 |
+
{isOpen && results.length > 0 && (
|
| 223 |
+
<div
|
| 224 |
+
ref={listRef}
|
| 225 |
+
className="absolute z-50 w-full mt-1 bg-card border border-border rounded-lg shadow-xl overflow-hidden"
|
| 226 |
+
>
|
| 227 |
+
<div className="max-h-64 overflow-y-auto">
|
| 228 |
+
{results.map((stock, index) => (
|
| 229 |
+
<button
|
| 230 |
+
key={stock.symbol}
|
| 231 |
+
onClick={() => handleSelect(stock)}
|
| 232 |
+
onMouseEnter={() => setHighlightedIndex(index)}
|
| 233 |
+
className={cn(
|
| 234 |
+
"w-full flex items-center gap-3 px-3 py-2 text-left transition-colors",
|
| 235 |
+
index === highlightedIndex
|
| 236 |
+
? "bg-muted"
|
| 237 |
+
: "hover:bg-muted/50"
|
| 238 |
+
)}
|
| 239 |
+
>
|
| 240 |
+
<span className="font-mono font-medium text-foreground min-w-[60px]">
|
| 241 |
+
{stock.symbol}
|
| 242 |
+
</span>
|
| 243 |
+
<span className="flex-1 text-sm text-muted-foreground truncate">
|
| 244 |
+
{stock.name}
|
| 245 |
+
</span>
|
| 246 |
+
<span className="text-xs text-muted-foreground px-2 py-0.5 bg-muted rounded">
|
| 247 |
+
{stock.exchange}
|
| 248 |
+
</span>
|
| 249 |
+
</button>
|
| 250 |
+
))}
|
| 251 |
+
</div>
|
| 252 |
+
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground">
|
| 253 |
+
↑↓ navigate · Enter select · Esc close
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
)}
|
| 257 |
+
</div>
|
| 258 |
+
)
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
export default StockSearch
|
frontend/src/components/ViewModeToggle.test.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi } from 'vitest'
|
| 2 |
+
import { render, screen, fireEvent } from '@testing-library/react'
|
| 3 |
+
import { ViewModeToggle } from './ViewModeToggle'
|
| 4 |
+
|
| 5 |
+
describe('ViewModeToggle', () => {
|
| 6 |
+
it('renders Executive and Full buttons', () => {
|
| 7 |
+
const onChange = vi.fn()
|
| 8 |
+
render(<ViewModeToggle value="executive" onChange={onChange} />)
|
| 9 |
+
|
| 10 |
+
expect(screen.getByRole('button', { name: 'Executive' })).toBeInTheDocument()
|
| 11 |
+
expect(screen.getByRole('button', { name: 'Full' })).toBeInTheDocument()
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
it('highlights the selected mode', () => {
|
| 15 |
+
const onChange = vi.fn()
|
| 16 |
+
const { rerender } = render(<ViewModeToggle value="executive" onChange={onChange} />)
|
| 17 |
+
|
| 18 |
+
const executiveBtn = screen.getByRole('button', { name: 'Executive' })
|
| 19 |
+
const fullBtn = screen.getByRole('button', { name: 'Full' })
|
| 20 |
+
|
| 21 |
+
expect(executiveBtn).toHaveClass('bg-primary')
|
| 22 |
+
expect(fullBtn).not.toHaveClass('bg-primary')
|
| 23 |
+
|
| 24 |
+
rerender(<ViewModeToggle value="full" onChange={onChange} />)
|
| 25 |
+
|
| 26 |
+
expect(executiveBtn).not.toHaveClass('bg-primary')
|
| 27 |
+
expect(fullBtn).toHaveClass('bg-primary')
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
it('calls onChange when clicking a different mode', () => {
|
| 31 |
+
const onChange = vi.fn()
|
| 32 |
+
render(<ViewModeToggle value="executive" onChange={onChange} />)
|
| 33 |
+
|
| 34 |
+
fireEvent.click(screen.getByRole('button', { name: 'Full' }))
|
| 35 |
+
|
| 36 |
+
expect(onChange).toHaveBeenCalledWith('full')
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
it('calls onChange with executive when clicking Executive', () => {
|
| 40 |
+
const onChange = vi.fn()
|
| 41 |
+
render(<ViewModeToggle value="full" onChange={onChange} />)
|
| 42 |
+
|
| 43 |
+
fireEvent.click(screen.getByRole('button', { name: 'Executive' }))
|
| 44 |
+
|
| 45 |
+
expect(onChange).toHaveBeenCalledWith('executive')
|
| 46 |
+
})
|
| 47 |
+
})
|
frontend/src/components/ViewModeToggle.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cn } from "@/lib/utils"
|
| 2 |
+
|
| 3 |
+
export type ViewMode = 'executive' | 'full'
|
| 4 |
+
|
| 5 |
+
interface ViewModeToggleProps {
|
| 6 |
+
value: ViewMode
|
| 7 |
+
onChange: (mode: ViewMode) => void
|
| 8 |
+
className?: string
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function ViewModeToggle({ value, onChange, className }: ViewModeToggleProps) {
|
| 12 |
+
return (
|
| 13 |
+
<div className={cn(
|
| 14 |
+
"inline-flex items-center rounded-lg bg-gray-800 p-1 text-sm",
|
| 15 |
+
className
|
| 16 |
+
)}>
|
| 17 |
+
<button
|
| 18 |
+
onClick={() => onChange('executive')}
|
| 19 |
+
className={cn(
|
| 20 |
+
"px-3 py-1.5 rounded-md transition-all duration-200 font-medium",
|
| 21 |
+
value === 'executive'
|
| 22 |
+
? "bg-primary text-primary-foreground shadow-sm"
|
| 23 |
+
: "text-gray-400 hover:text-gray-200"
|
| 24 |
+
)}
|
| 25 |
+
>
|
| 26 |
+
Executive
|
| 27 |
+
</button>
|
| 28 |
+
<button
|
| 29 |
+
onClick={() => onChange('full')}
|
| 30 |
+
className={cn(
|
| 31 |
+
"px-3 py-1.5 rounded-md transition-all duration-200 font-medium",
|
| 32 |
+
value === 'full'
|
| 33 |
+
? "bg-primary text-primary-foreground shadow-sm"
|
| 34 |
+
: "text-gray-400 hover:text-gray-200"
|
| 35 |
+
)}
|
| 36 |
+
>
|
| 37 |
+
Full
|
| 38 |
+
</button>
|
| 39 |
+
</div>
|
| 40 |
+
)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export default ViewModeToggle
|
frontend/src/components/ui/accordion.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
| 3 |
+
import { ChevronDown } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
const Accordion = AccordionPrimitive.Root;
|
| 8 |
+
|
| 9 |
+
const AccordionItem = React.forwardRef<
|
| 10 |
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
| 11 |
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
| 12 |
+
>(({ className, ...props }, ref) => (
|
| 13 |
+
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
|
| 14 |
+
));
|
| 15 |
+
AccordionItem.displayName = "AccordionItem";
|
| 16 |
+
|
| 17 |
+
const AccordionTrigger = React.forwardRef<
|
| 18 |
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
| 19 |
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
| 20 |
+
>(({ className, children, ...props }, ref) => (
|
| 21 |
+
<AccordionPrimitive.Header className="flex">
|
| 22 |
+
<AccordionPrimitive.Trigger
|
| 23 |
+
ref={ref}
|
| 24 |
+
className={cn(
|
| 25 |
+
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
| 26 |
+
className,
|
| 27 |
+
)}
|
| 28 |
+
{...props}
|
| 29 |
+
>
|
| 30 |
+
{children}
|
| 31 |
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
| 32 |
+
</AccordionPrimitive.Trigger>
|
| 33 |
+
</AccordionPrimitive.Header>
|
| 34 |
+
));
|
| 35 |
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
| 36 |
+
|
| 37 |
+
const AccordionContent = React.forwardRef<
|
| 38 |
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
| 39 |
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
| 40 |
+
>(({ className, children, ...props }, ref) => (
|
| 41 |
+
<AccordionPrimitive.Content
|
| 42 |
+
ref={ref}
|
| 43 |
+
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
| 44 |
+
{...props}
|
| 45 |
+
>
|
| 46 |
+
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
| 47 |
+
</AccordionPrimitive.Content>
|
| 48 |
+
));
|
| 49 |
+
|
| 50 |
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
| 51 |
+
|
| 52 |
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
frontend/src/components/ui/alert-dialog.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
import { buttonVariants } from "@/components/ui/button";
|
| 6 |
+
|
| 7 |
+
const AlertDialog = AlertDialogPrimitive.Root;
|
| 8 |
+
|
| 9 |
+
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
| 10 |
+
|
| 11 |
+
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
| 12 |
+
|
| 13 |
+
const AlertDialogOverlay = React.forwardRef<
|
| 14 |
+
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
| 15 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
| 16 |
+
>(({ className, ...props }, ref) => (
|
| 17 |
+
<AlertDialogPrimitive.Overlay
|
| 18 |
+
className={cn(
|
| 19 |
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
| 20 |
+
className,
|
| 21 |
+
)}
|
| 22 |
+
{...props}
|
| 23 |
+
ref={ref}
|
| 24 |
+
/>
|
| 25 |
+
));
|
| 26 |
+
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
| 27 |
+
|
| 28 |
+
const AlertDialogContent = React.forwardRef<
|
| 29 |
+
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
| 30 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
| 31 |
+
>(({ className, ...props }, ref) => (
|
| 32 |
+
<AlertDialogPortal>
|
| 33 |
+
<AlertDialogOverlay />
|
| 34 |
+
<AlertDialogPrimitive.Content
|
| 35 |
+
ref={ref}
|
| 36 |
+
className={cn(
|
| 37 |
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
| 38 |
+
className,
|
| 39 |
+
)}
|
| 40 |
+
{...props}
|
| 41 |
+
/>
|
| 42 |
+
</AlertDialogPortal>
|
| 43 |
+
));
|
| 44 |
+
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
| 45 |
+
|
| 46 |
+
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
| 47 |
+
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
| 48 |
+
);
|
| 49 |
+
AlertDialogHeader.displayName = "AlertDialogHeader";
|
| 50 |
+
|
| 51 |
+
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
| 52 |
+
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
| 53 |
+
);
|
| 54 |
+
AlertDialogFooter.displayName = "AlertDialogFooter";
|
| 55 |
+
|
| 56 |
+
const AlertDialogTitle = React.forwardRef<
|
| 57 |
+
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
| 58 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
| 59 |
+
>(({ className, ...props }, ref) => (
|
| 60 |
+
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
| 61 |
+
));
|
| 62 |
+
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
| 63 |
+
|
| 64 |
+
const AlertDialogDescription = React.forwardRef<
|
| 65 |
+
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
| 66 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
| 67 |
+
>(({ className, ...props }, ref) => (
|
| 68 |
+
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
| 69 |
+
));
|
| 70 |
+
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
| 71 |
+
|
| 72 |
+
const AlertDialogAction = React.forwardRef<
|
| 73 |
+
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
| 74 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
| 75 |
+
>(({ className, ...props }, ref) => (
|
| 76 |
+
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
| 77 |
+
));
|
| 78 |
+
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
| 79 |
+
|
| 80 |
+
const AlertDialogCancel = React.forwardRef<
|
| 81 |
+
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
| 82 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
| 83 |
+
>(({ className, ...props }, ref) => (
|
| 84 |
+
<AlertDialogPrimitive.Cancel
|
| 85 |
+
ref={ref}
|
| 86 |
+
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
| 87 |
+
{...props}
|
| 88 |
+
/>
|
| 89 |
+
));
|
| 90 |
+
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
| 91 |
+
|
| 92 |
+
export {
|
| 93 |
+
AlertDialog,
|
| 94 |
+
AlertDialogPortal,
|
| 95 |
+
AlertDialogOverlay,
|
| 96 |
+
AlertDialogTrigger,
|
| 97 |
+
AlertDialogContent,
|
| 98 |
+
AlertDialogHeader,
|
| 99 |
+
AlertDialogFooter,
|
| 100 |
+
AlertDialogTitle,
|
| 101 |
+
AlertDialogDescription,
|
| 102 |
+
AlertDialogAction,
|
| 103 |
+
AlertDialogCancel,
|
| 104 |
+
};
|
frontend/src/components/ui/alert.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
const alertVariants = cva(
|
| 7 |
+
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "bg-background text-foreground",
|
| 12 |
+
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
| 13 |
+
},
|
| 14 |
+
},
|
| 15 |
+
defaultVariants: {
|
| 16 |
+
variant: "default",
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
const Alert = React.forwardRef<
|
| 22 |
+
HTMLDivElement,
|
| 23 |
+
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
| 24 |
+
>(({ className, variant, ...props }, ref) => (
|
| 25 |
+
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
| 26 |
+
));
|
| 27 |
+
Alert.displayName = "Alert";
|
| 28 |
+
|
| 29 |
+
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
| 30 |
+
({ className, ...props }, ref) => (
|
| 31 |
+
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
| 32 |
+
),
|
| 33 |
+
);
|
| 34 |
+
AlertTitle.displayName = "AlertTitle";
|
| 35 |
+
|
| 36 |
+
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
| 37 |
+
({ className, ...props }, ref) => (
|
| 38 |
+
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
| 39 |
+
),
|
| 40 |
+
);
|
| 41 |
+
AlertDescription.displayName = "AlertDescription";
|
| 42 |
+
|
| 43 |
+
export { Alert, AlertTitle, AlertDescription };
|
frontend/src/components/ui/aspect-ratio.tsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
| 2 |
+
|
| 3 |
+
const AspectRatio = AspectRatioPrimitive.Root;
|
| 4 |
+
|
| 5 |
+
export { AspectRatio };
|
frontend/src/components/ui/avatar.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
const Avatar = React.forwardRef<
|
| 7 |
+
React.ElementRef<typeof AvatarPrimitive.Root>,
|
| 8 |
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
| 9 |
+
>(({ className, ...props }, ref) => (
|
| 10 |
+
<AvatarPrimitive.Root
|
| 11 |
+
ref={ref}
|
| 12 |
+
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
));
|
| 16 |
+
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
| 17 |
+
|
| 18 |
+
const AvatarImage = React.forwardRef<
|
| 19 |
+
React.ElementRef<typeof AvatarPrimitive.Image>,
|
| 20 |
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
| 21 |
+
>(({ className, ...props }, ref) => (
|
| 22 |
+
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
|
| 23 |
+
));
|
| 24 |
+
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
| 25 |
+
|
| 26 |
+
const AvatarFallback = React.forwardRef<
|
| 27 |
+
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
| 28 |
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
| 29 |
+
>(({ className, ...props }, ref) => (
|
| 30 |
+
<AvatarPrimitive.Fallback
|
| 31 |
+
ref={ref}
|
| 32 |
+
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
|
| 33 |
+
{...props}
|
| 34 |
+
/>
|
| 35 |
+
));
|
| 36 |
+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
| 37 |
+
|
| 38 |
+
export { Avatar, AvatarImage, AvatarFallback };
|
frontend/src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
const badgeVariants = cva(
|
| 7 |
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
| 12 |
+
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 13 |
+
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
| 14 |
+
outline: "text-foreground",
|
| 15 |
+
},
|
| 16 |
+
},
|
| 17 |
+
defaultVariants: {
|
| 18 |
+
variant: "default",
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
| 24 |
+
|
| 25 |
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
| 26 |
+
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export { Badge, badgeVariants };
|
frontend/src/components/ui/breadcrumb.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot";
|
| 3 |
+
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
const Breadcrumb = React.forwardRef<
|
| 8 |
+
HTMLElement,
|
| 9 |
+
React.ComponentPropsWithoutRef<"nav"> & {
|
| 10 |
+
separator?: React.ReactNode;
|
| 11 |
+
}
|
| 12 |
+
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
| 13 |
+
Breadcrumb.displayName = "Breadcrumb";
|
| 14 |
+
|
| 15 |
+
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
|
| 16 |
+
({ className, ...props }, ref) => (
|
| 17 |
+
<ol
|
| 18 |
+
ref={ref}
|
| 19 |
+
className={cn(
|
| 20 |
+
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
| 21 |
+
className,
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
),
|
| 26 |
+
);
|
| 27 |
+
BreadcrumbList.displayName = "BreadcrumbList";
|
| 28 |
+
|
| 29 |
+
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
|
| 30 |
+
({ className, ...props }, ref) => (
|
| 31 |
+
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
|
| 32 |
+
),
|
| 33 |
+
);
|
| 34 |
+
BreadcrumbItem.displayName = "BreadcrumbItem";
|
| 35 |
+
|
| 36 |
+
const BreadcrumbLink = React.forwardRef<
|
| 37 |
+
HTMLAnchorElement,
|
| 38 |
+
React.ComponentPropsWithoutRef<"a"> & {
|
| 39 |
+
asChild?: boolean;
|
| 40 |
+
}
|
| 41 |
+
>(({ asChild, className, ...props }, ref) => {
|
| 42 |
+
const Comp = asChild ? Slot : "a";
|
| 43 |
+
|
| 44 |
+
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
|
| 45 |
+
});
|
| 46 |
+
BreadcrumbLink.displayName = "BreadcrumbLink";
|
| 47 |
+
|
| 48 |
+
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
|
| 49 |
+
({ className, ...props }, ref) => (
|
| 50 |
+
<span
|
| 51 |
+
ref={ref}
|
| 52 |
+
role="link"
|
| 53 |
+
aria-disabled="true"
|
| 54 |
+
aria-current="page"
|
| 55 |
+
className={cn("font-normal text-foreground", className)}
|
| 56 |
+
{...props}
|
| 57 |
+
/>
|
| 58 |
+
),
|
| 59 |
+
);
|
| 60 |
+
BreadcrumbPage.displayName = "BreadcrumbPage";
|
| 61 |
+
|
| 62 |
+
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
| 63 |
+
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
|
| 64 |
+
{children ?? <ChevronRight />}
|
| 65 |
+
</li>
|
| 66 |
+
);
|
| 67 |
+
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
| 68 |
+
|
| 69 |
+
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
| 70 |
+
<span
|
| 71 |
+
role="presentation"
|
| 72 |
+
aria-hidden="true"
|
| 73 |
+
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
| 74 |
+
{...props}
|
| 75 |
+
>
|
| 76 |
+
<MoreHorizontal className="h-4 w-4" />
|
| 77 |
+
<span className="sr-only">More</span>
|
| 78 |
+
</span>
|
| 79 |
+
);
|
| 80 |
+
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
| 81 |
+
|
| 82 |
+
export {
|
| 83 |
+
Breadcrumb,
|
| 84 |
+
BreadcrumbList,
|
| 85 |
+
BreadcrumbItem,
|
| 86 |
+
BreadcrumbLink,
|
| 87 |
+
BreadcrumbPage,
|
| 88 |
+
BreadcrumbSeparator,
|
| 89 |
+
BreadcrumbEllipsis,
|
| 90 |
+
};
|
frontend/src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot";
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 13 |
+
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
| 14 |
+
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
| 15 |
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 16 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
| 17 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 18 |
+
},
|
| 19 |
+
size: {
|
| 20 |
+
default: "h-10 px-4 py-2",
|
| 21 |
+
sm: "h-9 rounded-md px-3",
|
| 22 |
+
lg: "h-11 rounded-md px-8",
|
| 23 |
+
icon: "h-10 w-10",
|
| 24 |
+
},
|
| 25 |
+
},
|
| 26 |
+
defaultVariants: {
|
| 27 |
+
variant: "default",
|
| 28 |
+
size: "default",
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
export interface ButtonProps
|
| 34 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
| 35 |
+
VariantProps<typeof buttonVariants> {
|
| 36 |
+
asChild?: boolean;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 40 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 41 |
+
const Comp = asChild ? Slot : "button";
|
| 42 |
+
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
| 43 |
+
},
|
| 44 |
+
);
|
| 45 |
+
Button.displayName = "Button";
|
| 46 |
+
|
| 47 |
+
export { Button, buttonVariants };
|
frontend/src/components/ui/calendar.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
| 3 |
+
import { DayPicker } from "react-day-picker";
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
import { buttonVariants } from "@/components/ui/button";
|
| 7 |
+
|
| 8 |
+
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
| 9 |
+
|
| 10 |
+
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
| 11 |
+
return (
|
| 12 |
+
<DayPicker
|
| 13 |
+
showOutsideDays={showOutsideDays}
|
| 14 |
+
className={cn("p-3", className)}
|
| 15 |
+
classNames={{
|
| 16 |
+
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
| 17 |
+
month: "space-y-4",
|
| 18 |
+
caption: "flex justify-center pt-1 relative items-center",
|
| 19 |
+
caption_label: "text-sm font-medium",
|
| 20 |
+
nav: "space-x-1 flex items-center",
|
| 21 |
+
nav_button: cn(
|
| 22 |
+
buttonVariants({ variant: "outline" }),
|
| 23 |
+
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
| 24 |
+
),
|
| 25 |
+
nav_button_previous: "absolute left-1",
|
| 26 |
+
nav_button_next: "absolute right-1",
|
| 27 |
+
table: "w-full border-collapse space-y-1",
|
| 28 |
+
head_row: "flex",
|
| 29 |
+
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
| 30 |
+
row: "flex w-full mt-2",
|
| 31 |
+
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
| 32 |
+
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
|
| 33 |
+
day_range_end: "day-range-end",
|
| 34 |
+
day_selected:
|
| 35 |
+
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
| 36 |
+
day_today: "bg-accent text-accent-foreground",
|
| 37 |
+
day_outside:
|
| 38 |
+
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
| 39 |
+
day_disabled: "text-muted-foreground opacity-50",
|
| 40 |
+
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
|
| 41 |
+
day_hidden: "invisible",
|
| 42 |
+
...classNames,
|
| 43 |
+
}}
|
| 44 |
+
components={{
|
| 45 |
+
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
|
| 46 |
+
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
| 47 |
+
}}
|
| 48 |
+
{...props}
|
| 49 |
+
/>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
Calendar.displayName = "Calendar";
|
| 53 |
+
|
| 54 |
+
export { Calendar };
|
frontend/src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils";
|
| 4 |
+
|
| 5 |
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
| 6 |
+
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
| 7 |
+
));
|
| 8 |
+
Card.displayName = "Card";
|
| 9 |
+
|
| 10 |
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 11 |
+
({ className, ...props }, ref) => (
|
| 12 |
+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
| 13 |
+
),
|
| 14 |
+
);
|
| 15 |
+
CardHeader.displayName = "CardHeader";
|
| 16 |
+
|
| 17 |
+
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
| 18 |
+
({ className, ...props }, ref) => (
|
| 19 |
+
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
| 20 |
+
),
|
| 21 |
+
);
|
| 22 |
+
CardTitle.displayName = "CardTitle";
|
| 23 |
+
|
| 24 |
+
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
| 25 |
+
({ className, ...props }, ref) => (
|
| 26 |
+
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
| 27 |
+
),
|
| 28 |
+
);
|
| 29 |
+
CardDescription.displayName = "CardDescription";
|
| 30 |
+
|
| 31 |
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 32 |
+
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
|
| 33 |
+
);
|
| 34 |
+
CardContent.displayName = "CardContent";
|
| 35 |
+
|
| 36 |
+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 37 |
+
({ className, ...props }, ref) => (
|
| 38 |
+
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
| 39 |
+
),
|
| 40 |
+
);
|
| 41 |
+
CardFooter.displayName = "CardFooter";
|
| 42 |
+
|
| 43 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
frontend/src/components/ui/carousel.tsx
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
|
| 3 |
+
import { ArrowLeft, ArrowRight } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
|
| 8 |
+
type CarouselApi = UseEmblaCarouselType[1];
|
| 9 |
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
| 10 |
+
type CarouselOptions = UseCarouselParameters[0];
|
| 11 |
+
type CarouselPlugin = UseCarouselParameters[1];
|
| 12 |
+
|
| 13 |
+
type CarouselProps = {
|
| 14 |
+
opts?: CarouselOptions;
|
| 15 |
+
plugins?: CarouselPlugin;
|
| 16 |
+
orientation?: "horizontal" | "vertical";
|
| 17 |
+
setApi?: (api: CarouselApi) => void;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
type CarouselContextProps = {
|
| 21 |
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
| 22 |
+
api: ReturnType<typeof useEmblaCarousel>[1];
|
| 23 |
+
scrollPrev: () => void;
|
| 24 |
+
scrollNext: () => void;
|
| 25 |
+
canScrollPrev: boolean;
|
| 26 |
+
canScrollNext: boolean;
|
| 27 |
+
} & CarouselProps;
|
| 28 |
+
|
| 29 |
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
| 30 |
+
|
| 31 |
+
function useCarousel() {
|
| 32 |
+
const context = React.useContext(CarouselContext);
|
| 33 |
+
|
| 34 |
+
if (!context) {
|
| 35 |
+
throw new Error("useCarousel must be used within a <Carousel />");
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return context;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
|
| 42 |
+
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
|
| 43 |
+
const [carouselRef, api] = useEmblaCarousel(
|
| 44 |
+
{
|
| 45 |
+
...opts,
|
| 46 |
+
axis: orientation === "horizontal" ? "x" : "y",
|
| 47 |
+
},
|
| 48 |
+
plugins,
|
| 49 |
+
);
|
| 50 |
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
| 51 |
+
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
| 52 |
+
|
| 53 |
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
| 54 |
+
if (!api) {
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
setCanScrollPrev(api.canScrollPrev());
|
| 59 |
+
setCanScrollNext(api.canScrollNext());
|
| 60 |
+
}, []);
|
| 61 |
+
|
| 62 |
+
const scrollPrev = React.useCallback(() => {
|
| 63 |
+
api?.scrollPrev();
|
| 64 |
+
}, [api]);
|
| 65 |
+
|
| 66 |
+
const scrollNext = React.useCallback(() => {
|
| 67 |
+
api?.scrollNext();
|
| 68 |
+
}, [api]);
|
| 69 |
+
|
| 70 |
+
const handleKeyDown = React.useCallback(
|
| 71 |
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
| 72 |
+
if (event.key === "ArrowLeft") {
|
| 73 |
+
event.preventDefault();
|
| 74 |
+
scrollPrev();
|
| 75 |
+
} else if (event.key === "ArrowRight") {
|
| 76 |
+
event.preventDefault();
|
| 77 |
+
scrollNext();
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
[scrollPrev, scrollNext],
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
React.useEffect(() => {
|
| 84 |
+
if (!api || !setApi) {
|
| 85 |
+
return;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
setApi(api);
|
| 89 |
+
}, [api, setApi]);
|
| 90 |
+
|
| 91 |
+
React.useEffect(() => {
|
| 92 |
+
if (!api) {
|
| 93 |
+
return;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
onSelect(api);
|
| 97 |
+
api.on("reInit", onSelect);
|
| 98 |
+
api.on("select", onSelect);
|
| 99 |
+
|
| 100 |
+
return () => {
|
| 101 |
+
api?.off("select", onSelect);
|
| 102 |
+
};
|
| 103 |
+
}, [api, onSelect]);
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<CarouselContext.Provider
|
| 107 |
+
value={{
|
| 108 |
+
carouselRef,
|
| 109 |
+
api: api,
|
| 110 |
+
opts,
|
| 111 |
+
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
| 112 |
+
scrollPrev,
|
| 113 |
+
scrollNext,
|
| 114 |
+
canScrollPrev,
|
| 115 |
+
canScrollNext,
|
| 116 |
+
}}
|
| 117 |
+
>
|
| 118 |
+
<div
|
| 119 |
+
ref={ref}
|
| 120 |
+
onKeyDownCapture={handleKeyDown}
|
| 121 |
+
className={cn("relative", className)}
|
| 122 |
+
role="region"
|
| 123 |
+
aria-roledescription="carousel"
|
| 124 |
+
{...props}
|
| 125 |
+
>
|
| 126 |
+
{children}
|
| 127 |
+
</div>
|
| 128 |
+
</CarouselContext.Provider>
|
| 129 |
+
);
|
| 130 |
+
},
|
| 131 |
+
);
|
| 132 |
+
Carousel.displayName = "Carousel";
|
| 133 |
+
|
| 134 |
+
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 135 |
+
({ className, ...props }, ref) => {
|
| 136 |
+
const { carouselRef, orientation } = useCarousel();
|
| 137 |
+
|
| 138 |
+
return (
|
| 139 |
+
<div ref={carouselRef} className="overflow-hidden">
|
| 140 |
+
<div
|
| 141 |
+
ref={ref}
|
| 142 |
+
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
|
| 143 |
+
{...props}
|
| 144 |
+
/>
|
| 145 |
+
</div>
|
| 146 |
+
);
|
| 147 |
+
},
|
| 148 |
+
);
|
| 149 |
+
CarouselContent.displayName = "CarouselContent";
|
| 150 |
+
|
| 151 |
+
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 152 |
+
({ className, ...props }, ref) => {
|
| 153 |
+
const { orientation } = useCarousel();
|
| 154 |
+
|
| 155 |
+
return (
|
| 156 |
+
<div
|
| 157 |
+
ref={ref}
|
| 158 |
+
role="group"
|
| 159 |
+
aria-roledescription="slide"
|
| 160 |
+
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
|
| 161 |
+
{...props}
|
| 162 |
+
/>
|
| 163 |
+
);
|
| 164 |
+
},
|
| 165 |
+
);
|
| 166 |
+
CarouselItem.displayName = "CarouselItem";
|
| 167 |
+
|
| 168 |
+
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
| 169 |
+
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
| 170 |
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
| 171 |
+
|
| 172 |
+
return (
|
| 173 |
+
<Button
|
| 174 |
+
ref={ref}
|
| 175 |
+
variant={variant}
|
| 176 |
+
size={size}
|
| 177 |
+
className={cn(
|
| 178 |
+
"absolute h-8 w-8 rounded-full",
|
| 179 |
+
orientation === "horizontal"
|
| 180 |
+
? "-left-12 top-1/2 -translate-y-1/2"
|
| 181 |
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 182 |
+
className,
|
| 183 |
+
)}
|
| 184 |
+
disabled={!canScrollPrev}
|
| 185 |
+
onClick={scrollPrev}
|
| 186 |
+
{...props}
|
| 187 |
+
>
|
| 188 |
+
<ArrowLeft className="h-4 w-4" />
|
| 189 |
+
<span className="sr-only">Previous slide</span>
|
| 190 |
+
</Button>
|
| 191 |
+
);
|
| 192 |
+
},
|
| 193 |
+
);
|
| 194 |
+
CarouselPrevious.displayName = "CarouselPrevious";
|
| 195 |
+
|
| 196 |
+
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
| 197 |
+
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
| 198 |
+
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
| 199 |
+
|
| 200 |
+
return (
|
| 201 |
+
<Button
|
| 202 |
+
ref={ref}
|
| 203 |
+
variant={variant}
|
| 204 |
+
size={size}
|
| 205 |
+
className={cn(
|
| 206 |
+
"absolute h-8 w-8 rounded-full",
|
| 207 |
+
orientation === "horizontal"
|
| 208 |
+
? "-right-12 top-1/2 -translate-y-1/2"
|
| 209 |
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 210 |
+
className,
|
| 211 |
+
)}
|
| 212 |
+
disabled={!canScrollNext}
|
| 213 |
+
onClick={scrollNext}
|
| 214 |
+
{...props}
|
| 215 |
+
>
|
| 216 |
+
<ArrowRight className="h-4 w-4" />
|
| 217 |
+
<span className="sr-only">Next slide</span>
|
| 218 |
+
</Button>
|
| 219 |
+
);
|
| 220 |
+
},
|
| 221 |
+
);
|
| 222 |
+
CarouselNext.displayName = "CarouselNext";
|
| 223 |
+
|
| 224 |
+
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
|
frontend/src/components/ui/chart.tsx
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as RechartsPrimitive from "recharts";
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
// Format: { THEME_NAME: CSS_SELECTOR }
|
| 7 |
+
const THEMES = { light: "", dark: ".dark" } as const;
|
| 8 |
+
|
| 9 |
+
export type ChartConfig = {
|
| 10 |
+
[k in string]: {
|
| 11 |
+
label?: React.ReactNode;
|
| 12 |
+
icon?: React.ComponentType;
|
| 13 |
+
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
type ChartContextProps = {
|
| 17 |
+
config: ChartConfig;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
| 21 |
+
|
| 22 |
+
function useChart() {
|
| 23 |
+
const context = React.useContext(ChartContext);
|
| 24 |
+
|
| 25 |
+
if (!context) {
|
| 26 |
+
throw new Error("useChart must be used within a <ChartContainer />");
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
return context;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const ChartContainer = React.forwardRef<
|
| 33 |
+
HTMLDivElement,
|
| 34 |
+
React.ComponentProps<"div"> & {
|
| 35 |
+
config: ChartConfig;
|
| 36 |
+
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
| 37 |
+
}
|
| 38 |
+
>(({ id, className, children, config, ...props }, ref) => {
|
| 39 |
+
const uniqueId = React.useId();
|
| 40 |
+
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<ChartContext.Provider value={{ config }}>
|
| 44 |
+
<div
|
| 45 |
+
data-chart={chartId}
|
| 46 |
+
ref={ref}
|
| 47 |
+
className={cn(
|
| 48 |
+
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
| 49 |
+
className,
|
| 50 |
+
)}
|
| 51 |
+
{...props}
|
| 52 |
+
>
|
| 53 |
+
<ChartStyle id={chartId} config={config} />
|
| 54 |
+
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
| 55 |
+
</div>
|
| 56 |
+
</ChartContext.Provider>
|
| 57 |
+
);
|
| 58 |
+
});
|
| 59 |
+
ChartContainer.displayName = "Chart";
|
| 60 |
+
|
| 61 |
+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
| 62 |
+
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
|
| 63 |
+
|
| 64 |
+
if (!colorConfig.length) {
|
| 65 |
+
return null;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return (
|
| 69 |
+
<style
|
| 70 |
+
dangerouslySetInnerHTML={{
|
| 71 |
+
__html: Object.entries(THEMES)
|
| 72 |
+
.map(
|
| 73 |
+
([theme, prefix]) => `
|
| 74 |
+
${prefix} [data-chart=${id}] {
|
| 75 |
+
${colorConfig
|
| 76 |
+
.map(([key, itemConfig]) => {
|
| 77 |
+
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
| 78 |
+
return color ? ` --color-${key}: ${color};` : null;
|
| 79 |
+
})
|
| 80 |
+
.join("\n")}
|
| 81 |
+
}
|
| 82 |
+
`,
|
| 83 |
+
)
|
| 84 |
+
.join("\n"),
|
| 85 |
+
}}
|
| 86 |
+
/>
|
| 87 |
+
);
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const ChartTooltip = RechartsPrimitive.Tooltip;
|
| 91 |
+
|
| 92 |
+
const ChartTooltipContent = React.forwardRef<
|
| 93 |
+
HTMLDivElement,
|
| 94 |
+
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
| 95 |
+
React.ComponentProps<"div"> & {
|
| 96 |
+
hideLabel?: boolean;
|
| 97 |
+
hideIndicator?: boolean;
|
| 98 |
+
indicator?: "line" | "dot" | "dashed";
|
| 99 |
+
nameKey?: string;
|
| 100 |
+
labelKey?: string;
|
| 101 |
+
}
|
| 102 |
+
>(
|
| 103 |
+
(
|
| 104 |
+
{
|
| 105 |
+
active,
|
| 106 |
+
payload,
|
| 107 |
+
className,
|
| 108 |
+
indicator = "dot",
|
| 109 |
+
hideLabel = false,
|
| 110 |
+
hideIndicator = false,
|
| 111 |
+
label,
|
| 112 |
+
labelFormatter,
|
| 113 |
+
labelClassName,
|
| 114 |
+
formatter,
|
| 115 |
+
color,
|
| 116 |
+
nameKey,
|
| 117 |
+
labelKey,
|
| 118 |
+
},
|
| 119 |
+
ref,
|
| 120 |
+
) => {
|
| 121 |
+
const { config } = useChart();
|
| 122 |
+
|
| 123 |
+
const tooltipLabel = React.useMemo(() => {
|
| 124 |
+
if (hideLabel || !payload?.length) {
|
| 125 |
+
return null;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const [item] = payload;
|
| 129 |
+
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
| 130 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
| 131 |
+
const value =
|
| 132 |
+
!labelKey && typeof label === "string"
|
| 133 |
+
? config[label as keyof typeof config]?.label || label
|
| 134 |
+
: itemConfig?.label;
|
| 135 |
+
|
| 136 |
+
if (labelFormatter) {
|
| 137 |
+
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (!value) {
|
| 141 |
+
return null;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
| 145 |
+
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
| 146 |
+
|
| 147 |
+
if (!active || !payload?.length) {
|
| 148 |
+
return null;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const nestLabel = payload.length === 1 && indicator !== "dot";
|
| 152 |
+
|
| 153 |
+
return (
|
| 154 |
+
<div
|
| 155 |
+
ref={ref}
|
| 156 |
+
className={cn(
|
| 157 |
+
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
| 158 |
+
className,
|
| 159 |
+
)}
|
| 160 |
+
>
|
| 161 |
+
{!nestLabel ? tooltipLabel : null}
|
| 162 |
+
<div className="grid gap-1.5">
|
| 163 |
+
{payload.map((item, index) => {
|
| 164 |
+
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
| 165 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
| 166 |
+
const indicatorColor = color || item.payload.fill || item.color;
|
| 167 |
+
|
| 168 |
+
return (
|
| 169 |
+
<div
|
| 170 |
+
key={item.dataKey}
|
| 171 |
+
className={cn(
|
| 172 |
+
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
| 173 |
+
indicator === "dot" && "items-center",
|
| 174 |
+
)}
|
| 175 |
+
>
|
| 176 |
+
{formatter && item?.value !== undefined && item.name ? (
|
| 177 |
+
formatter(item.value, item.name, item, index, item.payload)
|
| 178 |
+
) : (
|
| 179 |
+
<>
|
| 180 |
+
{itemConfig?.icon ? (
|
| 181 |
+
<itemConfig.icon />
|
| 182 |
+
) : (
|
| 183 |
+
!hideIndicator && (
|
| 184 |
+
<div
|
| 185 |
+
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
| 186 |
+
"h-2.5 w-2.5": indicator === "dot",
|
| 187 |
+
"w-1": indicator === "line",
|
| 188 |
+
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
| 189 |
+
"my-0.5": nestLabel && indicator === "dashed",
|
| 190 |
+
})}
|
| 191 |
+
style={
|
| 192 |
+
{
|
| 193 |
+
"--color-bg": indicatorColor,
|
| 194 |
+
"--color-border": indicatorColor,
|
| 195 |
+
} as React.CSSProperties
|
| 196 |
+
}
|
| 197 |
+
/>
|
| 198 |
+
)
|
| 199 |
+
)}
|
| 200 |
+
<div
|
| 201 |
+
className={cn(
|
| 202 |
+
"flex flex-1 justify-between leading-none",
|
| 203 |
+
nestLabel ? "items-end" : "items-center",
|
| 204 |
+
)}
|
| 205 |
+
>
|
| 206 |
+
<div className="grid gap-1.5">
|
| 207 |
+
{nestLabel ? tooltipLabel : null}
|
| 208 |
+
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
| 209 |
+
</div>
|
| 210 |
+
{item.value && (
|
| 211 |
+
<span className="font-mono font-medium tabular-nums text-foreground">
|
| 212 |
+
{item.value.toLocaleString()}
|
| 213 |
+
</span>
|
| 214 |
+
)}
|
| 215 |
+
</div>
|
| 216 |
+
</>
|
| 217 |
+
)}
|
| 218 |
+
</div>
|
| 219 |
+
);
|
| 220 |
+
})}
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
);
|
| 224 |
+
},
|
| 225 |
+
);
|
| 226 |
+
ChartTooltipContent.displayName = "ChartTooltip";
|
| 227 |
+
|
| 228 |
+
const ChartLegend = RechartsPrimitive.Legend;
|
| 229 |
+
|
| 230 |
+
const ChartLegendContent = React.forwardRef<
|
| 231 |
+
HTMLDivElement,
|
| 232 |
+
React.ComponentProps<"div"> &
|
| 233 |
+
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
| 234 |
+
hideIcon?: boolean;
|
| 235 |
+
nameKey?: string;
|
| 236 |
+
}
|
| 237 |
+
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
| 238 |
+
const { config } = useChart();
|
| 239 |
+
|
| 240 |
+
if (!payload?.length) {
|
| 241 |
+
return null;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
return (
|
| 245 |
+
<div
|
| 246 |
+
ref={ref}
|
| 247 |
+
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
|
| 248 |
+
>
|
| 249 |
+
{payload.map((item) => {
|
| 250 |
+
const key = `${nameKey || item.dataKey || "value"}`;
|
| 251 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
| 252 |
+
|
| 253 |
+
return (
|
| 254 |
+
<div
|
| 255 |
+
key={item.value}
|
| 256 |
+
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
|
| 257 |
+
>
|
| 258 |
+
{itemConfig?.icon && !hideIcon ? (
|
| 259 |
+
<itemConfig.icon />
|
| 260 |
+
) : (
|
| 261 |
+
<div
|
| 262 |
+
className="h-2 w-2 shrink-0 rounded-[2px]"
|
| 263 |
+
style={{
|
| 264 |
+
backgroundColor: item.color,
|
| 265 |
+
}}
|
| 266 |
+
/>
|
| 267 |
+
)}
|
| 268 |
+
{itemConfig?.label}
|
| 269 |
+
</div>
|
| 270 |
+
);
|
| 271 |
+
})}
|
| 272 |
+
</div>
|
| 273 |
+
);
|
| 274 |
+
});
|
| 275 |
+
ChartLegendContent.displayName = "ChartLegend";
|
| 276 |
+
|
| 277 |
+
// Helper to extract item config from a payload.
|
| 278 |
+
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
| 279 |
+
if (typeof payload !== "object" || payload === null) {
|
| 280 |
+
return undefined;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
const payloadPayload =
|
| 284 |
+
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
| 285 |
+
? payload.payload
|
| 286 |
+
: undefined;
|
| 287 |
+
|
| 288 |
+
let configLabelKey: string = key;
|
| 289 |
+
|
| 290 |
+
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
| 291 |
+
configLabelKey = payload[key as keyof typeof payload] as string;
|
| 292 |
+
} else if (
|
| 293 |
+
payloadPayload &&
|
| 294 |
+
key in payloadPayload &&
|
| 295 |
+
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
| 296 |
+
) {
|
| 297 |
+
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
frontend/src/components/ui/checkbox.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
| 3 |
+
import { Check } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
const Checkbox = React.forwardRef<
|
| 8 |
+
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
| 9 |
+
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
| 10 |
+
>(({ className, ...props }, ref) => (
|
| 11 |
+
<CheckboxPrimitive.Root
|
| 12 |
+
ref={ref}
|
| 13 |
+
className={cn(
|
| 14 |
+
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
| 15 |
+
className,
|
| 16 |
+
)}
|
| 17 |
+
{...props}
|
| 18 |
+
>
|
| 19 |
+
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
| 20 |
+
<Check className="h-4 w-4" />
|
| 21 |
+
</CheckboxPrimitive.Indicator>
|
| 22 |
+
</CheckboxPrimitive.Root>
|
| 23 |
+
));
|
| 24 |
+
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
| 25 |
+
|
| 26 |
+
export { Checkbox };
|