vn6295337 Claude Opus 4.5 commited on
Commit
0c591a7
·
0 Parent(s):

Initial commit: Instant SWOT Agent

Browse files

Multi-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
Files changed (50) hide show
  1. .dockerignore +52 -0
  2. .env.example +63 -0
  3. .gitignore +199 -0
  4. BUSINESS_README.md +89 -0
  5. Dockerfile +32 -0
  6. Makefile +104 -0
  7. README.md +345 -0
  8. a2a/__init__.py +7 -0
  9. a2a/agent_card.json +72 -0
  10. data/cache/us_stocks.json +0 -0
  11. data/strategy.db +0 -0
  12. docs/a2a_architecture.md +113 -0
  13. docs/architecture.md +160 -0
  14. docs/configuration.md +89 -0
  15. frontend/.gitignore +24 -0
  16. frontend/.storybook/main.ts +17 -0
  17. frontend/.storybook/preview.tsx +29 -0
  18. frontend/README.md +88 -0
  19. frontend/e2e/app.spec.ts +94 -0
  20. frontend/eslint.config.js +29 -0
  21. frontend/index.html +13 -0
  22. frontend/install_dependencies.sh +27 -0
  23. frontend/package-lock.json +0 -0
  24. frontend/package.json +108 -0
  25. frontend/playwright.config.ts +42 -0
  26. frontend/postcss.config.js +6 -0
  27. frontend/public/vite.svg +1 -0
  28. frontend/src/App.css +42 -0
  29. frontend/src/App.tsx +722 -0
  30. frontend/src/assets/react.svg +1 -0
  31. frontend/src/components/ActivityLog.test.tsx +58 -0
  32. frontend/src/components/ActivityLog.tsx +130 -0
  33. frontend/src/components/MetricsPanel.tsx +178 -0
  34. frontend/src/components/ProcessFlow.tsx +536 -0
  35. frontend/src/components/StockSearch.tsx +261 -0
  36. frontend/src/components/ViewModeToggle.test.tsx +47 -0
  37. frontend/src/components/ViewModeToggle.tsx +43 -0
  38. frontend/src/components/ui/accordion.tsx +52 -0
  39. frontend/src/components/ui/alert-dialog.tsx +104 -0
  40. frontend/src/components/ui/alert.tsx +43 -0
  41. frontend/src/components/ui/aspect-ratio.tsx +5 -0
  42. frontend/src/components/ui/avatar.tsx +38 -0
  43. frontend/src/components/ui/badge.tsx +29 -0
  44. frontend/src/components/ui/breadcrumb.tsx +90 -0
  45. frontend/src/components/ui/button.tsx +47 -0
  46. frontend/src/components/ui/calendar.tsx +54 -0
  47. frontend/src/components/ui/card.tsx +43 -0
  48. frontend/src/components/ui/carousel.tsx +224 -0
  49. frontend/src/components/ui/chart.tsx +303 -0
  50. 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
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
16
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 };