Repository: virattt/ai-hedge-fund
Branch: main
Commit: 359cce737377
Files: 258
Total size: 1.5 MB
Directory structure:
gitextract_0t_9v5yp/
├── .dockerignore
├── .github/
│ └── ISSUE_TEMPLATE/
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── README.md
├── app/
│ ├── README.md
│ ├── backend/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── alembic/
│ │ │ ├── README
│ │ │ ├── env.py
│ │ │ ├── script.py.mako
│ │ │ └── versions/
│ │ │ ├── 1b1feba3d897_add_data_column_to_hedge_fund_flows.py
│ │ │ ├── 2f8c5d9e4b1a_add_hedgefundflowrun_table.py
│ │ │ ├── 3f9a6b7c8d2e_add_hedgefundflowruncycle_table.py
│ │ │ ├── 5274886e5bee_add_hedgefundflow_table.py
│ │ │ └── add_api_keys_table.py
│ │ ├── alembic.ini
│ │ ├── database/
│ │ │ ├── __init__.py
│ │ │ ├── connection.py
│ │ │ └── models.py
│ │ ├── main.py
│ │ ├── models/
│ │ │ ├── __init__.py
│ │ │ ├── events.py
│ │ │ └── schemas.py
│ │ ├── repositories/
│ │ │ ├── __init__.py
│ │ │ ├── api_key_repository.py
│ │ │ ├── flow_repository.py
│ │ │ └── flow_run_repository.py
│ │ ├── routes/
│ │ │ ├── __init__.py
│ │ │ ├── api_keys.py
│ │ │ ├── flow_runs.py
│ │ │ ├── flows.py
│ │ │ ├── health.py
│ │ │ ├── hedge_fund.py
│ │ │ ├── language_models.py
│ │ │ ├── ollama.py
│ │ │ └── storage.py
│ │ └── services/
│ │ ├── __init__.py
│ │ ├── agent_service.py
│ │ ├── api_key_service.py
│ │ ├── backtest_service.py
│ │ ├── graph.py
│ │ ├── ollama_service.py
│ │ └── portfolio.py
│ ├── frontend/
│ │ ├── .eslintrc.cjs
│ │ ├── .github/
│ │ │ └── dependabot.yml
│ │ ├── .gitignore
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── src/
│ │ │ ├── App.tsx
│ │ │ ├── components/
│ │ │ │ ├── Flow.tsx
│ │ │ │ ├── Layout.tsx
│ │ │ │ ├── custom-controls.tsx
│ │ │ │ ├── layout/
│ │ │ │ │ └── top-bar.tsx
│ │ │ │ ├── panels/
│ │ │ │ │ ├── bottom/
│ │ │ │ │ │ ├── bottom-panel.tsx
│ │ │ │ │ │ └── tabs/
│ │ │ │ │ │ ├── backtest-output.tsx
│ │ │ │ │ │ ├── debug-console-tab.tsx
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── output-tab-utils.ts
│ │ │ │ │ │ ├── output-tab.tsx
│ │ │ │ │ │ ├── problems-tab.tsx
│ │ │ │ │ │ ├── reasoning-content.tsx
│ │ │ │ │ │ ├── regular-output.tsx
│ │ │ │ │ │ └── terminal-tab.tsx
│ │ │ │ │ ├── left/
│ │ │ │ │ │ ├── flow-actions.tsx
│ │ │ │ │ │ ├── flow-context-menu.tsx
│ │ │ │ │ │ ├── flow-create-dialog.tsx
│ │ │ │ │ │ ├── flow-edit-dialog.tsx
│ │ │ │ │ │ ├── flow-item-group.tsx
│ │ │ │ │ │ ├── flow-item.tsx
│ │ │ │ │ │ ├── flow-list.tsx
│ │ │ │ │ │ └── left-sidebar.tsx
│ │ │ │ │ ├── right/
│ │ │ │ │ │ ├── component-actions.tsx
│ │ │ │ │ │ ├── component-item-group.tsx
│ │ │ │ │ │ ├── component-item.tsx
│ │ │ │ │ │ ├── component-list.tsx
│ │ │ │ │ │ └── right-sidebar.tsx
│ │ │ │ │ └── search-box.tsx
│ │ │ │ ├── settings/
│ │ │ │ │ ├── api-keys.tsx
│ │ │ │ │ ├── appearance.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── models/
│ │ │ │ │ │ ├── cloud.tsx
│ │ │ │ │ │ └── ollama.tsx
│ │ │ │ │ ├── models.tsx
│ │ │ │ │ └── settings.tsx
│ │ │ │ ├── tabs/
│ │ │ │ │ ├── flow-tab-content.tsx
│ │ │ │ │ ├── tab-bar.tsx
│ │ │ │ │ └── tab-content.tsx
│ │ │ │ └── ui/
│ │ │ │ ├── accordion.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── command.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── llm-selector.tsx
│ │ │ │ ├── popover.tsx
│ │ │ │ ├── resizable.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── sheet.tsx
│ │ │ │ ├── sidebar.tsx
│ │ │ │ ├── skeleton.tsx
│ │ │ │ ├── sonner.tsx
│ │ │ │ ├── table.tsx
│ │ │ │ ├── tabs.tsx
│ │ │ │ └── tooltip.tsx
│ │ │ ├── contexts/
│ │ │ │ ├── flow-context.tsx
│ │ │ │ ├── layout-context.tsx
│ │ │ │ ├── node-context.tsx
│ │ │ │ └── tabs-context.tsx
│ │ │ ├── data/
│ │ │ │ ├── agents.ts
│ │ │ │ ├── models.ts
│ │ │ │ ├── multi-node-mappings.ts
│ │ │ │ ├── node-mappings.ts
│ │ │ │ └── sidebar-components.ts
│ │ │ ├── edges/
│ │ │ │ └── index.ts
│ │ │ ├── hooks/
│ │ │ │ ├── use-component-groups.ts
│ │ │ │ ├── use-enhanced-flow-actions.ts
│ │ │ │ ├── use-flow-connection.ts
│ │ │ │ ├── use-flow-history.ts
│ │ │ │ ├── use-flow-management-tabs.ts
│ │ │ │ ├── use-flow-management.ts
│ │ │ │ ├── use-keyboard-shortcuts.ts
│ │ │ │ ├── use-mobile.tsx
│ │ │ │ ├── use-node-state.ts
│ │ │ │ ├── use-output-node-connection.ts
│ │ │ │ ├── use-resizable.ts
│ │ │ │ └── use-toast-manager.ts
│ │ │ ├── index.css
│ │ │ ├── lib/
│ │ │ │ └── utils.ts
│ │ │ ├── main.tsx
│ │ │ ├── nodes/
│ │ │ │ ├── components/
│ │ │ │ │ ├── agent-node.tsx
│ │ │ │ │ ├── agent-output-dialog.tsx
│ │ │ │ │ ├── investment-report-dialog.tsx
│ │ │ │ │ ├── investment-report-node.tsx
│ │ │ │ │ ├── json-output-dialog.tsx
│ │ │ │ │ ├── json-output-node.tsx
│ │ │ │ │ ├── node-shell.tsx
│ │ │ │ │ ├── output-node-status.tsx
│ │ │ │ │ ├── portfolio-manager-node.tsx
│ │ │ │ │ ├── portfolio-start-node.tsx
│ │ │ │ │ └── stock-analyzer-node.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils.ts
│ │ │ ├── providers/
│ │ │ │ └── theme-provider.tsx
│ │ │ ├── services/
│ │ │ │ ├── api-keys-api.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── backtest-api.ts
│ │ │ │ ├── flow-service.ts
│ │ │ │ ├── sidebar-storage.ts
│ │ │ │ ├── tab-service.ts
│ │ │ │ └── types.ts
│ │ │ ├── types/
│ │ │ │ └── flow.ts
│ │ │ ├── utils/
│ │ │ │ ├── date-utils.ts
│ │ │ │ └── text-utils.ts
│ │ │ └── vite-env.d.ts
│ │ ├── tailwind.config.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ └── vite.config.ts
│ ├── run.bat
│ └── run.sh
├── docker/
│ ├── .dockerignore
│ ├── Dockerfile
│ ├── README.md
│ ├── docker-compose.yml
│ ├── run.bat
│ └── run.sh
├── pyproject.toml
├── src/
│ ├── __init__.py
│ ├── agents/
│ │ ├── __init__.py
│ │ ├── aswath_damodaran.py
│ │ ├── ben_graham.py
│ │ ├── bill_ackman.py
│ │ ├── cathie_wood.py
│ │ ├── charlie_munger.py
│ │ ├── fundamentals.py
│ │ ├── growth_agent.py
│ │ ├── michael_burry.py
│ │ ├── mohnish_pabrai.py
│ │ ├── news_sentiment.py
│ │ ├── peter_lynch.py
│ │ ├── phil_fisher.py
│ │ ├── portfolio_manager.py
│ │ ├── rakesh_jhunjhunwala.py
│ │ ├── risk_manager.py
│ │ ├── sentiment.py
│ │ ├── stanley_druckenmiller.py
│ │ ├── technicals.py
│ │ ├── valuation.py
│ │ └── warren_buffett.py
│ ├── backtester.py
│ ├── backtesting/
│ │ ├── __init__.py
│ │ ├── benchmarks.py
│ │ ├── cli.py
│ │ ├── controller.py
│ │ ├── engine.py
│ │ ├── metrics.py
│ │ ├── output.py
│ │ ├── portfolio.py
│ │ ├── trader.py
│ │ ├── types.py
│ │ └── valuation.py
│ ├── cli/
│ │ ├── __init__.py
│ │ └── input.py
│ ├── data/
│ │ ├── __init__.py
│ │ ├── cache.py
│ │ └── models.py
│ ├── graph/
│ │ ├── __init__.py
│ │ └── state.py
│ ├── llm/
│ │ ├── __init__.py
│ │ ├── api_models.json
│ │ ├── models.py
│ │ └── ollama_models.json
│ ├── main.py
│ ├── tools/
│ │ ├── __init__.py
│ │ └── api.py
│ └── utils/
│ ├── __init__.py
│ ├── analysts.py
│ ├── api_key.py
│ ├── display.py
│ ├── docker.py
│ ├── llm.py
│ ├── ollama.py
│ ├── progress.py
│ └── visualize.py
└── tests/
├── __init__.py
├── backtesting/
│ ├── conftest.py
│ ├── integration/
│ │ ├── conftest.py
│ │ ├── mocks.py
│ │ ├── test_integration_long_only.py
│ │ ├── test_integration_long_short.py
│ │ └── test_integration_short_only.py
│ ├── test_controller.py
│ ├── test_execution.py
│ ├── test_metrics.py
│ ├── test_portfolio.py
│ ├── test_results.py
│ └── test_valuation.py
├── fixtures/
│ └── api/
│ ├── financial_metrics/
│ │ ├── AAPL_2024-03-01_2024-03-08.json
│ │ ├── MSFT_2024-03-01_2024-03-08.json
│ │ └── TSLA_2024-03-01_2024-03-08.json
│ ├── insider_trades/
│ │ ├── AAPL_2024-03-01_2024-03-08.json
│ │ ├── MSFT_2024-03-01_2024-03-08.json
│ │ └── TSLA_2024-03-01_2024-03-08.json
│ ├── news/
│ │ ├── AAPL_2024-03-01_2024-03-08.json
│ │ ├── MSFT_2024-03-01_2024-03-08.json
│ │ └── TSLA_2024-03-01_2024-03-08.json
│ └── prices/
│ ├── AAPL_2024-03-01_2024-03-08.json
│ ├── MSFT_2024-03-01_2024-03-08.json
│ └── TSLA_2024-03-01_2024-03-08.json
└── test_api_rate_limiting.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
# Git
.git
.gitignore
# Poetry
.venv
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
# Environment
.env
# IDEs and editors
.idea/
.vscode/
*.swp
*.swo
# Logs and data
logs/
data/
*.log
# OS specific
.DS_Store
Thumbs.db
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Screenshot**
Add a screenshot of the bug to help explain your problem.
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Describe the feature you'd like**
A clear and concise description of what you want to happen.
================================================
FILE: .gitignore
================================================
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
# Environment Variables
.env
# IDE
.idea/
.vscode/
*.swp
*.swo
.cursorrules
.cursorignore
.cursorindexingignore
# OS
.DS_Store
Thumbs.db
# graph
*.png
# Txt files
*.txt
# PDF files
*.pdf
# Frontend
node_modules
# Outputs
outputs/
# Database files (users will have their own local databases)
*.db
*.db-journal
*.db-wal
*.db-shm
*.sqlite
*.sqlite3
# Alembic (keep migration files, but ignore generated/cache files)
app/backend/alembic/versions/__pycache__/
================================================
FILE: README.md
================================================
# AI Hedge Fund
This is a proof of concept for an AI-powered hedge fund. The goal of this project is to explore the use of AI to make trading decisions. This project is for **educational** purposes only and is not intended for real trading or investment.
This system employs several agents working together:
1. Aswath Damodaran Agent - The Dean of Valuation, focuses on story, numbers, and disciplined valuation
2. Ben Graham Agent - The godfather of value investing, only buys hidden gems with a margin of safety
3. Bill Ackman Agent - An activist investor, takes bold positions and pushes for change
4. Cathie Wood Agent - The queen of growth investing, believes in the power of innovation and disruption
5. Charlie Munger Agent - Warren Buffett's partner, only buys wonderful businesses at fair prices
6. Michael Burry Agent - The Big Short contrarian who hunts for deep value
7. Mohnish Pabrai Agent - The Dhandho investor, who looks for doubles at low risk
8. Peter Lynch Agent - Practical investor who seeks "ten-baggers" in everyday businesses
9. Phil Fisher Agent - Meticulous growth investor who uses deep "scuttlebutt" research
10. Rakesh Jhunjhunwala Agent - The Big Bull of India
11. Stanley Druckenmiller Agent - Macro legend who hunts for asymmetric opportunities with growth potential
12. Warren Buffett Agent - The oracle of Omaha, seeks wonderful companies at a fair price
13. Valuation Agent - Calculates the intrinsic value of a stock and generates trading signals
14. Sentiment Agent - Analyzes market sentiment and generates trading signals
15. Fundamentals Agent - Analyzes fundamental data and generates trading signals
16. Technicals Agent - Analyzes technical indicators and generates trading signals
17. Risk Manager - Calculates risk metrics and sets position limits
18. Portfolio Manager - Makes final trading decisions and generates orders
Note: the system does not actually make any trades.
[](https://twitter.com/virattt)
## Disclaimer
This project is for **educational and research purposes only**.
- Not intended for real trading or investment
- No investment advice or guarantees provided
- Creator assumes no liability for financial losses
- Consult a financial advisor for investment decisions
- Past performance does not indicate future results
By using this software, you agree to use it solely for learning purposes.
## Table of Contents
- [How to Install](#how-to-install)
- [How to Run](#how-to-run)
- [⌨️ Command Line Interface](#️-command-line-interface)
- [🖥️ Web Application](#️-web-application)
- [How to Contribute](#how-to-contribute)
- [Feature Requests](#feature-requests)
- [License](#license)
## How to Install
Before you can run the AI Hedge Fund, you'll need to install it and set up your API keys. These steps are common to both the full-stack web application and command line interface.
### 1. Clone the Repository
```bash
git clone https://github.com/virattt/ai-hedge-fund.git
cd ai-hedge-fund
```
### 2. Set up API keys
Create a `.env` file for your API keys:
```bash
# Create .env file for your API keys (in the root directory)
cp .env.example .env
```
Open and edit the `.env` file to add your API keys:
```bash
# For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.)
OPENAI_API_KEY=your-openai-api-key
# For getting financial data to power the hedge fund
FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key
```
**Important**: You must set at least one LLM API key (e.g. `OPENAI_API_KEY`, `GROQ_API_KEY`, `ANTHROPIC_API_KEY`, or `DEEPSEEK_API_KEY`) for the hedge fund to work.
**Financial Data**: Data for AAPL, GOOGL, MSFT, NVDA, and TSLA is free and does not require an API key. For any other ticker, you will need to set the `FINANCIAL_DATASETS_API_KEY` in the .env file.
## How to Run
### ⌨️ Command Line Interface
You can run the AI Hedge Fund directly via terminal. This approach offers more granular control and is useful for automation, scripting, and integration purposes.
#### Quick Start
1. Install Poetry (if not already installed):
```bash
curl -sSL https://install.python-poetry.org | python3 -
```
2. Install dependencies:
```bash
poetry install
```
#### Run the AI Hedge Fund
```bash
poetry run python src/main.py --ticker AAPL,MSFT,NVDA
```
You can also specify a `--ollama` flag to run the AI hedge fund using local LLMs.
```bash
poetry run python src/main.py --ticker AAPL,MSFT,NVDA --ollama
```
You can optionally specify the start and end dates to make decisions over a specific time period.
```bash
poetry run python src/main.py --ticker AAPL,MSFT,NVDA --start-date 2024-01-01 --end-date 2024-03-01
```
#### Run the Backtester
```bash
poetry run python src/backtester.py --ticker AAPL,MSFT,NVDA
```
**Example Output:**
Note: The `--ollama`, `--start-date`, and `--end-date` flags work for the backtester, as well!
### 🖥️ Web Application
The new way to run the AI Hedge Fund is through our web application that provides a user-friendly interface. This is recommended for users who prefer visual interfaces over command line tools.
Please see detailed instructions on how to install and run the web application [here](https://github.com/virattt/ai-hedge-fund/tree/main/app).
## How to Contribute
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
**Important**: Please keep your pull requests small and focused. This will make it easier to review and merge.
## Feature Requests
If you have a feature request, please open an [issue](https://github.com/virattt/ai-hedge-fund/issues) and make sure it is tagged with `enhancement`.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
================================================
FILE: app/README.md
================================================
# Web Application
The AI Hedge Fund app is a complete system with both frontend and backend components that enables you to run an AI-powered hedge fund trading system through a web interface on your own computer.
## Overview
The AI Hedge Fund consists of:
- **Backend**: A FastAPI application that provides a REST API to run the hedge fund trading system and backtester
- **Frontend**: A React/Vite application that offers a user-friendly interface to visualize and control the hedge fund operations
## Table of Contents
- [🚀 Quick Start (For Non-Technical Users)](#-quick-start-for-non-technical-users)
- [Option 1: Using 1-Line Shell Script (Recommended)](#option-1-using-1-line-shell-script-recommended)
- [Option 2: Using npm (Alternative)](#option-2-using-npm-alternative)
- [🛠️ Manual Setup (For Developers)](#️-manual-setup-for-developers)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Running the Application](#running-the-application)
- [Detailed Documentation](#detailed-documentation)
- [Disclaimer](#disclaimer)
- [Troubleshooting](#troubleshooting])
## 🚀 Quick Start (For Non-Technical Users)
**One-line setup and run command:**
### Option 1: Using 1-Line Shell Script (Recommended)
#### For Mac/Linux:
```bash
./run.sh
```
If you get a "permission denied" error, run this first:
```bash
chmod +x run.sh && ./run.sh
```
Or alternatively, you can run:
```bash
bash run.sh
```
#### For Windows:
```cmd
run.bat
```
### Option 2: Using npm (Alternative)
```bash
cd app && npm install && npm run setup
```
**That's it!** These scripts will:
1. Check for required dependencies (Node.js, Python, Poetry)
2. Install all dependencies automatically
3. Start both frontend and backend services
4. **Automatically open your web browser** to the application
**Requirements:**
- [Node.js](https://nodejs.org/) (includes npm)
- [Python 3](https://python.org/)
- [Poetry](https://python-poetry.org/)
**After running, you can access:**
- Frontend (Web Interface): http://localhost:5173
- Backend API: http://localhost:8000
- API Documentation: http://localhost:8000/docs
---
## 🛠️ Manual Setup (For Developers)
If you prefer to set up each component manually or need more control:
### Prerequisites
- Node.js and npm for the frontend
- Python 3.8+ and Poetry for the backend
### Installation
1. Clone the repository:
```bash
git clone https://github.com/virattt/ai-hedge-fund.git
cd ai-hedge-fund
```
2. Set up your environment variables:
```bash
# Create .env file for your API keys (in the root directory)
cp .env.example .env
```
3. Edit the .env file to add your API keys:
```bash
# For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.)
OPENAI_API_KEY=your-openai-api-key
# For running LLMs hosted by groq (deepseek, llama3, etc.)
GROQ_API_KEY=your-groq-api-key
# For getting financial data to power the hedge fund
FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key
```
4. Install Poetry (if not already installed):
```bash
curl -sSL https://install.python-poetry.org | python3 -
```
5. Install root project dependencies:
```bash
# From the root directory
poetry install
```
6. Install backend app dependencies:
```bash
# Navigate to the backend directory
cd app/backend
pip install -r requirements.txt # If there's a requirements.txt file
# OR
poetry install # If there's a pyproject.toml in the backend directory
```
7. Install frontend app dependencies:
```bash
cd app/frontend
npm install # or pnpm install or yarn install
```
### Running the Application
1. Start the backend server:
```bash
# In one terminal, from the backend directory
cd app/backend
poetry run uvicorn main:app --reload
```
2. Start the frontend application:
```bash
# In another terminal, from the frontend directory
cd app/frontend
npm run dev
```
You can now access:
- Frontend application: http://localhost:5173
- Backend API: http://localhost:8000
- API Documentation: http://localhost:8000/docs
## Detailed Documentation
For more detailed information:
- [Backend Documentation](./backend/README.md)
- [Frontend Documentation](./frontend/README.md)
## Disclaimer
This project is for **educational and research purposes only**.
- Not intended for real trading or investment
- No warranties or guarantees provided
- Creator assumes no liability for financial losses
- Consult a financial advisor for investment decisions
By using this software, you agree to use it solely for learning purposes.
## Troubleshooting
### Common Issues
#### "Command not found: uvicorn" Error
If you see this error when running the setup script:
```bash
[ERROR] Backend failed to start. Check the logs:
Command not found: uvicorn
```
**Solution:**
1. **Clean Poetry environment:**
```bash
cd app/backend
poetry env remove --all
poetry install
```
2. **Or force reinstall:**
```bash
cd app/backend
poetry install --sync
```
3. **Verify installation:**
```bash
cd app/backend
poetry run python -c "import uvicorn; import fastapi"
```
#### Python Version Issues
- **Use Python 3.11**: Python 3.13+ may have compatibility issues
- **Check your Python version:** `python --version`
- **Switch Python versions if needed** (using pyenv, conda, etc.)
#### Environment Variable Issues
- **Ensure .env file exists** in the project root directory
- **Copy from template:** `cp .env.example .env`
- **Add your API keys** to the .env file
#### Permission Issues (Mac/Linux)
If you get "permission denied":
```bash
chmod +x run.sh
./run.sh
```
#### Port Already in Use
If ports 8000 or 5173 are in use:
- **Kill existing processes:** `pkill -f "uvicorn\|vite"`
- **Or use different ports** by modifying the scripts
### Getting Help
- Check the [GitHub Issues](https://github.com/virattt/ai-hedge-fund/issues)
- Follow updates on [Twitter](https://x.com/virattt)
================================================
FILE: app/backend/README.md
================================================
# AI Hedge Fund - Backend [WIP] 🚧
This project is currently a work in progress. To track progress, please get updates [here](https://x.com/virattt).
This is the backend server for the AI Hedge Fund project. It provides a simple REST API to interact with the AI Hedge Fund system, allowing you to run the hedge fund through a web interface.
## Overview
This backend project is a FastAPI application that serves as the server-side component of the AI Hedge Fund system. It exposes endpoints for running the hedge fund trading system and backtester.
This backend is designed to work with a future frontend application that will allow users to interact with the AI Hedge Fund system through their browser.
## Installation
### Using Poetry
1. Clone the repository:
```bash
git clone https://github.com/virattt/ai-hedge-fund.git
cd ai-hedge-fund
```
2. Install Poetry (if not already installed):
```bash
curl -sSL https://install.python-poetry.org | python3 -
```
3. Install dependencies:
```bash
# From the root directory
poetry install
```
4. Set up your environment variables:
```bash
# Create .env file for your API keys (in the root directory)
cp .env.example .env
```
5. Edit the .env file to add your API keys:
```bash
# For running LLMs hosted by openai (gpt-4o, gpt-4o-mini, etc.)
OPENAI_API_KEY=your-openai-api-key
# For running LLMs hosted by groq (deepseek, llama3, etc.)
GROQ_API_KEY=your-groq-api-key
# For getting financial data to power the hedge fund
FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key
```
## Running the Server
To run the development server:
```bash
# Navigate to the backend directory
cd app/backend
# Start the FastAPI server with uvicorn
poetry run uvicorn main:app --reload
```
This will start the FastAPI server with hot-reloading enabled.
The API will be available at:
- API Endpoint: http://localhost:8000
- API Documentation: http://localhost:8000/docs
## API Endpoints
- `POST /hedge-fund/run`: Run the AI Hedge Fund with specified parameters
- `GET /ping`: Simple endpoint to test server connectivity
## Project Structure
```
app/backend/
├── api/ # API layer (future expansion)
├── models/ # Domain models
│ ├── __init__.py
│ └── schemas.py # Pydantic schema definitions
├── routes/ # API routes
│ ├── __init__.py # Router registry
│ ├── hedge_fund.py # Hedge fund endpoints
│ └── health.py # Health check endpoints
├── services/ # Business logic
│ ├── graph.py # Agent graph functionality
│ └── portfolio.py # Portfolio management
├── __init__.py # Package initialization
└── main.py # FastAPI application entry point
```
## Disclaimer
This project is for **educational and research purposes only**.
- Not intended for real trading or investment
- No warranties or guarantees provided
- Creator assumes no liability for financial losses
- Consult a financial advisor for investment decisions
By using this software, you agree to use it solely for learning purposes.
================================================
FILE: app/backend/__init__.py
================================================
import sys
from pathlib import Path
# Add the src directory to Python path for imports
# This is a temporary solution while we develop the backend
src_path = str(Path(__file__).parent.parent.parent / "src")
if src_path not in sys.path:
sys.path.append(src_path)
================================================
FILE: app/backend/alembic/README
================================================
Generic single-database configuration.
================================================
FILE: app/backend/alembic/env.py
================================================
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from app.backend.database.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
================================================
FILE: app/backend/alembic/script.py.mako
================================================
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
================================================
FILE: app/backend/alembic/versions/1b1feba3d897_add_data_column_to_hedge_fund_flows.py
================================================
"""add_data_column_to_hedge_fund_flows
Revision ID: 1b1feba3d897
Revises: 5274886e5bee
Create Date: 2025-06-22 17:30:50.992184
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1b1feba3d897'
down_revision: Union[str, None] = '5274886e5bee'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('hedge_fund_flows', sa.Column('data', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('hedge_fund_flows', 'data')
# ### end Alembic commands ###
================================================
FILE: app/backend/alembic/versions/2f8c5d9e4b1a_add_hedgefundflowrun_table.py
================================================
"""Add HedgeFundFlowRun table
Revision ID: 2f8c5d9e4b1a
Revises: 1b1feba3d897
Create Date: 2025-01-01 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2f8c5d9e4b1a'
down_revision: Union[str, None] = '1b1feba3d897'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('hedge_fund_flow_runs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('flow_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.String(length=50), nullable=False, server_default='IDLE'),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('request_data', sa.JSON(), nullable=True),
sa.Column('results', sa.JSON(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('run_number', sa.Integer(), nullable=False, server_default='1'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_hedge_fund_flow_runs_id'), 'hedge_fund_flow_runs', ['id'], unique=False)
op.create_index(op.f('ix_hedge_fund_flow_runs_flow_id'), 'hedge_fund_flow_runs', ['flow_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_hedge_fund_flow_runs_flow_id'), table_name='hedge_fund_flow_runs')
op.drop_index(op.f('ix_hedge_fund_flow_runs_id'), table_name='hedge_fund_flow_runs')
op.drop_table('hedge_fund_flow_runs')
# ### end Alembic commands ###
================================================
FILE: app/backend/alembic/versions/3f9a6b7c8d2e_add_hedgefundflowruncycle_table.py
================================================
"""Add HedgeFundFlowRunCycle table and update HedgeFundFlowRun
Revision ID: 3f9a6b7c8d2e
Revises: 2f8c5d9e4b1a
Create Date: 2024-11-27 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3f9a6b7c8d2e'
down_revision = '2f8c5d9e4b1a'
branch_labels = None
depends_on = None
def upgrade():
# Get the database connection to check existing columns
conn = op.get_bind()
# Check if columns already exist before adding them
inspector = sa.inspect(conn)
existing_columns = [col['name'] for col in inspector.get_columns('hedge_fund_flow_runs')]
# Add new columns to hedge_fund_flow_runs table only if they don't exist
if 'trading_mode' not in existing_columns:
op.add_column('hedge_fund_flow_runs', sa.Column('trading_mode', sa.String(50), nullable=False, server_default='one-time'))
if 'schedule' not in existing_columns:
op.add_column('hedge_fund_flow_runs', sa.Column('schedule', sa.String(50), nullable=True))
if 'duration' not in existing_columns:
op.add_column('hedge_fund_flow_runs', sa.Column('duration', sa.String(50), nullable=True))
if 'initial_portfolio' not in existing_columns:
op.add_column('hedge_fund_flow_runs', sa.Column('initial_portfolio', sa.JSON, nullable=True))
if 'final_portfolio' not in existing_columns:
op.add_column('hedge_fund_flow_runs', sa.Column('final_portfolio', sa.JSON, nullable=True))
# Create hedge_fund_flow_run_cycles table only if it doesn't exist
existing_tables = inspector.get_table_names()
if 'hedge_fund_flow_run_cycles' not in existing_tables:
op.create_table(
'hedge_fund_flow_run_cycles',
sa.Column('id', sa.Integer, primary_key=True, index=True),
sa.Column('flow_run_id', sa.Integer, nullable=False, index=True),
sa.Column('cycle_number', sa.Integer, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('analyst_signals', sa.JSON, nullable=True),
sa.Column('trading_decisions', sa.JSON, nullable=True),
sa.Column('executed_trades', sa.JSON, nullable=True),
sa.Column('portfolio_snapshot', sa.JSON, nullable=True),
sa.Column('performance_metrics', sa.JSON, nullable=True),
sa.Column('status', sa.String(50), nullable=False, server_default='IN_PROGRESS'),
sa.Column('error_message', sa.Text, nullable=True),
sa.Column('llm_calls_count', sa.Integer, nullable=True, server_default='0'),
sa.Column('api_calls_count', sa.Integer, nullable=True, server_default='0'),
sa.Column('estimated_cost', sa.String(20), nullable=True),
sa.Column('trigger_reason', sa.String(100), nullable=True),
sa.Column('market_conditions', sa.JSON, nullable=True),
)
# Create indexes for the new table
op.create_index('ix_hedge_fund_flow_run_cycles_flow_run_id', 'hedge_fund_flow_run_cycles', ['flow_run_id'])
op.create_index('ix_hedge_fund_flow_run_cycles_cycle_number', 'hedge_fund_flow_run_cycles', ['cycle_number'])
op.create_index('ix_hedge_fund_flow_run_cycles_status', 'hedge_fund_flow_run_cycles', ['status'])
op.create_index('ix_hedge_fund_flow_run_cycles_started_at', 'hedge_fund_flow_run_cycles', ['started_at'])
def downgrade():
# Check if table exists before dropping
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = inspector.get_table_names()
if 'hedge_fund_flow_run_cycles' in existing_tables:
# Drop indexes if they exist
try:
op.drop_index('ix_hedge_fund_flow_run_cycles_started_at', 'hedge_fund_flow_run_cycles')
op.drop_index('ix_hedge_fund_flow_run_cycles_status', 'hedge_fund_flow_run_cycles')
op.drop_index('ix_hedge_fund_flow_run_cycles_cycle_number', 'hedge_fund_flow_run_cycles')
op.drop_index('ix_hedge_fund_flow_run_cycles_flow_run_id', 'hedge_fund_flow_run_cycles')
except:
pass # Index may not exist
# Drop hedge_fund_flow_run_cycles table
op.drop_table('hedge_fund_flow_run_cycles')
# Check existing columns before dropping
existing_columns = [col['name'] for col in inspector.get_columns('hedge_fund_flow_runs')]
# Remove columns from hedge_fund_flow_runs table only if they exist
if 'final_portfolio' in existing_columns:
op.drop_column('hedge_fund_flow_runs', 'final_portfolio')
if 'initial_portfolio' in existing_columns:
op.drop_column('hedge_fund_flow_runs', 'initial_portfolio')
if 'duration' in existing_columns:
op.drop_column('hedge_fund_flow_runs', 'duration')
if 'schedule' in existing_columns:
op.drop_column('hedge_fund_flow_runs', 'schedule')
if 'trading_mode' in existing_columns:
op.drop_column('hedge_fund_flow_runs', 'trading_mode')
================================================
FILE: app/backend/alembic/versions/5274886e5bee_add_hedgefundflow_table.py
================================================
"""Add HedgeFundFlow table
Revision ID: 5274886e5bee
Revises:
Create Date: 2025-06-20 14:50:24.736989
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5274886e5bee'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('hedge_fund_flows',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('nodes', sa.JSON(), nullable=False),
sa.Column('edges', sa.JSON(), nullable=False),
sa.Column('viewport', sa.JSON(), nullable=True),
sa.Column('is_template', sa.Boolean(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_hedge_fund_flows_id'), 'hedge_fund_flows', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_hedge_fund_flows_id'), table_name='hedge_fund_flows')
op.drop_table('hedge_fund_flows')
# ### end Alembic commands ###
================================================
FILE: app/backend/alembic/versions/add_api_keys_table.py
================================================
"""add_api_keys_table
Revision ID: d5e78f9a1b2c
Revises: 3f9a6b7c8d2e
Create Date: 2025-07-27 15:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd5e78f9a1b2c'
down_revision: Union[str, None] = '3f9a6b7c8d2e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Create API keys table
op.create_table('api_keys',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('provider', sa.String(length=100), nullable=False),
sa.Column('key_value', sa.Text(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('last_used', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider')
)
op.create_index(op.f('ix_api_keys_id'), 'api_keys', ['id'], unique=False)
op.create_index(op.f('ix_api_keys_provider'), 'api_keys', ['provider'], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f('ix_api_keys_provider'), table_name='api_keys')
op.drop_index(op.f('ix_api_keys_id'), table_name='api_keys')
op.drop_table('api_keys')
================================================
FILE: app/backend/alembic.ini
================================================
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///./hedge_fund.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
================================================
FILE: app/backend/database/__init__.py
================================================
from .connection import get_db, engine, SessionLocal
from .models import Base
__all__ = ["get_db", "engine", "SessionLocal", "Base"]
================================================
FILE: app/backend/database/connection.py
================================================
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
from pathlib import Path
# Get the backend directory path
BACKEND_DIR = Path(__file__).parent.parent
DATABASE_PATH = BACKEND_DIR / "hedge_fund.db"
# Database configuration - use absolute path
DATABASE_URL = f"sqlite:///{DATABASE_PATH}"
# Create SQLAlchemy engine
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} # Needed for SQLite
)
# Create SessionLocal class
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create Base class for models
Base = declarative_base()
# Dependency for FastAPI
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
================================================
FILE: app/backend/database/models.py
================================================
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, JSON, ForeignKey
from sqlalchemy.sql import func
from .connection import Base
class HedgeFundFlow(Base):
"""Table to store React Flow configurations (nodes, edges, viewport)"""
__tablename__ = "hedge_fund_flows"
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Flow metadata
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
# React Flow state
nodes = Column(JSON, nullable=False) # Store React Flow nodes as JSON
edges = Column(JSON, nullable=False) # Store React Flow edges as JSON
viewport = Column(JSON, nullable=True) # Store viewport state (zoom, x, y)
data = Column(JSON, nullable=True) # Store node internal states (tickers, models, etc.)
# Additional metadata
is_template = Column(Boolean, default=False) # Mark as template for reuse
tags = Column(JSON, nullable=True) # Store tags for categorization
class HedgeFundFlowRun(Base):
"""Table to track individual execution runs of a hedge fund flow"""
__tablename__ = "hedge_fund_flow_runs"
id = Column(Integer, primary_key=True, index=True)
flow_id = Column(Integer, ForeignKey("hedge_fund_flows.id"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Run execution tracking
status = Column(String(50), nullable=False, default="IDLE") # IDLE, IN_PROGRESS, COMPLETE, ERROR
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Run configuration
trading_mode = Column(String(50), nullable=False, default="one-time") # one-time, continuous, advisory
schedule = Column(String(50), nullable=True) # hourly, daily, weekly (for continuous mode)
duration = Column(String(50), nullable=True) # 1day, 1week, 1month (for continuous mode)
# Run data
request_data = Column(JSON, nullable=True) # Store the request parameters (tickers, agents, models, etc.)
initial_portfolio = Column(JSON, nullable=True) # Store initial portfolio state
final_portfolio = Column(JSON, nullable=True) # Store final portfolio state
results = Column(JSON, nullable=True) # Store the output/results from the run
error_message = Column(Text, nullable=True) # Store error details if run failed
# Metadata
run_number = Column(Integer, nullable=False, default=1) # Sequential run number for this flow
class HedgeFundFlowRunCycle(Base):
"""Individual analysis cycles within a trading session"""
__tablename__ = "hedge_fund_flow_run_cycles"
id = Column(Integer, primary_key=True, index=True)
flow_run_id = Column(Integer, ForeignKey("hedge_fund_flow_runs.id"), nullable=False, index=True)
cycle_number = Column(Integer, nullable=False) # 1, 2, 3, etc. within the run
# Timing
created_at = Column(DateTime(timezone=True), server_default=func.now())
started_at = Column(DateTime(timezone=True), nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Analysis results
analyst_signals = Column(JSON, nullable=True) # All agent decisions/signals
trading_decisions = Column(JSON, nullable=True) # Portfolio manager decisions
executed_trades = Column(JSON, nullable=True) # Actual trades executed (paper trading)
# Portfolio state after this cycle
portfolio_snapshot = Column(JSON, nullable=True) # Cash, positions, performance metrics
# Performance metrics for this cycle
performance_metrics = Column(JSON, nullable=True) # Returns, sharpe ratio, etc.
# Execution tracking
status = Column(String(50), nullable=False, default="IN_PROGRESS") # IN_PROGRESS, COMPLETED, ERROR
error_message = Column(Text, nullable=True) # Store error details if cycle failed
# Cost tracking
llm_calls_count = Column(Integer, nullable=True, default=0) # Number of LLM calls made
api_calls_count = Column(Integer, nullable=True, default=0) # Number of financial API calls made
estimated_cost = Column(String(20), nullable=True) # Estimated cost in USD
# Metadata
trigger_reason = Column(String(100), nullable=True) # scheduled, manual, market_event, etc.
market_conditions = Column(JSON, nullable=True) # Market data snapshot at cycle start
class ApiKey(Base):
"""Table to store API keys for various services"""
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# API key details
provider = Column(String(100), nullable=False, unique=True, index=True) # e.g., "ANTHROPIC_API_KEY"
key_value = Column(Text, nullable=False) # The actual API key (encrypted in production)
is_active = Column(Boolean, default=True) # Enable/disable without deletion
# Optional metadata
description = Column(Text, nullable=True) # Human-readable description
last_used = Column(DateTime(timezone=True), nullable=True) # Track usage
================================================
FILE: app/backend/main.py
================================================
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import logging
import asyncio
from app.backend.routes import api_router
from app.backend.database.connection import engine
from app.backend.database.models import Base
from app.backend.services.ollama_service import ollama_service
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="AI Hedge Fund API", description="Backend API for AI Hedge Fund", version="0.1.0")
# Initialize database tables (this is safe to run multiple times)
Base.metadata.create_all(bind=engine)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], # Frontend URLs
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include all routes
app.include_router(api_router)
@app.on_event("startup")
async def startup_event():
"""Startup event to check Ollama availability."""
try:
logger.info("Checking Ollama availability...")
status = await ollama_service.check_ollama_status()
if status["installed"]:
if status["running"]:
logger.info(f"✓ Ollama is installed and running at {status['server_url']}")
if status["available_models"]:
logger.info(f"✓ Available models: {', '.join(status['available_models'])}")
else:
logger.info("ℹ No models are currently downloaded")
else:
logger.info("ℹ Ollama is installed but not running")
logger.info("ℹ You can start it from the Settings page or manually with 'ollama serve'")
else:
logger.info("ℹ Ollama is not installed. Install it to use local models.")
logger.info("ℹ Visit https://ollama.com to download and install Ollama")
except Exception as e:
logger.warning(f"Could not check Ollama status: {e}")
logger.info("ℹ Ollama integration is available if you install it later")
================================================
FILE: app/backend/models/__init__.py
================================================
================================================
FILE: app/backend/models/events.py
================================================
from typing import Dict, Optional, Any, Literal
from pydantic import BaseModel
class BaseEvent(BaseModel):
"""Base class for all Server-Sent Event events"""
type: str
def to_sse(self) -> str:
"""Convert to Server-Sent Event format"""
event_type = self.type.lower()
return f"event: {event_type}\ndata: {self.model_dump_json()}\n\n"
class StartEvent(BaseEvent):
"""Event indicating the start of processing"""
type: Literal["start"] = "start"
timestamp: Optional[str] = None
class ProgressUpdateEvent(BaseEvent):
"""Event containing an agent's progress update"""
type: Literal["progress"] = "progress"
agent: str
ticker: Optional[str] = None
status: str
timestamp: Optional[str] = None
analysis: Optional[str] = None
class ErrorEvent(BaseEvent):
"""Event indicating an error occurred"""
type: Literal["error"] = "error"
message: str
timestamp: Optional[str] = None
class CompleteEvent(BaseEvent):
"""Event indicating successful completion with results"""
type: Literal["complete"] = "complete"
data: Dict[str, Any]
timestamp: Optional[str] = None
================================================
FILE: app/backend/models/schemas.py
================================================
from datetime import datetime, timedelta
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional, Dict, Any
from src.llm.models import ModelProvider
from enum import Enum
from app.backend.services.graph import extract_base_agent_key
class FlowRunStatus(str, Enum):
IDLE = "IDLE"
IN_PROGRESS = "IN_PROGRESS"
COMPLETE = "COMPLETE"
ERROR = "ERROR"
class AgentModelConfig(BaseModel):
agent_id: str
model_name: Optional[str] = None
model_provider: Optional[ModelProvider] = None
class PortfolioPosition(BaseModel):
ticker: str
quantity: float
trade_price: float
@field_validator('trade_price')
@classmethod
def price_must_be_positive(cls, v: float) -> float:
if v <= 0:
raise ValueError('Trade price must be positive!')
return v
class GraphNode(BaseModel):
id: str
type: Optional[str] = None
data: Optional[Dict[str, Any]] = None
position: Optional[Dict[str, Any]] = None
class GraphEdge(BaseModel):
id: str
source: str
target: str
type: Optional[str] = None
data: Optional[Dict[str, Any]] = None
class HedgeFundResponse(BaseModel):
decisions: dict
analyst_signals: dict
class ErrorResponse(BaseModel):
message: str
error: str | None = None
# Base class for shared fields between HedgeFundRequest and BacktestRequest
class BaseHedgeFundRequest(BaseModel):
tickers: List[str]
graph_nodes: List[GraphNode]
graph_edges: List[GraphEdge]
agent_models: Optional[List[AgentModelConfig]] = None
model_name: Optional[str] = "gpt-4.1"
model_provider: Optional[ModelProvider] = ModelProvider.OPENAI
margin_requirement: float = 0.0
portfolio_positions: Optional[List[PortfolioPosition]] = None
api_keys: Optional[Dict[str, str]] = None
def get_agent_ids(self) -> List[str]:
"""Extract agent IDs from graph structure"""
return [node.id for node in self.graph_nodes]
def get_agent_model_config(self, agent_id: str) -> tuple[str, ModelProvider]:
"""Get model configuration for a specific agent"""
if self.agent_models:
# Extract base agent key from unique node ID for matching
base_agent_key = extract_base_agent_key(agent_id)
for config in self.agent_models:
# Check both unique node ID and base agent key for matches
config_base_key = extract_base_agent_key(config.agent_id)
if config.agent_id == agent_id or config_base_key == base_agent_key:
return (
config.model_name or self.model_name,
config.model_provider or self.model_provider
)
# Fallback to global model settings
return self.model_name, self.model_provider
class BacktestRequest(BaseHedgeFundRequest):
start_date: str
end_date: str
initial_capital: float = 100000.0
class BacktestDayResult(BaseModel):
date: str
portfolio_value: float
cash: float
decisions: Dict[str, Any]
executed_trades: Dict[str, int]
analyst_signals: Dict[str, Any]
current_prices: Dict[str, float]
long_exposure: float
short_exposure: float
gross_exposure: float
net_exposure: float
long_short_ratio: Optional[float] = None
class BacktestPerformanceMetrics(BaseModel):
sharpe_ratio: Optional[float] = None
sortino_ratio: Optional[float] = None
max_drawdown: Optional[float] = None
max_drawdown_date: Optional[str] = None
long_short_ratio: Optional[float] = None
gross_exposure: Optional[float] = None
net_exposure: Optional[float] = None
class BacktestResponse(BaseModel):
results: List[BacktestDayResult]
performance_metrics: BacktestPerformanceMetrics
final_portfolio: Dict[str, Any]
class HedgeFundRequest(BaseHedgeFundRequest):
end_date: Optional[str] = Field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))
start_date: Optional[str] = None
initial_cash: float = 100000.0
def get_start_date(self) -> str:
"""Calculate start date if not provided"""
if self.start_date:
return self.start_date
return (datetime.strptime(self.end_date, "%Y-%m-%d") - timedelta(days=90)).strftime("%Y-%m-%d")
# Flow-related schemas
class FlowCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
nodes: List[Dict[str, Any]]
edges: List[Dict[str, Any]]
viewport: Optional[Dict[str, Any]] = None
data: Optional[Dict[str, Any]] = None
is_template: bool = False
tags: Optional[List[str]] = None
class FlowUpdateRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
nodes: Optional[List[Dict[str, Any]]] = None
edges: Optional[List[Dict[str, Any]]] = None
viewport: Optional[Dict[str, Any]] = None
data: Optional[Dict[str, Any]] = None
is_template: Optional[bool] = None
tags: Optional[List[str]] = None
class FlowResponse(BaseModel):
id: int
name: str
description: Optional[str]
nodes: List[Dict[str, Any]]
edges: List[Dict[str, Any]]
viewport: Optional[Dict[str, Any]]
data: Optional[Dict[str, Any]]
is_template: bool
tags: Optional[List[str]]
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
class FlowSummaryResponse(BaseModel):
"""Lightweight flow response without nodes/edges for listing"""
id: int
name: str
description: Optional[str]
is_template: bool
tags: Optional[List[str]]
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
# Flow Run schemas
class FlowRunCreateRequest(BaseModel):
"""Request to create a new flow run"""
request_data: Optional[Dict[str, Any]] = None
class FlowRunUpdateRequest(BaseModel):
"""Request to update an existing flow run"""
status: Optional[FlowRunStatus] = None
results: Optional[Dict[str, Any]] = None
error_message: Optional[str] = None
class FlowRunResponse(BaseModel):
"""Complete flow run response"""
id: int
flow_id: int
status: FlowRunStatus
run_number: int
created_at: datetime
updated_at: Optional[datetime]
started_at: Optional[datetime]
completed_at: Optional[datetime]
request_data: Optional[Dict[str, Any]]
results: Optional[Dict[str, Any]]
error_message: Optional[str]
class Config:
from_attributes = True
class FlowRunSummaryResponse(BaseModel):
"""Lightweight flow run response for listing"""
id: int
flow_id: int
status: FlowRunStatus
run_number: int
created_at: datetime
started_at: Optional[datetime]
completed_at: Optional[datetime]
error_message: Optional[str]
class Config:
from_attributes = True
# API Key schemas
class ApiKeyCreateRequest(BaseModel):
"""Request to create or update an API key"""
provider: str = Field(..., min_length=1, max_length=100)
key_value: str = Field(..., min_length=1)
description: Optional[str] = None
is_active: bool = True
class ApiKeyUpdateRequest(BaseModel):
"""Request to update an existing API key"""
key_value: Optional[str] = Field(None, min_length=1)
description: Optional[str] = None
is_active: Optional[bool] = None
class ApiKeyResponse(BaseModel):
"""Complete API key response"""
id: int
provider: str
key_value: str
is_active: bool
description: Optional[str]
created_at: datetime
updated_at: Optional[datetime]
last_used: Optional[datetime]
class Config:
from_attributes = True
class ApiKeySummaryResponse(BaseModel):
"""API key response without the actual key value"""
id: int
provider: str
is_active: bool
description: Optional[str]
created_at: datetime
updated_at: Optional[datetime]
last_used: Optional[datetime]
has_key: bool = True # Indicates if a key is set
class Config:
from_attributes = True
class ApiKeyBulkUpdateRequest(BaseModel):
"""Request to update multiple API keys at once"""
api_keys: List[ApiKeyCreateRequest]
================================================
FILE: app/backend/repositories/__init__.py
================================================
from .flow_repository import FlowRepository
__all__ = ["FlowRepository"]
================================================
FILE: app/backend/repositories/api_key_repository.py
================================================
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional
from datetime import datetime
from app.backend.database.models import ApiKey
class ApiKeyRepository:
"""Repository for API key database operations"""
def __init__(self, db: Session):
self.db = db
def create_or_update_api_key(
self,
provider: str,
key_value: str,
description: str = None,
is_active: bool = True
) -> ApiKey:
"""Create a new API key or update existing one"""
# Check if API key already exists for this provider
existing_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first()
if existing_key:
# Update existing key
existing_key.key_value = key_value
existing_key.description = description
existing_key.is_active = is_active
existing_key.updated_at = func.now()
self.db.commit()
self.db.refresh(existing_key)
return existing_key
else:
# Create new key
api_key = ApiKey(
provider=provider,
key_value=key_value,
description=description,
is_active=is_active
)
self.db.add(api_key)
self.db.commit()
self.db.refresh(api_key)
return api_key
def get_api_key_by_provider(self, provider: str) -> Optional[ApiKey]:
"""Get API key by provider name"""
return self.db.query(ApiKey).filter(
ApiKey.provider == provider,
ApiKey.is_active == True
).first()
def get_all_api_keys(self, include_inactive: bool = False) -> List[ApiKey]:
"""Get all API keys"""
query = self.db.query(ApiKey)
if not include_inactive:
query = query.filter(ApiKey.is_active == True)
return query.order_by(ApiKey.provider).all()
def update_api_key(
self,
provider: str,
key_value: str = None,
description: str = None,
is_active: bool = None
) -> Optional[ApiKey]:
"""Update an existing API key"""
api_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first()
if not api_key:
return None
if key_value is not None:
api_key.key_value = key_value
if description is not None:
api_key.description = description
if is_active is not None:
api_key.is_active = is_active
api_key.updated_at = func.now()
self.db.commit()
self.db.refresh(api_key)
return api_key
def delete_api_key(self, provider: str) -> bool:
"""Delete an API key by provider"""
api_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first()
if not api_key:
return False
self.db.delete(api_key)
self.db.commit()
return True
def deactivate_api_key(self, provider: str) -> bool:
"""Deactivate an API key instead of deleting it"""
api_key = self.db.query(ApiKey).filter(ApiKey.provider == provider).first()
if not api_key:
return False
api_key.is_active = False
api_key.updated_at = func.now()
self.db.commit()
return True
def update_last_used(self, provider: str) -> bool:
"""Update the last_used timestamp for an API key"""
api_key = self.db.query(ApiKey).filter(
ApiKey.provider == provider,
ApiKey.is_active == True
).first()
if not api_key:
return False
api_key.last_used = func.now()
self.db.commit()
return True
def bulk_create_or_update(self, api_keys_data: List[dict]) -> List[ApiKey]:
"""Bulk create or update multiple API keys"""
results = []
for data in api_keys_data:
api_key = self.create_or_update_api_key(
provider=data['provider'],
key_value=data['key_value'],
description=data.get('description'),
is_active=data.get('is_active', True)
)
results.append(api_key)
return results
================================================
FILE: app/backend/repositories/flow_repository.py
================================================
from typing import List, Optional
from sqlalchemy.orm import Session
from app.backend.database.models import HedgeFundFlow
class FlowRepository:
"""Repository for HedgeFundFlow CRUD operations"""
def __init__(self, db: Session):
self.db = db
def create_flow(self, name: str, nodes: dict, edges: dict, description: str = None,
viewport: dict = None, data: dict = None, is_template: bool = False, tags: List[str] = None) -> HedgeFundFlow:
"""Create a new hedge fund flow"""
flow = HedgeFundFlow(
name=name,
description=description,
nodes=nodes,
edges=edges,
viewport=viewport,
data=data,
is_template=is_template,
tags=tags or []
)
self.db.add(flow)
self.db.commit()
self.db.refresh(flow)
return flow
def get_flow_by_id(self, flow_id: int) -> Optional[HedgeFundFlow]:
"""Get a flow by its ID"""
return self.db.query(HedgeFundFlow).filter(HedgeFundFlow.id == flow_id).first()
def get_all_flows(self, include_templates: bool = True) -> List[HedgeFundFlow]:
"""Get all flows, optionally excluding templates"""
query = self.db.query(HedgeFundFlow)
if not include_templates:
query = query.filter(HedgeFundFlow.is_template == False)
return query.order_by(HedgeFundFlow.updated_at.desc()).all()
def get_flows_by_name(self, name: str) -> List[HedgeFundFlow]:
"""Search flows by name (case-insensitive partial match)"""
return self.db.query(HedgeFundFlow).filter(
HedgeFundFlow.name.ilike(f"%{name}%")
).order_by(HedgeFundFlow.updated_at.desc()).all()
def update_flow(self, flow_id: int, name: str = None, description: str = None,
nodes: dict = None, edges: dict = None, viewport: dict = None, data: dict = None,
is_template: bool = None, tags: List[str] = None) -> Optional[HedgeFundFlow]:
"""Update an existing flow"""
flow = self.get_flow_by_id(flow_id)
if not flow:
return None
if name is not None:
flow.name = name
if description is not None:
flow.description = description
if nodes is not None:
flow.nodes = nodes
if edges is not None:
flow.edges = edges
if viewport is not None:
flow.viewport = viewport
if data is not None:
flow.data = data
if is_template is not None:
flow.is_template = is_template
if tags is not None:
flow.tags = tags
self.db.commit()
self.db.refresh(flow)
return flow
def delete_flow(self, flow_id: int) -> bool:
"""Delete a flow by ID"""
flow = self.get_flow_by_id(flow_id)
if not flow:
return False
self.db.delete(flow)
self.db.commit()
return True
def duplicate_flow(self, flow_id: int, new_name: str = None) -> Optional[HedgeFundFlow]:
"""Create a copy of an existing flow"""
original = self.get_flow_by_id(flow_id)
if not original:
return None
copy_name = new_name or f"{original.name} (Copy)"
return self.create_flow(
name=copy_name,
description=original.description,
nodes=original.nodes,
edges=original.edges,
viewport=original.viewport,
data=original.data,
is_template=False, # Copies are not templates by default
tags=original.tags
)
================================================
FILE: app/backend/repositories/flow_run_repository.py
================================================
from typing import List, Optional, Dict, Any
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import desc, func
from app.backend.database.models import HedgeFundFlowRun
from app.backend.models.schemas import FlowRunStatus
class FlowRunRepository:
"""Repository for HedgeFundFlowRun CRUD operations"""
def __init__(self, db: Session):
self.db = db
def create_flow_run(self, flow_id: int, request_data: Dict[str, Any] = None) -> HedgeFundFlowRun:
"""Create a new flow run"""
# Get the next run number for this flow
run_number = self._get_next_run_number(flow_id)
flow_run = HedgeFundFlowRun(
flow_id=flow_id,
request_data=request_data,
run_number=run_number,
status=FlowRunStatus.IDLE.value
)
self.db.add(flow_run)
self.db.commit()
self.db.refresh(flow_run)
return flow_run
def get_flow_run_by_id(self, run_id: int) -> Optional[HedgeFundFlowRun]:
"""Get a flow run by its ID"""
return self.db.query(HedgeFundFlowRun).filter(HedgeFundFlowRun.id == run_id).first()
def get_flow_runs_by_flow_id(self, flow_id: int, limit: int = 50, offset: int = 0) -> List[HedgeFundFlowRun]:
"""Get all runs for a specific flow, ordered by most recent first"""
return (
self.db.query(HedgeFundFlowRun)
.filter(HedgeFundFlowRun.flow_id == flow_id)
.order_by(desc(HedgeFundFlowRun.created_at))
.limit(limit)
.offset(offset)
.all()
)
def get_active_flow_run(self, flow_id: int) -> Optional[HedgeFundFlowRun]:
"""Get the current active (IN_PROGRESS) run for a flow"""
return (
self.db.query(HedgeFundFlowRun)
.filter(
HedgeFundFlowRun.flow_id == flow_id,
HedgeFundFlowRun.status == FlowRunStatus.IN_PROGRESS.value
)
.first()
)
def get_latest_flow_run(self, flow_id: int) -> Optional[HedgeFundFlowRun]:
"""Get the most recent run for a flow"""
return (
self.db.query(HedgeFundFlowRun)
.filter(HedgeFundFlowRun.flow_id == flow_id)
.order_by(desc(HedgeFundFlowRun.created_at))
.first()
)
def update_flow_run(
self,
run_id: int,
status: Optional[FlowRunStatus] = None,
results: Optional[Dict[str, Any]] = None,
error_message: Optional[str] = None
) -> Optional[HedgeFundFlowRun]:
"""Update an existing flow run"""
flow_run = self.get_flow_run_by_id(run_id)
if not flow_run:
return None
# Update status and timing
if status is not None:
flow_run.status = status.value
# Update timing based on status
if status == FlowRunStatus.IN_PROGRESS and not flow_run.started_at:
flow_run.started_at = datetime.utcnow()
elif status in [FlowRunStatus.COMPLETE, FlowRunStatus.ERROR] and not flow_run.completed_at:
flow_run.completed_at = datetime.utcnow()
# Update results and error message
if results is not None:
flow_run.results = results
if error_message is not None:
flow_run.error_message = error_message
self.db.commit()
self.db.refresh(flow_run)
return flow_run
def delete_flow_run(self, run_id: int) -> bool:
"""Delete a flow run by ID"""
flow_run = self.get_flow_run_by_id(run_id)
if not flow_run:
return False
self.db.delete(flow_run)
self.db.commit()
return True
def delete_flow_runs_by_flow_id(self, flow_id: int) -> int:
"""Delete all runs for a specific flow. Returns count of deleted runs."""
deleted_count = (
self.db.query(HedgeFundFlowRun)
.filter(HedgeFundFlowRun.flow_id == flow_id)
.delete()
)
self.db.commit()
return deleted_count
def get_flow_run_count(self, flow_id: int) -> int:
"""Get total count of runs for a flow"""
return (
self.db.query(HedgeFundFlowRun)
.filter(HedgeFundFlowRun.flow_id == flow_id)
.count()
)
def _get_next_run_number(self, flow_id: int) -> int:
"""Get the next run number for a flow"""
max_run_number = (
self.db.query(func.max(HedgeFundFlowRun.run_number))
.filter(HedgeFundFlowRun.flow_id == flow_id)
.scalar()
)
return (max_run_number or 0) + 1
================================================
FILE: app/backend/routes/__init__.py
================================================
from fastapi import APIRouter
from app.backend.routes.hedge_fund import router as hedge_fund_router
from app.backend.routes.health import router as health_router
from app.backend.routes.storage import router as storage_router
from app.backend.routes.flows import router as flows_router
from app.backend.routes.flow_runs import router as flow_runs_router
from app.backend.routes.ollama import router as ollama_router
from app.backend.routes.language_models import router as language_models_router
from app.backend.routes.api_keys import router as api_keys_router
# Main API router
api_router = APIRouter()
# Include sub-routers
api_router.include_router(health_router, tags=["health"])
api_router.include_router(hedge_fund_router, tags=["hedge-fund"])
api_router.include_router(storage_router, tags=["storage"])
api_router.include_router(flows_router, tags=["flows"])
api_router.include_router(flow_runs_router, tags=["flow-runs"])
api_router.include_router(ollama_router, tags=["ollama"])
api_router.include_router(language_models_router, tags=["language-models"])
api_router.include_router(api_keys_router, tags=["api-keys"])
================================================
FILE: app/backend/routes/api_keys.py
================================================
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.orm import Session
from typing import List
from app.backend.database import get_db
from app.backend.repositories.api_key_repository import ApiKeyRepository
from app.backend.models.schemas import (
ApiKeyCreateRequest,
ApiKeyUpdateRequest,
ApiKeyResponse,
ApiKeySummaryResponse,
ApiKeyBulkUpdateRequest,
ErrorResponse
)
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
@router.post(
"/",
response_model=ApiKeyResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid request"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def create_or_update_api_key(request: ApiKeyCreateRequest, db: Session = Depends(get_db)):
"""Create a new API key or update existing one"""
try:
repo = ApiKeyRepository(db)
api_key = repo.create_or_update_api_key(
provider=request.provider,
key_value=request.key_value,
description=request.description,
is_active=request.is_active
)
return ApiKeyResponse.from_orm(api_key)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create/update API key: {str(e)}")
@router.get(
"/",
response_model=List[ApiKeySummaryResponse],
responses={
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_api_keys(include_inactive: bool = False, db: Session = Depends(get_db)):
"""Get all API keys (without actual key values for security)"""
try:
repo = ApiKeyRepository(db)
api_keys = repo.get_all_api_keys(include_inactive=include_inactive)
return [ApiKeySummaryResponse.from_orm(key) for key in api_keys]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve API keys: {str(e)}")
@router.get(
"/{provider}",
response_model=ApiKeyResponse,
responses={
404: {"model": ErrorResponse, "description": "API key not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_api_key(provider: str, db: Session = Depends(get_db)):
"""Get a specific API key by provider"""
try:
repo = ApiKeyRepository(db)
api_key = repo.get_api_key_by_provider(provider)
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
return ApiKeyResponse.from_orm(api_key)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve API key: {str(e)}")
@router.put(
"/{provider}",
response_model=ApiKeyResponse,
responses={
404: {"model": ErrorResponse, "description": "API key not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def update_api_key(provider: str, request: ApiKeyUpdateRequest, db: Session = Depends(get_db)):
"""Update an existing API key"""
try:
repo = ApiKeyRepository(db)
api_key = repo.update_api_key(
provider=provider,
key_value=request.key_value,
description=request.description,
is_active=request.is_active
)
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
return ApiKeyResponse.from_orm(api_key)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update API key: {str(e)}")
@router.delete(
"/{provider}",
responses={
204: {"description": "API key deleted successfully"},
404: {"model": ErrorResponse, "description": "API key not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def delete_api_key(provider: str, db: Session = Depends(get_db)):
"""Delete an API key"""
try:
repo = ApiKeyRepository(db)
success = repo.delete_api_key(provider)
if not success:
raise HTTPException(status_code=404, detail="API key not found")
return {"message": "API key deleted successfully"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete API key: {str(e)}")
@router.patch(
"/{provider}/deactivate",
response_model=ApiKeySummaryResponse,
responses={
404: {"model": ErrorResponse, "description": "API key not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def deactivate_api_key(provider: str, db: Session = Depends(get_db)):
"""Deactivate an API key without deleting it"""
try:
repo = ApiKeyRepository(db)
success = repo.deactivate_api_key(provider)
if not success:
raise HTTPException(status_code=404, detail="API key not found")
# Return the updated key
api_key = repo.get_api_key_by_provider(provider)
return ApiKeySummaryResponse.from_orm(api_key)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to deactivate API key: {str(e)}")
@router.post(
"/bulk",
response_model=List[ApiKeyResponse],
responses={
400: {"model": ErrorResponse, "description": "Invalid request"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def bulk_update_api_keys(request: ApiKeyBulkUpdateRequest, db: Session = Depends(get_db)):
"""Bulk create or update multiple API keys"""
try:
repo = ApiKeyRepository(db)
api_keys_data = [
{
'provider': key.provider,
'key_value': key.key_value,
'description': key.description,
'is_active': key.is_active
}
for key in request.api_keys
]
api_keys = repo.bulk_create_or_update(api_keys_data)
return [ApiKeyResponse.from_orm(key) for key in api_keys]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to bulk update API keys: {str(e)}")
@router.patch(
"/{provider}/last-used",
responses={
200: {"description": "Last used timestamp updated"},
404: {"model": ErrorResponse, "description": "API key not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def update_last_used(provider: str, db: Session = Depends(get_db)):
"""Update the last used timestamp for an API key"""
try:
repo = ApiKeyRepository(db)
success = repo.update_last_used(provider)
if not success:
raise HTTPException(status_code=404, detail="API key not found")
return {"message": "Last used timestamp updated"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update last used timestamp: {str(e)}")
================================================
FILE: app/backend/routes/flow_runs.py
================================================
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from app.backend.database import get_db
from app.backend.repositories.flow_run_repository import FlowRunRepository
from app.backend.repositories.flow_repository import FlowRepository
from app.backend.models.schemas import (
FlowRunCreateRequest,
FlowRunUpdateRequest,
FlowRunResponse,
FlowRunSummaryResponse,
FlowRunStatus,
ErrorResponse
)
router = APIRouter(prefix="/flows/{flow_id}/runs", tags=["flow-runs"])
@router.post(
"/",
response_model=FlowRunResponse,
responses={
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def create_flow_run(
flow_id: int,
request: FlowRunCreateRequest,
db: Session = Depends(get_db)
):
"""Create a new flow run for the specified flow"""
try:
# Verify flow exists
flow_repo = FlowRepository(db)
flow = flow_repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
# Create the flow run
run_repo = FlowRunRepository(db)
flow_run = run_repo.create_flow_run(
flow_id=flow_id,
request_data=request.request_data
)
return FlowRunResponse.from_orm(flow_run)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create flow run: {str(e)}")
@router.get(
"/",
response_model=List[FlowRunSummaryResponse],
responses={
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_flow_runs(
flow_id: int,
limit: int = Query(50, ge=1, le=100, description="Maximum number of runs to return"),
offset: int = Query(0, ge=0, description="Number of runs to skip"),
db: Session = Depends(get_db)
):
"""Get all runs for the specified flow"""
try:
# Verify flow exists
flow_repo = FlowRepository(db)
flow = flow_repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
# Get flow runs
run_repo = FlowRunRepository(db)
flow_runs = run_repo.get_flow_runs_by_flow_id(flow_id, limit=limit, offset=offset)
return [FlowRunSummaryResponse.from_orm(run) for run in flow_runs]
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve flow runs: {str(e)}")
@router.get(
"/active",
response_model=Optional[FlowRunResponse],
responses={
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_active_flow_run(flow_id: int, db: Session = Depends(get_db)):
"""Get the current active (IN_PROGRESS) run for the specified flow"""
try:
# Verify flow exists
flow_repo = FlowRepository(db)
flow = flow_repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
# Get active flow run
run_repo = FlowRunRepository(db)
active_run = run_repo.get_active_flow_run(flow_id)
return FlowRunResponse.from_orm(active_run) if active_run else None
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve active flow run: {str(e)}")
@router.get(
"/latest",
response_model=Optional[FlowRunResponse],
responses={
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_latest_flow_run(flow_id: int, db: Session = Depends(get_db)):
"""Get the most recent run for the specified flow"""
try:
# Verify flow exists
flow_repo = FlowRepository(db)
flow = flow_repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
# Get latest flow run
run_repo = FlowRunRepository(db)
latest_run = run_repo.get_latest_flow_run(flow_id)
return FlowRunResponse.from_orm(latest_run) if latest_run else None
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve latest flow run: {str(e)}")
@router.get(
"/{run_id}",
response_model=FlowRunResponse,
responses={
404: {"model": ErrorResponse, "description": "Flow or run not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_flow_run(flow_id: int, run_id: int, db: Session = Depends(get_db)):
"""Get a specific flow run by ID"""
try:
# Verify flow exists
flow_repo = FlowRepository(db)
flow = flow_repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
# Get flow run
run_repo = FlowRunRepository(db)
flow_run = run_repo.get_flow_run_by_id(run_id)
if not flow_run or flow_run.flow_id != flow_id:
raise HTTPException(status_code=404, detail="Flow run not found")
return FlowRunResponse.from_orm(flow_run)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve flow run: {str(e)}")
@router.put(
"/{run_id}",
response_model=FlowRunResponse,
responses={
404: {"model": ErrorResponse, "description": "Flow or run not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def update_flow_run(
flow_id: int,
run_id: int,
request: FlowRunUpdateRequest,
db: Session = Depends(get_db)
):
"""Update an existing flow run"""
try:
# Verify flow exists
flow_repo = FlowRepository(db)
flow = flow_repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
# Update flow run
run_repo = FlowRunRepository(db)
# First verify the run exists and belongs to this flow
existing_run = run_repo.get_flow_run_by_id(run_id)
if not existing_run or existing_run.flow_id != flow_id:
raise HTTPException(status_code=404, detail="Flow run not found")
flow_run = run_repo.update_flow_run(
run_id=run_id,
status=request.status,
results=request.results,
error_message=request.error_message
)
if not flow_run:
raise HTTPException(status_code=404, detail="Flow run not found")
return FlowRunResponse.from_orm(flow_run)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update flow run: {str(e)}")
@router.delete(
"/{run_id}",
responses={
204: {"description": "Flow run deleted successfully"},
404: {"model": ErrorResponse, "description": "Flow or run not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def delete_flow_run(flow_id: int, run_id: int, db: Session = Depends(get_db)):
"""Delete a flow run"""
try:
# Verify flow exists
flow_repo = FlowRepository(db)
flow = flow_repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
# Verify run exists and belongs to this flow
run_repo = FlowRunRepository(db)
existing_run = run_repo.get_flow_run_by_id(run_id)
if not existing_run or existing_run.flow_id != flow_id:
raise HTTPException(status_code=404, detail="Flow run not found")
success = run_repo.delete_flow_run(run_id)
if not success:
raise HTTPException(status_code=404, detail="Flow run not found")
return {"message": "Flow run deleted successfully"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete flow run: {str(e)}")
@router.delete(
"/",
responses={
204: {"description": "All flow runs deleted successfully"},
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def delete_all_flow_runs(flow_id: int, db: Session = Depends(get_db)):
"""Delete all runs for the specified flow"""
try:
# Verify flow exists
flow_repo = FlowRepository(db)
flow = flow_repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
# Delete all flow runs
run_repo = FlowRunRepository(db)
deleted_count = run_repo.delete_flow_runs_by_flow_id(flow_id)
return {"message": f"Deleted {deleted_count} flow runs successfully"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete flow runs: {str(e)}")
@router.get(
"/count",
responses={
200: {"description": "Flow run count"},
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_flow_run_count(flow_id: int, db: Session = Depends(get_db)):
"""Get the total count of runs for the specified flow"""
try:
# Verify flow exists
flow_repo = FlowRepository(db)
flow = flow_repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
# Get run count
run_repo = FlowRunRepository(db)
count = run_repo.get_flow_run_count(flow_id)
return {"flow_id": flow_id, "total_runs": count}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get flow run count: {str(e)}")
================================================
FILE: app/backend/routes/flows.py
================================================
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.orm import Session
from typing import List
from app.backend.database import get_db
from app.backend.repositories.flow_repository import FlowRepository
from app.backend.models.schemas import (
FlowCreateRequest,
FlowUpdateRequest,
FlowResponse,
FlowSummaryResponse,
ErrorResponse
)
router = APIRouter(prefix="/flows", tags=["flows"])
@router.post(
"/",
response_model=FlowResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid request"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def create_flow(request: FlowCreateRequest, db: Session = Depends(get_db)):
"""Create a new hedge fund flow"""
try:
repo = FlowRepository(db)
flow = repo.create_flow(
name=request.name,
description=request.description,
nodes=request.nodes,
edges=request.edges,
viewport=request.viewport,
data=request.data,
is_template=request.is_template,
tags=request.tags
)
return FlowResponse.from_orm(flow)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create flow: {str(e)}")
@router.get(
"/",
response_model=List[FlowSummaryResponse],
responses={
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_flows(include_templates: bool = True, db: Session = Depends(get_db)):
"""Get all flows (summary view)"""
try:
repo = FlowRepository(db)
flows = repo.get_all_flows(include_templates=include_templates)
return [FlowSummaryResponse.from_orm(flow) for flow in flows]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve flows: {str(e)}")
@router.get(
"/{flow_id}",
response_model=FlowResponse,
responses={
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_flow(flow_id: int, db: Session = Depends(get_db)):
"""Get a specific flow by ID"""
try:
repo = FlowRepository(db)
flow = repo.get_flow_by_id(flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
return FlowResponse.from_orm(flow)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve flow: {str(e)}")
@router.put(
"/{flow_id}",
response_model=FlowResponse,
responses={
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def update_flow(flow_id: int, request: FlowUpdateRequest, db: Session = Depends(get_db)):
"""Update an existing flow"""
try:
repo = FlowRepository(db)
flow = repo.update_flow(
flow_id=flow_id,
name=request.name,
description=request.description,
nodes=request.nodes,
edges=request.edges,
viewport=request.viewport,
data=request.data,
is_template=request.is_template,
tags=request.tags
)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
return FlowResponse.from_orm(flow)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update flow: {str(e)}")
@router.delete(
"/{flow_id}",
responses={
204: {"description": "Flow deleted successfully"},
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def delete_flow(flow_id: int, db: Session = Depends(get_db)):
"""Delete a flow"""
try:
repo = FlowRepository(db)
success = repo.delete_flow(flow_id)
if not success:
raise HTTPException(status_code=404, detail="Flow not found")
return {"message": "Flow deleted successfully"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete flow: {str(e)}")
@router.post(
"/{flow_id}/duplicate",
response_model=FlowResponse,
responses={
404: {"model": ErrorResponse, "description": "Flow not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def duplicate_flow(flow_id: int, new_name: str = None, db: Session = Depends(get_db)):
"""Create a copy of an existing flow"""
try:
repo = FlowRepository(db)
flow = repo.duplicate_flow(flow_id, new_name)
if not flow:
raise HTTPException(status_code=404, detail="Flow not found")
return FlowResponse.from_orm(flow)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to duplicate flow: {str(e)}")
@router.get(
"/search/{name}",
response_model=List[FlowSummaryResponse],
responses={
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def search_flows(name: str, db: Session = Depends(get_db)):
"""Search flows by name"""
try:
repo = FlowRepository(db)
flows = repo.get_flows_by_name(name)
return [FlowSummaryResponse.from_orm(flow) for flow in flows]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to search flows: {str(e)}")
================================================
FILE: app/backend/routes/health.py
================================================
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
import asyncio
import json
router = APIRouter()
@router.get("/")
async def root():
return {"message": "Welcome to AI Hedge Fund API"}
@router.get("/ping")
async def ping():
async def event_generator():
for i in range(5):
# Create a JSON object for each ping
data = {"ping": f"ping {i+1}/5", "timestamp": i + 1}
# Format as SSE
yield f"data: {json.dumps(data)}\n\n"
# Wait 1 second
await asyncio.sleep(1)
return StreamingResponse(event_generator(), media_type="text/event-stream")
================================================
FILE: app/backend/routes/hedge_fund.py
================================================
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
import asyncio
from app.backend.database import get_db
from app.backend.models.schemas import ErrorResponse, HedgeFundRequest, BacktestRequest, BacktestDayResult, BacktestPerformanceMetrics
from app.backend.models.events import StartEvent, ProgressUpdateEvent, ErrorEvent, CompleteEvent
from app.backend.services.graph import create_graph, parse_hedge_fund_response, run_graph_async
from app.backend.services.portfolio import create_portfolio
from app.backend.services.backtest_service import BacktestService
from app.backend.services.api_key_service import ApiKeyService
from src.utils.progress import progress
from src.utils.analysts import get_agents_list
router = APIRouter(prefix="/hedge-fund")
@router.post(
path="/run",
responses={
200: {"description": "Successful response with streaming updates"},
400: {"model": ErrorResponse, "description": "Invalid request parameters"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def run(request_data: HedgeFundRequest, request: Request, db: Session = Depends(get_db)):
try:
# Hydrate API keys from database if not provided
if not request_data.api_keys:
api_key_service = ApiKeyService(db)
request_data.api_keys = api_key_service.get_api_keys_dict()
# Create the portfolio
portfolio = create_portfolio(request_data.initial_cash, request_data.margin_requirement, request_data.tickers, request_data.portfolio_positions)
# Construct agent graph using the React Flow graph structure
graph = create_graph(
graph_nodes=request_data.graph_nodes,
graph_edges=request_data.graph_edges
)
graph = graph.compile()
# Log a test progress update for debugging
progress.update_status("system", None, "Preparing hedge fund run")
# Convert model_provider to string if it's an enum
model_provider = request_data.model_provider
if hasattr(model_provider, "value"):
model_provider = model_provider.value
# Function to detect client disconnection
async def wait_for_disconnect():
"""Wait for client disconnect and return True when it happens"""
try:
while True:
message = await request.receive()
if message["type"] == "http.disconnect":
return True
except Exception:
return True
# Set up streaming response
async def event_generator():
# Queue for progress updates
progress_queue = asyncio.Queue()
run_task = None
disconnect_task = None
# Simple handler to add updates to the queue
def progress_handler(agent_name, ticker, status, analysis, timestamp):
event = ProgressUpdateEvent(agent=agent_name, ticker=ticker, status=status, timestamp=timestamp, analysis=analysis)
progress_queue.put_nowait(event)
# Register our handler with the progress tracker
progress.register_handler(progress_handler)
try:
# Start the graph execution in a background task
run_task = asyncio.create_task(
run_graph_async(
graph=graph,
portfolio=portfolio,
tickers=request_data.tickers,
start_date=request_data.start_date,
end_date=request_data.end_date,
model_name=request_data.model_name,
model_provider=model_provider,
request=request_data, # Pass the full request for agent-specific model access
)
)
# Start the disconnect detection task
disconnect_task = asyncio.create_task(wait_for_disconnect())
# Send initial message
yield StartEvent().to_sse()
# Stream progress updates until run_task completes or client disconnects
while not run_task.done():
# Check if client disconnected
if disconnect_task.done():
print("Client disconnected, cancelling hedge fund execution")
run_task.cancel()
try:
await run_task
except asyncio.CancelledError:
pass
return
# Either get a progress update or wait a bit
try:
event = await asyncio.wait_for(progress_queue.get(), timeout=1.0)
yield event.to_sse()
except asyncio.TimeoutError:
# Just continue the loop
pass
# Get the final result
try:
result = await run_task
except asyncio.CancelledError:
print("Task was cancelled")
return
if not result or not result.get("messages"):
yield ErrorEvent(message="Failed to generate hedge fund decisions").to_sse()
return
# Send the final result
final_data = CompleteEvent(
data={
"decisions": parse_hedge_fund_response(result.get("messages", [])[-1].content),
"analyst_signals": result.get("data", {}).get("analyst_signals", {}),
"current_prices": result.get("data", {}).get("current_prices", {}),
}
)
yield final_data.to_sse()
except asyncio.CancelledError:
print("Event generator cancelled")
return
finally:
# Clean up
progress.unregister_handler(progress_handler)
if run_task and not run_task.done():
run_task.cancel()
try:
await run_task
except asyncio.CancelledError:
pass
if disconnect_task and not disconnect_task.done():
disconnect_task.cancel()
# Return a streaming response
return StreamingResponse(event_generator(), media_type="text/event-stream")
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(status_code=500, detail=f"An error occurred while processing the request: {str(e)}")
@router.post(
path="/backtest",
responses={
200: {"description": "Successful response with streaming backtest updates"},
400: {"model": ErrorResponse, "description": "Invalid request parameters"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def backtest(request_data: BacktestRequest, request: Request, db: Session = Depends(get_db)):
"""Run a continuous backtest over a time period with streaming updates."""
try:
# Hydrate API keys from database if not provided
if not request_data.api_keys:
api_key_service = ApiKeyService(db)
request_data.api_keys = api_key_service.get_api_keys_dict()
# Convert model_provider to string if it's an enum
model_provider = request_data.model_provider
if hasattr(model_provider, "value"):
model_provider = model_provider.value
# Create the portfolio (same as /run endpoint)
portfolio = create_portfolio(
request_data.initial_capital,
request_data.margin_requirement,
request_data.tickers,
request_data.portfolio_positions
)
# Construct agent graph using the React Flow graph structure (same as /run endpoint)
graph = create_graph(graph_nodes=request_data.graph_nodes, graph_edges=request_data.graph_edges)
graph = graph.compile()
# Create backtest service with the compiled graph
backtest_service = BacktestService(
graph=graph,
portfolio=portfolio,
tickers=request_data.tickers,
start_date=request_data.start_date,
end_date=request_data.end_date,
initial_capital=request_data.initial_capital,
model_name=request_data.model_name,
model_provider=model_provider,
request=request_data, # Pass the full request for agent-specific model access
)
# Function to detect client disconnection
async def wait_for_disconnect():
"""Wait for client disconnect and return True when it happens"""
try:
while True:
message = await request.receive()
if message["type"] == "http.disconnect":
return True
except Exception:
return True
# Set up streaming response
async def event_generator():
progress_queue = asyncio.Queue()
backtest_task = None
disconnect_task = None
# Global progress handler to capture individual agent updates during backtest
def progress_handler(agent_name, ticker, status, analysis, timestamp):
event = ProgressUpdateEvent(agent=agent_name, ticker=ticker, status=status, timestamp=timestamp, analysis=analysis)
progress_queue.put_nowait(event)
# Progress callback to handle backtest-specific updates
def progress_callback(update):
if update["type"] == "progress":
event = ProgressUpdateEvent(
agent="backtest",
ticker=None,
status=f"Processing {update['current_date']} ({update['current_step']}/{update['total_dates']})",
timestamp=None,
analysis=None
)
progress_queue.put_nowait(event)
elif update["type"] == "backtest_result":
# Convert day result to a streaming event
backtest_result = BacktestDayResult(**update["data"])
# Send the full day result data as JSON in the analysis field
import json
analysis_data = json.dumps(update["data"])
event = ProgressUpdateEvent(
agent="backtest",
ticker=None,
status=f"Completed {backtest_result.date} - Portfolio: ${backtest_result.portfolio_value:,.2f}",
timestamp=None,
analysis=analysis_data
)
progress_queue.put_nowait(event)
# Register our handler with the progress tracker to capture agent updates
progress.register_handler(progress_handler)
try:
# Start the backtest in a background task
backtest_task = asyncio.create_task(
backtest_service.run_backtest_async(progress_callback=progress_callback)
)
# Start the disconnect detection task
disconnect_task = asyncio.create_task(wait_for_disconnect())
# Send initial message
yield StartEvent().to_sse()
# Stream progress updates until backtest_task completes or client disconnects
while not backtest_task.done():
# Check if client disconnected
if disconnect_task.done():
print("Client disconnected, cancelling backtest execution")
backtest_task.cancel()
try:
await backtest_task
except asyncio.CancelledError:
pass
return
# Either get a progress update or wait a bit
try:
event = await asyncio.wait_for(progress_queue.get(), timeout=1.0)
yield event.to_sse()
except asyncio.TimeoutError:
# Just continue the loop
pass
# Get the final result
try:
result = await backtest_task
except asyncio.CancelledError:
print("Backtest task was cancelled")
return
if not result:
yield ErrorEvent(message="Failed to complete backtest").to_sse()
return
# Send the final result
performance_metrics = BacktestPerformanceMetrics(**result["performance_metrics"])
final_data = CompleteEvent(
data={
"performance_metrics": performance_metrics.model_dump(),
"final_portfolio": result["final_portfolio"],
"total_days": len(result["results"]),
}
)
yield final_data.to_sse()
except asyncio.CancelledError:
print("Backtest event generator cancelled")
return
finally:
# Clean up
progress.unregister_handler(progress_handler)
if backtest_task and not backtest_task.done():
backtest_task.cancel()
try:
await backtest_task
except asyncio.CancelledError:
pass
if disconnect_task and not disconnect_task.done():
disconnect_task.cancel()
# Return a streaming response
return StreamingResponse(event_generator(), media_type="text/event-stream")
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(status_code=500, detail=f"An error occurred while processing the backtest request: {str(e)}")
@router.get(
path="/agents",
responses={
200: {"description": "List of available agents"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_agents():
"""Get the list of available agents."""
try:
return {"agents": get_agents_list()}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve agents: {str(e)}")
================================================
FILE: app/backend/routes/language_models.py
================================================
from fastapi import APIRouter, HTTPException
from typing import List, Dict, Any
from app.backend.models.schemas import ErrorResponse
from app.backend.services.ollama_service import OllamaService
from src.llm.models import get_models_list
router = APIRouter(prefix="/language-models")
# Initialize Ollama service
ollama_service = OllamaService()
@router.get(
path="/",
responses={
200: {"description": "List of available language models"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_language_models():
"""Get the list of available cloud-based and Ollama language models."""
try:
# Start with cloud models
models = get_models_list()
# Add available Ollama models (handles all checking internally)
ollama_models = await ollama_service.get_available_models()
models.extend(ollama_models)
return {"models": models}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve models: {str(e)}")
@router.get(
path="/providers",
responses={
200: {"description": "List of available model providers"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_language_model_providers():
"""Get the list of available model providers with their models grouped."""
try:
models = get_models_list()
# Group models by provider
providers = {}
for model in models:
provider_name = model["provider"]
if provider_name not in providers:
providers[provider_name] = {
"name": provider_name,
"models": []
}
providers[provider_name]["models"].append({
"display_name": model["display_name"],
"model_name": model["model_name"]
})
return {"providers": list(providers.values())}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve providers: {str(e)}")
================================================
FILE: app/backend/routes/ollama.py
================================================
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import List, Dict, Any
import logging
from app.backend.models.schemas import ErrorResponse
from app.backend.services.ollama_service import ollama_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ollama")
class ModelRequest(BaseModel):
model_name: str
class OllamaStatusResponse(BaseModel):
installed: bool
running: bool
available_models: List[str]
server_url: str
error: str | None = None
class ActionResponse(BaseModel):
success: bool
message: str
class RecommendedModel(BaseModel):
display_name: str
model_name: str
provider: str
class ProgressResponse(BaseModel):
status: str
percentage: float | None = None
message: str | None = None
phase: str | None = None
bytes_downloaded: int | None = None
total_bytes: int | None = None
@router.get(
"/status",
response_model=OllamaStatusResponse,
responses={
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_ollama_status():
"""Get Ollama installation and server status."""
try:
status = await ollama_service.check_ollama_status()
return OllamaStatusResponse(**status)
except Exception as e:
logger.error(f"Failed to check Ollama status: {e}")
raise HTTPException(status_code=500, detail=f"Failed to check Ollama status: {str(e)}")
@router.post(
"/start",
response_model=ActionResponse,
responses={
400: {"model": ErrorResponse, "description": "Bad request"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def start_ollama_server():
"""Start the Ollama server."""
try:
# First check if it's already running
status = await ollama_service.check_ollama_status()
if not status["installed"]:
raise HTTPException(status_code=400, detail="Ollama is not installed on this system")
if status["running"]:
return ActionResponse(success=True, message="Ollama server is already running")
result = await ollama_service.start_server()
if not result["success"]:
logger.error(f"Failed to start Ollama server: {result['message']}")
raise HTTPException(status_code=500, detail=result["message"])
return ActionResponse(**result)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error starting Ollama server: {e}")
raise HTTPException(status_code=500, detail=f"Failed to start Ollama server: {str(e)}")
@router.post(
"/stop",
response_model=ActionResponse,
responses={
400: {"model": ErrorResponse, "description": "Bad request"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def stop_ollama_server():
"""Stop the Ollama server."""
try:
# First check if it's installed
status = await ollama_service.check_ollama_status()
if not status["installed"]:
raise HTTPException(status_code=400, detail="Ollama is not installed on this system")
if not status["running"]:
return ActionResponse(success=True, message="Ollama server is already stopped")
result = await ollama_service.stop_server()
if not result["success"]:
logger.error(f"Failed to stop Ollama server: {result['message']}")
raise HTTPException(status_code=500, detail=result["message"])
return ActionResponse(**result)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error stopping Ollama server: {e}")
raise HTTPException(status_code=500, detail=f"Failed to stop Ollama server: {str(e)}")
@router.post(
"/models/download",
response_model=ActionResponse,
responses={
400: {"model": ErrorResponse, "description": "Bad request"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def download_model(request: ModelRequest):
"""Download an Ollama model (legacy endpoint)."""
try:
logger.info(f"Download request for model: {request.model_name}")
# Check current status
status = await ollama_service.check_ollama_status()
logger.debug(f"Current Ollama status: installed={status['installed']}, running={status['running']}")
if not status["installed"]:
raise HTTPException(status_code=400, detail="Ollama is not installed on this system")
if not status["running"]:
raise HTTPException(status_code=400, detail="Ollama server is not running. Please start it first.")
result = await ollama_service.download_model(request.model_name)
if not result["success"]:
logger.error(f"Failed to download model {request.model_name}: {result['message']}")
raise HTTPException(status_code=500, detail=result["message"])
logger.info(f"Successfully downloaded model: {request.model_name}")
return ActionResponse(**result)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error downloading model {request.model_name}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to download model: {str(e)}")
@router.post(
"/models/download/progress",
responses={
400: {"model": ErrorResponse, "description": "Bad request"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def download_model_with_progress(request: ModelRequest):
"""Download an Ollama model with real-time progress updates via Server-Sent Events."""
try:
logger.info(f"Progress download request for model: {request.model_name}")
# Check current status
status = await ollama_service.check_ollama_status()
logger.debug(f"Current Ollama status: installed={status['installed']}, running={status['running']}")
if not status["installed"]:
raise HTTPException(status_code=400, detail="Ollama is not installed on this system")
if not status["running"]:
raise HTTPException(status_code=400, detail="Ollama server is not running. Please start it first.")
# Return Server-Sent Events stream
return StreamingResponse(
ollama_service.download_model_with_progress(request.model_name),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "*",
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error setting up progress download for {request.model_name}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to start progress download: {str(e)}")
@router.get(
"/models/download/progress/{model_name}",
response_model=ProgressResponse,
responses={
404: {"model": ErrorResponse, "description": "Model download not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_download_progress(model_name: str):
"""Get current download progress for a specific model."""
try:
progress = ollama_service.get_download_progress(model_name)
if progress is None:
raise HTTPException(status_code=404, detail=f"No active download found for model: {model_name}")
return ProgressResponse(**progress)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting download progress for {model_name}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get download progress: {str(e)}")
@router.get(
"/models/downloads/active",
response_model=Dict[str, ProgressResponse],
responses={
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_active_downloads():
"""Get all currently active model downloads."""
try:
active_downloads = {}
all_progress = ollama_service.get_all_download_progress()
# Only return downloads that are actually active (not completed, error, or cancelled)
for model_name, progress in all_progress.items():
if progress.get("status") in ["starting", "downloading"]:
active_downloads[model_name] = ProgressResponse(**progress)
return active_downloads
except Exception as e:
logger.error(f"Error getting active downloads: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get active downloads: {str(e)}")
@router.delete(
"/models/{model_name}",
response_model=ActionResponse,
responses={
400: {"model": ErrorResponse, "description": "Bad request"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def delete_model(model_name: str):
"""Delete an Ollama model."""
try:
logger.info(f"Delete request for model: {model_name}")
# Check current status
status = await ollama_service.check_ollama_status()
logger.debug(f"Current Ollama status: installed={status['installed']}, running={status['running']}")
if not status["installed"]:
raise HTTPException(status_code=400, detail="Ollama is not installed on this system")
if not status["running"]:
raise HTTPException(status_code=400, detail="Ollama server is not running. Please start it first.")
result = await ollama_service.delete_model(model_name)
if not result["success"]:
logger.error(f"Failed to delete model {model_name}: {result['message']}")
raise HTTPException(status_code=500, detail=result["message"])
logger.info(f"Successfully deleted model: {model_name}")
return ActionResponse(**result)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error deleting model {model_name}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}")
@router.get(
"/models/recommended",
response_model=List[RecommendedModel],
responses={
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_recommended_models():
"""Get list of recommended Ollama models."""
try:
models = await ollama_service.get_recommended_models()
return [RecommendedModel(**model) for model in models]
except Exception as e:
logger.error(f"Failed to get recommended models: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get recommended models: {str(e)}")
@router.delete(
"/models/download/{model_name}",
response_model=ActionResponse,
responses={
404: {"model": ErrorResponse, "description": "Download not found"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def cancel_download(model_name: str):
"""Cancel an active model download."""
try:
logger.info(f"Cancel download request for model: {model_name}")
success = ollama_service.cancel_download(model_name)
if success:
return ActionResponse(success=True, message=f"Download cancelled for {model_name}")
else:
raise HTTPException(status_code=404, detail=f"No active download found for model: {model_name}")
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error cancelling download for {model_name}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to cancel download: {str(e)}")
================================================
FILE: app/backend/routes/storage.py
================================================
from fastapi import APIRouter, HTTPException
import json
from pathlib import Path
from pydantic import BaseModel
from app.backend.models.schemas import ErrorResponse
router = APIRouter(prefix="/storage")
class SaveJsonRequest(BaseModel):
filename: str
data: dict
@router.post(
path="/save-json",
responses={
200: {"description": "File saved successfully"},
400: {"model": ErrorResponse, "description": "Invalid request parameters"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def save_json_file(request: SaveJsonRequest):
"""Save JSON data to the project's /outputs directory."""
try:
# Create outputs directory if it doesn't exist
project_root = Path(__file__).parent.parent.parent.parent # Navigate to project root
outputs_dir = project_root / "outputs"
outputs_dir.mkdir(exist_ok=True)
# Construct file path
file_path = outputs_dir / request.filename
# Save JSON data to file
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(request.data, f, indent=2, ensure_ascii=False)
return {
"success": True,
"message": f"File saved successfully to {file_path}",
"filename": request.filename
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
================================================
FILE: app/backend/services/__init__.py
================================================
================================================
FILE: app/backend/services/agent_service.py
================================================
from functools import partial
from typing import Callable
from src.graph.state import AgentState
def create_agent_function(agent_function: Callable, agent_id: str) -> Callable[[AgentState], dict]:
"""
Creates a new function from an agent function that accepts an agent_id.
:param agent_function: The agent function to wrap.
:param agent_id: The ID to be passed to the agent.
:return: A new function that can be called by LangGraph.
"""
return partial(agent_function, agent_id=agent_id)
================================================
FILE: app/backend/services/api_key_service.py
================================================
from sqlalchemy.orm import Session
from typing import Dict, Optional
from app.backend.repositories.api_key_repository import ApiKeyRepository
class ApiKeyService:
"""Simple service to load API keys for requests"""
def __init__(self, db: Session):
self.repository = ApiKeyRepository(db)
def get_api_keys_dict(self) -> Dict[str, str]:
"""
Load all active API keys from database and return as a dictionary
suitable for injecting into requests
"""
api_keys = self.repository.get_all_api_keys(include_inactive=False)
return {key.provider: key.key_value for key in api_keys}
def get_api_key(self, provider: str) -> Optional[str]:
"""Get a specific API key by provider"""
api_key = self.repository.get_api_key_by_provider(provider)
return api_key.key_value if api_key else None
================================================
FILE: app/backend/services/backtest_service.py
================================================
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import pandas as pd
import numpy as np
from typing import Callable, Dict, List, Optional, Any
import asyncio
from src.tools.api import (
get_company_news,
get_price_data,
get_prices,
get_financial_metrics,
get_insider_trades,
)
from app.backend.services.graph import run_graph_async, parse_hedge_fund_response
from app.backend.services.portfolio import create_portfolio
class BacktestService:
"""
Core backtesting service that focuses purely on backtesting logic.
Uses a pre-compiled graph and portfolio for trading decisions.
"""
def __init__(
self,
graph,
portfolio: dict,
tickers: List[str],
start_date: str,
end_date: str,
initial_capital: float,
model_name: str = "gpt-4.1",
model_provider: str = "OpenAI",
request: dict = {},
):
"""
Initialize the backtest service.
:param graph: Pre-compiled LangGraph graph for trading decisions.
:param portfolio: Initial portfolio state.
:param tickers: List of tickers to backtest.
:param start_date: Start date string (YYYY-MM-DD).
:param end_date: End date string (YYYY-MM-DD).
:param initial_capital: Starting portfolio cash.
:param model_name: Which LLM model name to use.
:param model_provider: Which LLM provider.
:param request: Request object containing API keys and other metadata.
"""
self.graph = graph
self.portfolio = portfolio
self.tickers = tickers
self.start_date = start_date
self.end_date = end_date
self.initial_capital = initial_capital
self.model_name = model_name
self.model_provider = model_provider
self.request = request
self.portfolio_values = []
def execute_trade(self, ticker: str, action: str, quantity: float, current_price: float) -> int:
"""
Execute trades with support for both long and short positions.
Returns the actual quantity traded.
"""
if quantity <= 0:
return 0
quantity = int(quantity) # force integer shares
position = self.portfolio["positions"][ticker]
if action == "buy":
cost = quantity * current_price
if cost <= self.portfolio["cash"]:
# Weighted average cost basis for the new total
old_shares = position["long"]
old_cost_basis = position["long_cost_basis"]
new_shares = quantity
total_shares = old_shares + new_shares
if total_shares > 0:
total_old_cost = old_cost_basis * old_shares
total_new_cost = cost
position["long_cost_basis"] = (total_old_cost + total_new_cost) / total_shares
position["long"] += quantity
self.portfolio["cash"] -= cost
return quantity
else:
# Calculate maximum affordable quantity
max_quantity = int(self.portfolio["cash"] / current_price)
if max_quantity > 0:
cost = max_quantity * current_price
old_shares = position["long"]
old_cost_basis = position["long_cost_basis"]
total_shares = old_shares + max_quantity
if total_shares > 0:
total_old_cost = old_cost_basis * old_shares
total_new_cost = cost
position["long_cost_basis"] = (total_old_cost + total_new_cost) / total_shares
position["long"] += max_quantity
self.portfolio["cash"] -= cost
return max_quantity
return 0
elif action == "sell":
quantity = min(quantity, position["long"])
if quantity > 0:
avg_cost_per_share = position["long_cost_basis"] if position["long"] > 0 else 0
realized_gain = (current_price - avg_cost_per_share) * quantity
self.portfolio["realized_gains"][ticker]["long"] += realized_gain
position["long"] -= quantity
self.portfolio["cash"] += quantity * current_price
if position["long"] == 0:
position["long_cost_basis"] = 0.0
return quantity
elif action == "short":
proceeds = current_price * quantity
margin_required = proceeds * self.portfolio["margin_requirement"]
available_cash = max(
0.0, self.portfolio["cash"] - self.portfolio["margin_used"]
)
if margin_required <= available_cash:
# Weighted average short cost basis
old_short_shares = position["short"]
old_cost_basis = position["short_cost_basis"]
new_shares = quantity
total_shares = old_short_shares + new_shares
if total_shares > 0:
total_old_cost = old_cost_basis * old_short_shares
total_new_cost = current_price * new_shares
position["short_cost_basis"] = (total_old_cost + total_new_cost) / total_shares
position["short"] += quantity
position["short_margin_used"] += margin_required
self.portfolio["margin_used"] += margin_required
self.portfolio["cash"] += proceeds
self.portfolio["cash"] -= margin_required
return quantity
else:
margin_ratio = self.portfolio["margin_requirement"]
if margin_ratio > 0:
max_quantity = int(available_cash / (current_price * margin_ratio))
else:
max_quantity = 0
if max_quantity > 0:
proceeds = current_price * max_quantity
margin_required = proceeds * margin_ratio
old_short_shares = position["short"]
old_cost_basis = position["short_cost_basis"]
total_shares = old_short_shares + max_quantity
if total_shares > 0:
total_old_cost = old_cost_basis * old_short_shares
total_new_cost = current_price * max_quantity
position["short_cost_basis"] = (total_old_cost + total_new_cost) / total_shares
position["short"] += max_quantity
position["short_margin_used"] += margin_required
self.portfolio["margin_used"] += margin_required
self.portfolio["cash"] += proceeds
self.portfolio["cash"] -= margin_required
return max_quantity
return 0
elif action == "cover":
quantity = min(quantity, position["short"])
if quantity > 0:
cover_cost = quantity * current_price
avg_short_price = position["short_cost_basis"] if position["short"] > 0 else 0
realized_gain = (avg_short_price - current_price) * quantity
if position["short"] > 0:
portion = quantity / position["short"]
else:
portion = 1.0
margin_to_release = portion * position["short_margin_used"]
position["short"] -= quantity
position["short_margin_used"] -= margin_to_release
self.portfolio["margin_used"] -= margin_to_release
self.portfolio["cash"] += margin_to_release
self.portfolio["cash"] -= cover_cost
self.portfolio["realized_gains"][ticker]["short"] += realized_gain
if position["short"] == 0:
position["short_cost_basis"] = 0.0
position["short_margin_used"] = 0.0
return quantity
return 0
def calculate_portfolio_value(self, current_prices: Dict[str, float]) -> float:
"""Calculate total portfolio value."""
total_value = self.portfolio["cash"]
for ticker in self.tickers:
position = self.portfolio["positions"][ticker]
price = current_prices[ticker]
# Long position value
long_value = position["long"] * price
total_value += long_value
# Short position unrealized PnL
if position["short"] > 0:
total_value -= position["short"] * price
return total_value
def prefetch_data(self):
"""Pre-fetch all data needed for the backtest period."""
end_date_dt = datetime.strptime(self.end_date, "%Y-%m-%d")
start_date_dt = end_date_dt - relativedelta(years=1)
start_date_str = start_date_dt.strftime("%Y-%m-%d")
api_key = self.request.api_keys.get("FINANCIAL_DATASETS_API_KEY")
for ticker in self.tickers:
get_prices(ticker, start_date_str, self.end_date, api_key=api_key)
get_financial_metrics(ticker, self.end_date, limit=10, api_key=api_key)
get_insider_trades(ticker, self.end_date, start_date=self.start_date, limit=1000, api_key=api_key)
get_company_news(ticker, self.end_date, start_date=self.start_date, limit=1000, api_key=api_key)
def _update_performance_metrics(self, performance_metrics: Dict[str, Any]):
"""Update performance metrics using daily returns."""
values_df = pd.DataFrame(self.portfolio_values).set_index("Date")
values_df["Daily Return"] = values_df["Portfolio Value"].pct_change()
clean_returns = values_df["Daily Return"].dropna()
if len(clean_returns) < 2:
return
daily_risk_free_rate = 0.0434 / 252
excess_returns = clean_returns - daily_risk_free_rate
mean_excess_return = excess_returns.mean()
std_excess_return = excess_returns.std()
# Sharpe ratio
if std_excess_return > 1e-12:
performance_metrics["sharpe_ratio"] = np.sqrt(252) * (mean_excess_return / std_excess_return)
else:
performance_metrics["sharpe_ratio"] = 0.0
# Sortino ratio
negative_returns = excess_returns[excess_returns < 0]
if len(negative_returns) > 0:
downside_std = negative_returns.std()
if downside_std > 1e-12:
performance_metrics["sortino_ratio"] = np.sqrt(252) * (mean_excess_return / downside_std)
else:
performance_metrics["sortino_ratio"] = None if mean_excess_return > 0 else 0
else:
performance_metrics["sortino_ratio"] = None if mean_excess_return > 0 else 0
# Maximum drawdown
rolling_max = values_df["Portfolio Value"].cummax()
drawdown = (values_df["Portfolio Value"] - rolling_max) / rolling_max
if len(drawdown) > 0:
min_drawdown = drawdown.min()
performance_metrics["max_drawdown"] = min_drawdown * 100
if min_drawdown < 0:
performance_metrics["max_drawdown_date"] = drawdown.idxmin().strftime("%Y-%m-%d")
else:
performance_metrics["max_drawdown_date"] = None
else:
performance_metrics["max_drawdown"] = 0.0
performance_metrics["max_drawdown_date"] = None
async def run_backtest_async(self, progress_callback: Optional[Callable] = None) -> Dict[str, Any]:
"""
Run the backtest asynchronously with optional progress callbacks.
Uses the pre-compiled graph for trading decisions.
"""
# Pre-fetch all data at the start
self.prefetch_data()
dates = pd.date_range(self.start_date, self.end_date, freq="B")
performance_metrics = {
"sharpe_ratio": 0.0,
"sortino_ratio": 0.0,
"max_drawdown": 0.0,
"long_short_ratio": 0.0,
"gross_exposure": 0.0,
"net_exposure": 0.0,
}
# Initialize portfolio values
if len(dates) > 0:
self.portfolio_values = [{"Date": dates[0], "Portfolio Value": self.initial_capital}]
else:
self.portfolio_values = []
backtest_results = []
for i, current_date in enumerate(dates):
# Allow other async operations to run
await asyncio.sleep(0)
lookback_start = (current_date - timedelta(days=30)).strftime("%Y-%m-%d")
current_date_str = current_date.strftime("%Y-%m-%d")
previous_date_str = (current_date - timedelta(days=1)).strftime("%Y-%m-%d")
if lookback_start == current_date_str:
continue
# Send progress update if callback provided
if progress_callback:
progress_callback({
"type": "progress",
"current_date": current_date_str,
"progress": (i + 1) / len(dates),
"total_dates": len(dates),
"current_step": i + 1,
})
# Get current prices
try:
current_prices = {}
missing_data = False
for ticker in self.tickers:
try:
price_data = get_price_data(ticker, previous_date_str, current_date_str)
if price_data.empty:
missing_data = True
break
current_prices[ticker] = price_data.iloc[-1]["close"]
except Exception as e:
missing_data = True
break
if missing_data:
continue
except Exception:
continue
# Create portfolio for this iteration
portfolio_for_graph = create_portfolio(
initial_cash=self.portfolio["cash"],
margin_requirement=self.portfolio["margin_requirement"],
tickers=self.tickers,
portfolio_positions=[] # We'll handle positions manually
)
# Copy current portfolio state to the graph portfolio
portfolio_for_graph.update(self.portfolio)
# Execute graph-based agent decisions
try:
result = await run_graph_async(
graph=self.graph,
portfolio=portfolio_for_graph,
tickers=self.tickers,
start_date=lookback_start,
end_date=current_date_str,
model_name=self.model_name,
model_provider=self.model_provider,
request=self.request,
)
# Parse the decisions from the graph result
if result and result.get("messages"):
decisions = parse_hedge_fund_response(result["messages"][-1].content)
analyst_signals = result.get("data", {}).get("analyst_signals", {})
else:
decisions = {}
analyst_signals = {}
except Exception as e:
print(f"Error running graph for {current_date_str}: {e}")
decisions = {}
analyst_signals = {}
# Execute trades based on decisions
executed_trades = {}
for ticker in self.tickers:
decision = decisions.get(ticker, {"action": "hold", "quantity": 0})
action, quantity = decision.get("action", "hold"), decision.get("quantity", 0)
executed_quantity = self.execute_trade(ticker, action, quantity, current_prices[ticker])
executed_trades[ticker] = executed_quantity
# Calculate portfolio value
total_value = self.calculate_portfolio_value(current_prices)
# Calculate exposures
long_exposure = sum(self.portfolio["positions"][t]["long"] * current_prices[t] for t in self.tickers)
short_exposure = sum(self.portfolio["positions"][t]["short"] * current_prices[t] for t in self.tickers)
gross_exposure = long_exposure + short_exposure
net_exposure = long_exposure - short_exposure
long_short_ratio = long_exposure / short_exposure if short_exposure > 1e-9 else None
# Track portfolio value
self.portfolio_values.append({
"Date": current_date,
"Portfolio Value": total_value,
"Long Exposure": long_exposure,
"Short Exposure": short_exposure,
"Gross Exposure": gross_exposure,
"Net Exposure": net_exposure,
"Long/Short Ratio": long_short_ratio,
})
# Calculate performance metrics for this day
portfolio_return = (total_value / self.initial_capital - 1) * 100
# Update performance metrics if we have enough data
if len(self.portfolio_values) > 2:
self._update_performance_metrics(performance_metrics)
# Build detailed result for this date (similar to CLI format)
date_result = {
"date": current_date_str,
"portfolio_value": total_value,
"cash": self.portfolio["cash"],
"decisions": decisions,
"executed_trades": executed_trades,
"analyst_signals": analyst_signals,
"current_prices": current_prices,
"long_exposure": long_exposure,
"short_exposure": short_exposure,
"gross_exposure": gross_exposure,
"net_exposure": net_exposure,
"long_short_ratio": long_short_ratio,
"portfolio_return": portfolio_return,
"performance_metrics": performance_metrics.copy(),
# Add detailed trading information for each ticker
"ticker_details": []
}
# Build ticker details (similar to CLI format_backtest_row)
for ticker in self.tickers:
ticker_signals = {}
for agent_name, signals in analyst_signals.items():
if ticker in signals:
ticker_signals[agent_name] = signals[ticker]
bullish_count = len([s for s in ticker_signals.values() if s.get("signal", "").lower() == "bullish"])
bearish_count = len([s for s in ticker_signals.values() if s.get("signal", "").lower() == "bearish"])
neutral_count = len([s for s in ticker_signals.values() if s.get("signal", "").lower() == "neutral"])
# Calculate net position value
pos = self.portfolio["positions"][ticker]
long_val = pos["long"] * current_prices[ticker]
short_val = pos["short"] * current_prices[ticker]
net_position_value = long_val - short_val
# Get the action and quantity from the decisions
action = decisions.get(ticker, {}).get("action", "hold")
quantity = executed_trades.get(ticker, 0)
ticker_detail = {
"ticker": ticker,
"action": action,
"quantity": quantity,
"price": current_prices[ticker],
"shares_owned": pos["long"] - pos["short"], # net shares
"long_shares": pos["long"],
"short_shares": pos["short"],
"position_value": net_position_value,
"bullish_count": bullish_count,
"bearish_count": bearish_count,
"neutral_count": neutral_count,
}
date_result["ticker_details"].append(ticker_detail)
backtest_results.append(date_result)
# Send intermediate result if callback provided
if progress_callback:
progress_callback({
"type": "backtest_result",
"data": date_result,
})
# Ensure final performance metrics are calculated
if len(self.portfolio_values) > 1:
self._update_performance_metrics(performance_metrics)
# Calculate final exposures if we have results
if backtest_results:
final_result = backtest_results[-1]
performance_metrics["gross_exposure"] = final_result["gross_exposure"]
performance_metrics["net_exposure"] = final_result["net_exposure"]
performance_metrics["long_short_ratio"] = final_result["long_short_ratio"]
# Store final performance metrics
self.performance_metrics = performance_metrics
return {
"results": backtest_results,
"performance_metrics": performance_metrics,
"portfolio_values": self.portfolio_values,
"final_portfolio": self.portfolio,
}
def run_backtest_sync(self) -> Dict[str, Any]:
"""
Run the backtest synchronously.
This version can be used by the CLI.
"""
# Use asyncio to run the async version
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(self.run_backtest_async())
finally:
loop.close()
def analyze_performance(self) -> pd.DataFrame:
"""Analyze performance and return DataFrame with metrics."""
if not self.portfolio_values:
return pd.DataFrame()
performance_df = pd.DataFrame(self.portfolio_values).set_index("Date")
if performance_df.empty:
return performance_df
# Calculate additional metrics
performance_df["Daily Return"] = performance_df["Portfolio Value"].pct_change().fillna(0)
return performance_df
================================================
FILE: app/backend/services/graph.py
================================================
import asyncio
import json
import re
from langchain_core.messages import HumanMessage
from langgraph.graph import END, StateGraph
from app.backend.services.agent_service import create_agent_function
from src.agents.portfolio_manager import portfolio_management_agent
from src.agents.risk_manager import risk_management_agent
from src.main import start
from src.utils.analysts import ANALYST_CONFIG
from src.graph.state import AgentState
def extract_base_agent_key(unique_id: str) -> str:
"""
Extract the base agent key from a unique node ID.
Args:
unique_id: The unique node ID with suffix (e.g., "warren_buffett_abc123")
Returns:
The base agent key (e.g., "warren_buffett")
"""
# For agent nodes, remove the last underscore and 6-character suffix
parts = unique_id.split('_')
if len(parts) >= 2:
last_part = parts[-1]
# If the last part is a 6-character alphanumeric string, it's likely our suffix
if len(last_part) == 6 and re.match(r'^[a-z0-9]+$', last_part):
return '_'.join(parts[:-1])
return unique_id # Return original if no suffix pattern found
# Helper function to create the agent graph
def create_graph(graph_nodes: list, graph_edges: list) -> StateGraph:
"""Create the workflow based on the React Flow graph structure."""
graph = StateGraph(AgentState)
graph.add_node("start_node", start)
# Get analyst nodes from the configuration
analyst_nodes = {key: (f"{key}_agent", config["agent_func"]) for key, config in ANALYST_CONFIG.items()}
# Extract agent IDs from graph structure
agent_ids = [node.id for node in graph_nodes]
agent_ids_set = set(agent_ids)
# Track which nodes are portfolio managers for special handling
portfolio_manager_nodes = set()
# Add agent nodes
for unique_agent_id in agent_ids:
base_agent_key = extract_base_agent_key(unique_agent_id)
# Track portfolio manager nodes for special handling (before ANALYST_CONFIG check)
if base_agent_key == "portfolio_manager":
portfolio_manager_nodes.add(unique_agent_id)
continue
# Skip if the base agent key is not in our analyst configuration
if base_agent_key not in ANALYST_CONFIG:
continue
node_name, node_func = analyst_nodes[base_agent_key]
agent_function = create_agent_function(node_func, unique_agent_id)
graph.add_node(unique_agent_id, agent_function)
# Add portfolio manager nodes and their corresponding risk managers
risk_manager_nodes = {} # Map portfolio manager ID to risk manager ID
for portfolio_manager_id in portfolio_manager_nodes:
portfolio_manager_function = create_agent_function(portfolio_management_agent, portfolio_manager_id)
graph.add_node(portfolio_manager_id, portfolio_manager_function)
# Create unique risk manager for this portfolio manager
suffix = portfolio_manager_id.split('_')[-1]
risk_manager_id = f"risk_management_agent_{suffix}"
risk_manager_nodes[portfolio_manager_id] = risk_manager_id
# Add the risk manager node
risk_manager_function = create_agent_function(risk_management_agent, risk_manager_id)
graph.add_node(risk_manager_id, risk_manager_function)
# Build connections based on React Flow graph structure
nodes_with_incoming_edges = set()
nodes_with_outgoing_edges = set()
direct_to_portfolio_managers = {} # Map analyst ID to portfolio manager ID for direct connections
for edge in graph_edges:
# Only consider edges between agent nodes (not from stock tickers)
if edge.source in agent_ids_set and edge.target in agent_ids_set:
source_base_key = extract_base_agent_key(edge.source)
target_base_key = extract_base_agent_key(edge.target)
nodes_with_incoming_edges.add(edge.target)
nodes_with_outgoing_edges.add(edge.source)
# Check if this is a direct connection from analyst to portfolio manager
if (source_base_key in ANALYST_CONFIG and
source_base_key != "portfolio_manager" and
target_base_key == "portfolio_manager"):
# Don't add direct edge to portfolio manager - we'll route through risk manager
direct_to_portfolio_managers[edge.source] = edge.target
else:
# Add edge between agent nodes (but not direct to portfolio managers)
graph.add_edge(edge.source, edge.target)
# Connect start_node to nodes that don't have incoming edges from other agents
for agent_id in agent_ids:
if agent_id not in nodes_with_incoming_edges:
base_agent_key = extract_base_agent_key(agent_id)
if base_agent_key in ANALYST_CONFIG and base_agent_key != "portfolio_manager":
graph.add_edge("start_node", agent_id)
# Connect analysts that have direct connections to portfolio managers to their corresponding risk managers
for analyst_id, portfolio_manager_id in direct_to_portfolio_managers.items():
risk_manager_id = risk_manager_nodes[portfolio_manager_id]
graph.add_edge(analyst_id, risk_manager_id)
# Connect each risk manager to its corresponding portfolio manager
for portfolio_manager_id, risk_manager_id in risk_manager_nodes.items():
graph.add_edge(risk_manager_id, portfolio_manager_id)
# Connect portfolio managers to END
for portfolio_manager_id in portfolio_manager_nodes:
graph.add_edge(portfolio_manager_id, END)
# Set the entry point to the start node
graph.set_entry_point("start_node")
return graph
async def run_graph_async(graph, portfolio, tickers, start_date, end_date, model_name, model_provider, request=None):
"""Async wrapper for run_graph to work with asyncio."""
# Use run_in_executor to run the synchronous function in a separate thread
# so it doesn't block the event loop
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, lambda: run_graph(graph, portfolio, tickers, start_date, end_date, model_name, model_provider, request)) # Use default executor
return result
def run_graph(
graph: StateGraph,
portfolio: dict,
tickers: list[str],
start_date: str,
end_date: str,
model_name: str,
model_provider: str,
request=None,
) -> dict:
"""
Run the graph with the given portfolio, tickers,
start date, end date, show reasoning, model name,
and model provider.
"""
return graph.invoke(
{
"messages": [
HumanMessage(
content="Make trading decisions based on the provided data.",
)
],
"data": {
"tickers": tickers,
"portfolio": portfolio,
"start_date": start_date,
"end_date": end_date,
"analyst_signals": {},
},
"metadata": {
"show_reasoning": False,
"model_name": model_name,
"model_provider": model_provider,
"request": request, # Pass the request for agent-specific model access
},
},
)
def parse_hedge_fund_response(response):
"""Parses a JSON string and returns a dictionary."""
try:
return json.loads(response)
except json.JSONDecodeError as e:
print(f"JSON decoding error: {e}\nResponse: {repr(response)}")
return None
except TypeError as e:
print(f"Invalid response type (expected string, got {type(response).__name__}): {e}")
return None
except Exception as e:
print(f"Unexpected error while parsing response: {e}\nResponse: {repr(response)}")
return None
================================================
FILE: app/backend/services/ollama_service.py
================================================
import asyncio
import os
import sys
import platform
import subprocess
import time
import re
import json
import queue
import threading
from pathlib import Path
from typing import Dict, List, Optional, AsyncGenerator
import logging
import signal
import ollama
logger = logging.getLogger(__name__)
class OllamaService:
"""Service for managing Ollama integration in the backend."""
def __init__(self):
self._download_progress = {}
self._download_processes = {}
# Initialize async client
self._async_client = ollama.AsyncClient()
self._sync_client = ollama.Client()
# =============================================================================
# PUBLIC API METHODS
# =============================================================================
async def check_ollama_status(self) -> Dict[str, any]:
"""Check Ollama installation and server status."""
try:
is_installed = await self._check_installation()
is_running = await self._check_server_running()
models, server_url = await self._get_server_info(is_running)
status = {
"installed": is_installed,
"running": is_running,
"server_running": is_running, # Backward compatibility
"available_models": models,
"server_url": server_url,
"error": None
}
logger.debug(f"Ollama status: installed={is_installed}, running={is_running}, models={len(models)}")
return status
except Exception as e:
logger.error(f"Error checking Ollama status: {e}")
return self._create_error_status(str(e))
async def start_server(self) -> Dict[str, any]:
"""Start the Ollama server."""
try:
success = await self._execute_server_start()
message = "Ollama server started successfully" if success else "Failed to start Ollama server"
return {"success": success, "message": message}
except Exception as e:
logger.error(f"Error starting Ollama server: {e}")
return {"success": False, "message": f"Error starting server: {str(e)}"}
async def stop_server(self) -> Dict[str, any]:
"""Stop the Ollama server."""
try:
success = await self._execute_server_stop()
message = "Ollama server stopped successfully" if success else "Failed to stop Ollama server"
return {"success": success, "message": message}
except Exception as e:
logger.error(f"Error stopping Ollama server: {e}")
return {"success": False, "message": f"Error stopping server: {str(e)}"}
async def download_model(self, model_name: str) -> Dict[str, any]:
"""Download an Ollama model."""
try:
success = await self._execute_model_download(model_name)
message = f"Model {model_name} downloaded successfully" if success else f"Failed to download model {model_name}"
return {"success": success, "message": message}
except Exception as e:
logger.error(f"Error downloading model {model_name}: {e}")
return {"success": False, "message": f"Error downloading model: {str(e)}"}
async def download_model_with_progress(self, model_name: str) -> AsyncGenerator[str, None]:
"""Download an Ollama model with progress streaming."""
async for progress_data in self._stream_model_download(model_name):
yield progress_data
async def delete_model(self, model_name: str) -> Dict[str, any]:
"""Delete an Ollama model."""
try:
success = await self._execute_model_deletion(model_name)
message = f"Model {model_name} deleted successfully" if success else f"Failed to delete model {model_name}"
return {"success": success, "message": message}
except Exception as e:
logger.error(f"Error deleting model {model_name}: {e}")
return {"success": False, "message": f"Error deleting model: {str(e)}"}
async def get_recommended_models(self) -> List[Dict[str, str]]:
"""Get list of recommended Ollama models."""
try:
models_path = self._get_ollama_models_path()
if models_path.exists():
return self._load_models_from_file(models_path)
else:
return self._get_fallback_models()
except Exception as e:
logger.error(f"Error loading recommended models: {e}")
return []
async def get_available_models(self) -> List[Dict[str, str]]:
"""Get available Ollama models formatted for the language models API.
Returns only models that are:
1. Server is running
2. Model is downloaded locally
3. Model is in our recommended list (OLLAMA_MODELS)
"""
try:
status = await self.check_ollama_status()
if not status.get("server_running", False):
logger.debug("Ollama server not running, returning no models for API")
return []
downloaded_models = status.get("available_models", [])
if not downloaded_models:
logger.debug("No Ollama models downloaded, returning empty list for API")
return []
api_models = self._format_models_for_api(downloaded_models)
logger.debug(f"Returning {len(api_models)} Ollama models for language models API")
return api_models
except Exception as e:
logger.error(f"Error getting available models for API: {e}")
return [] # Return empty list on error to not break the API
def get_download_progress(self, model_name: str) -> Optional[Dict[str, any]]:
"""Get current download progress for a model."""
return self._download_progress.get(model_name)
def get_all_download_progress(self) -> Dict[str, Dict[str, any]]:
"""Get current download progress for all models."""
return self._download_progress.copy()
def cancel_download(self, model_name: str) -> bool:
"""Cancel an active download."""
logger.warning(f"Download cancellation not directly supported by ollama client for model: {model_name}")
if model_name in self._download_progress:
self._download_progress[model_name] = {
"status": "cancelled",
"message": f"Download of {model_name} was cancelled",
"error": "Download cancelled by user"
}
return True
return False
# =============================================================================
# PRIVATE HELPER METHODS
# =============================================================================
def _create_error_status(self, error: str) -> Dict[str, any]:
"""Create error status response."""
return {
"installed": False,
"running": False,
"server_running": False,
"available_models": [],
"server_url": "",
"error": error
}
async def _check_installation(self) -> bool:
"""Check if Ollama CLI is installed."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._is_ollama_installed)
def _is_ollama_installed(self) -> bool:
"""Check if Ollama is installed on the system."""
system = platform.system().lower()
command = ["which", "ollama"] if system in ["darwin", "linux"] else ["where", "ollama"]
shell = system == "windows"
try:
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, shell=shell)
return result.returncode == 0
except Exception:
return False
async def _check_server_running(self) -> bool:
"""Check if the Ollama server is running using the ollama client."""
try:
await self._async_client.list()
logger.debug("Ollama server confirmed running via client")
return True
except Exception as e:
logger.debug(f"Ollama server not reachable: {e}")
return False
async def _get_server_info(self, is_running: bool) -> tuple[List[str], str]:
"""Get server information (models and URL) if server is running."""
if not is_running:
return [], ""
try:
response = await self._async_client.list()
models = [model.model for model in response.models]
server_url = getattr(self._async_client, 'host', 'http://localhost:11434')
logger.debug(f"Found {len(models)} locally available models")
return models, server_url
except Exception as e:
logger.debug(f"Failed to get server info: {e}")
return [], ""
async def _execute_server_start(self) -> bool:
"""Execute server start operation."""
# Check if already running
try:
self._sync_client.list()
logger.info("Ollama server is already running")
return True
except Exception:
pass # Server not running, continue to start it
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._start_ollama_process)
def _start_ollama_process(self) -> bool:
"""Start the Ollama server process."""
system = platform.system().lower()
try:
command = ["ollama", "serve"]
shell = system == "windows"
subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=shell)
return self._wait_for_server_start()
except Exception as e:
logger.error(f"Error starting Ollama server: {e}")
return False
def _wait_for_server_start(self) -> bool:
"""Wait for server to start and become ready."""
logger.info("Starting Ollama server, waiting for it to become ready...")
for i in range(20): # Try for 20 seconds
time.sleep(1)
try:
self._sync_client.list()
logger.info(f"Ollama server started successfully after {i+1} seconds")
return True
except Exception:
logger.debug(f"Waiting for Ollama server... ({i+1}/20)")
continue
logger.error("Ollama server failed to start within 20 seconds")
return False
async def _execute_server_stop(self) -> bool:
"""Execute server stop operation."""
# Check if already stopped
try:
self._sync_client.list()
except Exception:
logger.info("Ollama server is already stopped")
return True
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._stop_ollama_process)
def _stop_ollama_process(self) -> bool:
"""Stop the Ollama server process."""
system = platform.system().lower()
try:
if system in ["darwin", "linux"]:
return self._stop_unix_process()
elif system == "windows":
return self._stop_windows_process()
else:
return False
except Exception as e:
logger.error(f"Error stopping Ollama server: {e}")
return False
def _stop_unix_process(self) -> bool:
"""Stop Ollama on Unix-like systems."""
try:
result = subprocess.run(
["pgrep", "-f", "ollama serve"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if result.returncode == 0:
pids = [pid for pid in result.stdout.strip().split('\n') if pid]
self._terminate_processes(pids)
return self._verify_server_stopped()
except Exception as e:
logger.error(f"Error stopping Unix process: {e}")
return False
def _stop_windows_process(self) -> bool:
"""Stop Ollama on Windows."""
try:
subprocess.run(
["taskkill", "/F", "/IM", "ollama.exe"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
return self._verify_server_stopped()
except Exception as e:
logger.error(f"Error stopping Windows process: {e}")
return False
def _terminate_processes(self, pids: List[str]) -> None:
"""Terminate processes gracefully, then forcefully if needed."""
# Try SIGTERM first
for pid in pids:
if pid:
try:
os.kill(int(pid), signal.SIGTERM)
except (ValueError, ProcessLookupError, PermissionError):
continue
# Wait for graceful termination
for _ in range(5):
try:
self._sync_client.list()
time.sleep(1)
except Exception:
return # Server stopped
# Force kill if still running
for pid in pids:
if pid:
try:
os.kill(int(pid), signal.SIGKILL)
except (ValueError, ProcessLookupError, PermissionError):
continue
def _verify_server_stopped(self) -> bool:
"""Verify that the server has stopped."""
for _ in range(3):
try:
self._sync_client.list()
time.sleep(1)
except Exception:
return True
return False
async def _execute_model_download(self, model_name: str) -> bool:
"""Execute model download operation."""
if not await self._check_server_running():
logger.error(f"Cannot download model {model_name}: Ollama server is not running")
return False
try:
logger.info(f"Starting download of model: {model_name}")
await self._async_client.pull(model_name)
logger.info(f"Successfully downloaded model: {model_name}")
return True
except Exception as e:
logger.error(f"Error downloading model {model_name}: {e}")
return False
async def _execute_model_deletion(self, model_name: str) -> bool:
"""Execute model deletion operation."""
if not await self._check_server_running():
logger.error(f"Cannot delete model {model_name}: Ollama server is not running")
return False
try:
logger.info(f"Deleting model: {model_name}")
await self._async_client.delete(model_name)
logger.info(f"Successfully deleted model: {model_name}")
return True
except Exception as e:
logger.error(f"Error deleting model {model_name}: {e}")
return False
async def _stream_model_download(self, model_name: str) -> AsyncGenerator[str, None]:
"""Stream model download with progress updates."""
try:
if not await self._check_server_running():
yield f"data: {json.dumps({'status': 'error', 'error': 'Ollama server is not running'})}\n\n"
return
logger.info(f"Starting download of model: {model_name}")
self._download_progress[model_name] = {"status": "starting", "percentage": 0}
yield f"data: {json.dumps({'status': 'starting', 'percentage': 0, 'message': f'Starting download of {model_name}...'})}\n\n"
# Await the pull method to get the async iterator
pull_stream = await self._async_client.pull(model_name, stream=True)
async for progress in pull_stream:
progress_data = self._process_download_progress(progress, model_name)
if progress_data:
yield f"data: {json.dumps(progress_data)}\n\n"
if progress_data.get("status") == "completed":
logger.info(f"Successfully downloaded model: {model_name}")
break
except Exception as e:
error_data = {
"status": "error",
"message": f"Error downloading model {model_name}",
"error": str(e)
}
self._download_progress[model_name] = error_data
yield f"data: {json.dumps(error_data)}\n\n"
logger.error(f"Error downloading model {model_name}: {e}")
finally:
await asyncio.sleep(1)
if model_name in self._download_progress:
del self._download_progress[model_name]
def _process_download_progress(self, progress, model_name: str) -> Optional[Dict[str, any]]:
"""Process download progress from ollama client."""
if not hasattr(progress, 'status'):
return None
progress_data = {
"status": "downloading",
"message": progress.status,
"raw_output": progress.status
}
# Add completed/total info if available
if (hasattr(progress, 'completed') and hasattr(progress, 'total') and
progress.total is not None and progress.completed is not None and progress.total > 0):
percentage = (progress.completed / progress.total) * 100
progress_data.update({
"percentage": percentage,
"bytes_downloaded": progress.completed,
"total_bytes": progress.total
})
# Add digest info if available
if hasattr(progress, 'digest'):
progress_data["digest"] = progress.digest
# Store in cache
self._download_progress[model_name] = progress_data
# Check if download is complete
if (progress.status == "success" or
(hasattr(progress, 'completed') and hasattr(progress, 'total') and
progress.completed is not None and progress.total is not None and
progress.completed == progress.total)):
final_data = {
"status": "completed",
"percentage": 100,
"message": f"Model {model_name} downloaded successfully!"
}
self._download_progress[model_name] = final_data
return final_data
return progress_data
def _get_ollama_models_path(self) -> Path:
"""Get path to ollama_models.json file."""
return Path(__file__).parent.parent.parent.parent / "src" / "llm" / "ollama_models.json"
def _load_models_from_file(self, models_path: Path) -> List[Dict[str, str]]:
"""Load models from JSON file."""
with open(models_path, 'r') as f:
return json.load(f)
def _get_fallback_models(self) -> List[Dict[str, str]]:
"""Get fallback models when file is not available."""
return [
{"display_name": "[meta] llama3.1 (8B)", "model_name": "llama3.1:latest", "provider": "Ollama"},
{"display_name": "[google] gemma3 (4B)", "model_name": "gemma3:4b", "provider": "Ollama"},
{"display_name": "[alibaba] qwen3 (4B)", "model_name": "qwen3:4b", "provider": "Ollama"},
]
def _format_models_for_api(self, downloaded_models: List[str]) -> List[Dict[str, str]]:
"""Format downloaded models for API response."""
# Import OLLAMA_MODELS here to avoid circular imports
from src.llm.models import OLLAMA_MODELS
api_models = []
for ollama_model in OLLAMA_MODELS:
if ollama_model.model_name in downloaded_models:
api_models.append({
"display_name": ollama_model.display_name,
"model_name": ollama_model.model_name,
"provider": "Ollama"
})
return api_models
# Global service instance
ollama_service = OllamaService()
================================================
FILE: app/backend/services/portfolio.py
================================================
from typing import Optional, List
from app.backend.models.schemas import PortfolioPosition
def create_portfolio(initial_cash: float, margin_requirement: float, tickers: list[str], portfolio_positions: Optional[List[PortfolioPosition]] = None) -> dict:
# Initialize base portfolio structure
portfolio = {
"cash": initial_cash, # Initial cash amount
"margin_requirement": margin_requirement, # Initial margin requirement
"margin_used": 0.0, # total margin usage across all short positions
"positions": {
ticker: {
"long": 0, # Number of shares held long
"short": 0, # Number of shares held short
"long_cost_basis": 0.0, # Average cost basis for long positions
"short_cost_basis": 0.0, # Average price at which shares were sold short
"short_margin_used": 0.0, # Dollars of margin used for this ticker's short
}
for ticker in tickers
},
"realized_gains": {
ticker: {
"long": 0.0, # Realized gains from long positions
"short": 0.0, # Realized gains from short positions
}
for ticker in tickers
},
}
# If portfolio positions are provided, populate them
if portfolio_positions:
for position in portfolio_positions:
ticker = position.ticker
quantity = position.quantity
trade_price = position.trade_price
# Ensure ticker exists in portfolio (it should from tickers list)
if ticker in portfolio["positions"]:
if quantity > 0:
# Positive quantity means long position
portfolio["positions"][ticker]["long"] = quantity
portfolio["positions"][ticker]["long_cost_basis"] = trade_price
elif quantity < 0:
# Negative quantity means short position
portfolio["positions"][ticker]["short"] = abs(quantity)
portfolio["positions"][ticker]["short_cost_basis"] = trade_price
# Calculate margin used for short position
portfolio["positions"][ticker]["short_margin_used"] = abs(quantity) * trade_price * margin_requirement
portfolio["margin_used"] += portfolio["positions"][ticker]["short_margin_used"]
return portfolio
================================================
FILE: app/frontend/.eslintrc.cjs
================================================
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
================================================
FILE: app/frontend/.github/dependabot.yml
================================================
# docs:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'daily'
allow:
- dependency-name: '@xyflow/react'
================================================
FILE: app/frontend/.gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
================================================
FILE: app/frontend/LICENSE
================================================
MIT License
Copyright (c) 2023 webkid GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: app/frontend/README.md
================================================
# AI Hedge Fund - Frontend [WIP] 🚧
This project is currently a work in progress. To track progress, please get updates [here](https://x.com/virattt).
This is the frontend application for the AI Hedge Fund project. It provides a web interface to interact with the AI Hedge Fund system, allowing you to visualize and control the hedge fund operations.
## Overview
This frontend project is built with React and Vite, serving as the client-side component of the AI Hedge Fund system. It connects to the backend API to provide a user-friendly interface for managing the hedge fund trading system and backtester.
## Installation
The project contains the minimum dependencies to get up and running, and includes eslint with additional rules to help write clean React code:
```bash
npm install # or `pnpm install` or `yarn install`
```
## Running the Application
Start the application with:
```bash
npm run dev
```
While the application is running, changes made to the code will be automatically reflected in the browser!
## Disclaimer
This project is for **educational and research purposes only**.
- Not intended for real trading or investment
- No warranties or guarantees provided
- Creator assumes no liability for financial losses
- Consult a financial advisor for investment decisions
By using this software, you agree to use it solely for learning purposes.
================================================
FILE: app/frontend/components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
================================================
FILE: app/frontend/index.html
================================================
AI Hedge Fund
================================================
FILE: app/frontend/package.json
================================================
{
"name": "vite-react-flow-template",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.507.0",
"next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable-panels": "^3.0.1",
"react-syntax-highlighter": "^15.6.1",
"shadcn-ui": "^0.9.5",
"sonner": "^2.0.5",
"tailwind-merge": "^3.2.0"
},
"license": "MIT",
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.15.3",
"@types/react": "^18.2.53",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}
================================================
FILE: app/frontend/postcss.config.mjs
================================================
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
================================================
FILE: app/frontend/src/App.tsx
================================================
import { Layout } from './components/layout';
import { Toaster } from './components/ui/sonner';
export default function App() {
return (
<>
>
);
}
================================================
FILE: app/frontend/src/components/Flow.tsx
================================================
import {
Background,
BackgroundVariant,
ColorMode,
Connection,
Edge,
EdgeChange,
MarkerType,
NodeChange,
ReactFlow,
addEdge,
useEdgesState,
useNodesState
} from '@xyflow/react';
import { useTheme } from 'next-themes';
import { useCallback, useEffect, useRef, useState } from 'react';
import '@xyflow/react/dist/style.css';
import { useFlowContext } from '@/contexts/flow-context';
import { useEnhancedFlowActions } from '@/hooks/use-enhanced-flow-actions';
import { useFlowHistory } from '@/hooks/use-flow-history';
import { useFlowKeyboardShortcuts, useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useToastManager } from '@/hooks/use-toast-manager';
import { AppNode } from '@/nodes/types';
import { edgeTypes } from '../edges';
import { nodeTypes } from '../nodes';
import { TooltipProvider } from './ui/tooltip';
type FlowProps = {
className?: string;
};
export function Flow({ className = '' }: FlowProps) {
const { theme, resolvedTheme } = useTheme();
// Use the resolved theme for ReactFlow ColorMode
const colorMode: ColorMode = resolvedTheme === 'light' ? 'light' : 'dark';
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [isInitialized, setIsInitialized] = useState(false);
const proOptions = { hideAttribution: true };
// Get flow context for flow ID
const { currentFlowId } = useFlowContext();
// Get enhanced flow actions for complete state persistence
const { saveCurrentFlowWithCompleteState } = useEnhancedFlowActions();
// Get toast manager
const { success, error } = useToastManager();
// Initialize flow history (each flow maintains its own separate history)
const { takeSnapshot, undo, redo, canUndo, canRedo, clearHistory } = useFlowHistory({ flowId: currentFlowId });
// Create debounced auto-save function
const autoSaveTimeoutRef = useRef(null);
const lastSavedFlowIdRef = useRef(null);
const autoSave = useCallback(async (flowIdToSave?: number | null) => {
// Use the provided flowId or fall back to current flow ID
const targetFlowId = flowIdToSave !== undefined ? flowIdToSave : currentFlowId;
// Clear any existing timeout
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
// Set new timeout for debounced save
autoSaveTimeoutRef.current = setTimeout(async () => {
// Double-check that we're still saving to the correct flow
if (!targetFlowId) {
return;
}
// If the current flow has changed since this auto-save was scheduled, skip it
if (targetFlowId !== currentFlowId) {
return;
}
try {
await saveCurrentFlowWithCompleteState();
lastSavedFlowIdRef.current = targetFlowId;
} catch (error) {
console.error(`[Auto-save] Failed to save flow ${targetFlowId}:`, error);
}
}, 1000); // 1 second debounce
}, [currentFlowId, saveCurrentFlowWithCompleteState]);
// Enhanced onNodesChange handler with auto-save for specific change types
const handleNodesChange = useCallback((changes: NodeChange[]) => {
// Apply the changes first
onNodesChange(changes);
// Check if any of the changes should trigger auto-save
const shouldAutoSave = changes.some(change => {
switch (change.type) {
case 'add':
return true;
case 'remove':
return true;
case 'position':
// Only auto-save position changes when dragging is complete
if (!change.dragging) {
return true;
}
return false;
default:
return false;
}
});
// Trigger auto-save if needed and flow is initialized
// IMPORTANT: Capture the current flow ID at the time of the change
if (shouldAutoSave && isInitialized && currentFlowId) {
const flowIdAtTimeOfChange = currentFlowId;
autoSave(flowIdAtTimeOfChange);
}
}, [onNodesChange, autoSave, isInitialized, currentFlowId]);
// Enhanced onEdgesChange handler with auto-save for edge removal
const handleEdgesChange = useCallback((changes: EdgeChange[]) => {
// Apply the changes first
onEdgesChange(changes);
// Check if any of the changes should trigger auto-save
const shouldAutoSave = changes.some(change => {
switch (change.type) {
case 'remove':
return true;
default:
return false;
}
});
// Trigger auto-save if needed and flow is initialized
// IMPORTANT: Capture the current flow ID at the time of the change
if (shouldAutoSave && isInitialized && currentFlowId) {
const flowIdAtTimeOfChange = currentFlowId;
autoSave(flowIdAtTimeOfChange);
}
}, [onEdgesChange, autoSave, isInitialized, currentFlowId]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, []);
// Cancel pending auto-saves when flow changes to prevent cross-flow saves
useEffect(() => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
}, [currentFlowId]);
// Take initial snapshot when flow is initialized
useEffect(() => {
if (isInitialized && nodes.length === 0 && edges.length === 0) {
takeSnapshot();
}
}, [isInitialized, takeSnapshot, nodes.length, edges.length]);
// Take snapshot when nodes or edges change (debounced)
useEffect(() => {
if (!isInitialized) return;
const timeoutId = setTimeout(() => {
takeSnapshot();
}, 500); // Debounce snapshots by 500ms
return () => clearTimeout(timeoutId);
}, [nodes, edges, takeSnapshot, isInitialized]);
// // Auto-save when nodes or edges change (debounced with longer delay)
// useEffect(() => {
// if (!isInitialized) return;
// const timeoutId = setTimeout(async () => {
// try {
// await saveCurrentFlowWithCompleteState();
// // Don't show success toast for auto-save to avoid spam
// } catch (err) {
// // Only show error notifications for auto-save failures
// error('Auto-save failed', 'auto-save-error');
// }
// }, 1000); // Debounce auto-save by 1 second (longer than undo/redo)
// return () => clearTimeout(timeoutId);
// }, [nodes, edges, saveCurrentFlowWithCompleteState, error, isInitialized]);
// Connect keyboard shortcuts to save flow with toast
useFlowKeyboardShortcuts(async () => {
try {
const savedFlow = await saveCurrentFlowWithCompleteState();
if (savedFlow) {
success(`"${savedFlow.name}" saved!`, 'flow-save');
} else {
error('Failed to save flow', 'flow-save-error');
}
} catch (err) {
error('Failed to save flow', 'flow-save-error');
}
});
// Add undo/redo keyboard shortcuts
useKeyboardShortcuts({
shortcuts: [
{
key: 'z',
ctrlKey: true,
metaKey: true,
callback: undo,
preventDefault: true,
},
{
key: 'z',
ctrlKey: true,
metaKey: true,
shiftKey: true,
callback: redo,
preventDefault: true,
},
],
});
// Initialize the flow when it first renders
const onInit = useCallback(() => {
if (!isInitialized) {
setIsInitialized(true);
}
}, [isInitialized]);
// Connect two nodes with marker
const onConnect = useCallback(
(connection: Connection) => {
// Create a new edge with a marker and unique ID
const newEdge: Edge = {
...connection,
id: `edge-${Date.now()}`, // Add unique ID
markerEnd: {
type: MarkerType.ArrowClosed,
},
};
setEdges((eds) => addEdge(newEdge, eds));
// Auto-save new connections immediately (structural change)
if (currentFlowId) {
// IMPORTANT: Capture the current flow ID at the time of the change
const flowIdAtTimeOfChange = currentFlowId;
// Clear any pending debounced saves and save immediately
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
// Use setTimeout to ensure the edge is added to state first
setTimeout(async () => {
// Double-check that we're still saving to the correct flow
if (flowIdAtTimeOfChange !== currentFlowId) {
return;
}
try {
await saveCurrentFlowWithCompleteState();
} catch (error) {
console.error(`[Auto-save] Failed to save new connection for flow ${flowIdAtTimeOfChange}:`, error);
}
}, 100);
}
},
[setEdges, currentFlowId, saveCurrentFlowWithCompleteState]
);
// Theme-aware background colors
const backgroundStyle = {
backgroundColor: 'hsl(var(--background))'
};
const gridColor = resolvedTheme === 'light' ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))';
return (
{/* */}
);
}
================================================
FILE: app/frontend/src/components/Layout.tsx
================================================
import { BottomPanel } from '@/components/panels/bottom/bottom-panel';
import { LeftSidebar } from '@/components/panels/left/left-sidebar';
import { RightSidebar } from '@/components/panels/right/right-sidebar';
import { TabBar } from '@/components/tabs/tab-bar';
import { TabContent } from '@/components/tabs/tab-content';
import { SidebarProvider } from '@/components/ui/sidebar';
import { FlowProvider, useFlowContext } from '@/contexts/flow-context';
import { LayoutProvider, useLayoutContext } from '@/contexts/layout-context';
import { TabsProvider, useTabsContext } from '@/contexts/tabs-context';
import { useLayoutKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { cn } from '@/lib/utils';
import { SidebarStorageService } from '@/services/sidebar-storage';
import { TabService } from '@/services/tab-service';
import { ReactFlowProvider } from '@xyflow/react';
import { ReactNode, useEffect, useState } from 'react';
import { TopBar } from './layout/top-bar';
// Create a LayoutContent component to access the FlowContext, TabsContext, and LayoutContext
function LayoutContent({ children }: { children: ReactNode }) {
const { reactFlowInstance } = useFlowContext();
const { openTab } = useTabsContext();
const { isBottomCollapsed, expandBottomPanel, collapseBottomPanel, toggleBottomPanel } = useLayoutContext();
// Initialize sidebar states from storage service
const [isLeftCollapsed, setIsLeftCollapsed] = useState(() =>
SidebarStorageService.loadLeftSidebarState(false)
);
const [isRightCollapsed, setIsRightCollapsed] = useState(() =>
SidebarStorageService.loadRightSidebarState(false)
);
// Track actual sidebar widths for dynamic positioning
const [leftSidebarWidth, setLeftSidebarWidth] = useState(280);
const [rightSidebarWidth, setRightSidebarWidth] = useState(280);
const [bottomPanelHeight, setBottomPanelHeight] = useState(300);
const handleSettingsClick = () => {
const tabData = TabService.createSettingsTab();
openTab(tabData);
};
// Add keyboard shortcuts for toggling sidebars and fit view
useLayoutKeyboardShortcuts(
() => setIsRightCollapsed(!isRightCollapsed), // Cmd+I for right sidebar
() => setIsLeftCollapsed(!isLeftCollapsed), // Cmd+B for left sidebar
() => reactFlowInstance.fitView({ padding: 0.1, duration: 500 }), // Cmd+O for fit view
// Note: undo/redo will be handled directly in the Flow component for now
undefined, // undo
undefined, // redo
toggleBottomPanel, // Cmd+J for bottom panel
handleSettingsClick, // Shift+Cmd+J for settings
);
// Save sidebar states whenever they change
useEffect(() => {
SidebarStorageService.saveLeftSidebarState(isLeftCollapsed);
}, [isLeftCollapsed]);
useEffect(() => {
SidebarStorageService.saveRightSidebarState(isRightCollapsed);
}, [isRightCollapsed]);
// Calculate tab bar and bottom panel positioning based on actual sidebar widths
const getSidebarBasedStyle = () => {
let left = 0;
let right = 0;
if (!isLeftCollapsed) {
left = leftSidebarWidth;
}
if (!isRightCollapsed) {
right = rightSidebarWidth;
}
return {
left: `${left}px`,
right: `${right}px`,
};
};
// Calculate main content positioning accounting for tab bar height
const getMainContentStyle = () => {
const tabBarHeight = 40; // Approximate tab bar height
let top = tabBarHeight;
let bottom = 0;
if (!isBottomCollapsed) {
bottom = bottomPanelHeight;
}
return {
top: `${top}px`,
bottom: `${bottom}px`,
left: '0',
right: '0',
width: 'auto',
height: 'auto',
};
};
return (
{/* VSCode-style Top Bar */}
setIsLeftCollapsed(!isLeftCollapsed)}
onToggleRight={() => setIsRightCollapsed(!isRightCollapsed)}
onToggleBottom={toggleBottomPanel}
onSettingsClick={handleSettingsClick}
/>
{/* Tab Bar - positioned absolutely like bottom panel */}
{/* Main content area */}
{/* Floating left sidebar */}
setIsLeftCollapsed(true)}
onExpand={() => setIsLeftCollapsed(false)}
onWidthChange={setLeftSidebarWidth}
/>
{/* Floating right sidebar */}
setIsRightCollapsed(true)}
onExpand={() => setIsRightCollapsed(false)}
onWidthChange={setRightSidebarWidth}
/>
{/* Bottom panel */}
);
}
interface LayoutProps {
children: ReactNode;
}
export function Layout({ children }: LayoutProps) {
return (
{children}
);
}
================================================
FILE: app/frontend/src/components/custom-controls.tsx
================================================
import { ResetIcon } from '@radix-ui/react-icons';
import { ControlButton, Controls } from '@xyflow/react';
type CustomControlsProps = {
onReset: () => void;
};
export function CustomControls({ onReset }: CustomControlsProps) {
return (
);
}
================================================
FILE: app/frontend/src/components/layout/top-bar.tsx
================================================
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { PanelBottom, PanelLeft, PanelRight, Settings } from 'lucide-react';
interface TopBarProps {
isLeftCollapsed: boolean;
isRightCollapsed: boolean;
isBottomCollapsed: boolean;
onToggleLeft: () => void;
onToggleRight: () => void;
onToggleBottom: () => void;
onSettingsClick: () => void;
}
export function TopBar({
isLeftCollapsed,
isRightCollapsed,
isBottomCollapsed,
onToggleLeft,
onToggleRight,
onToggleBottom,
onSettingsClick,
}: TopBarProps) {
return (
{/* Left Sidebar Toggle */}
{/* Bottom Panel Toggle */}
{/* Right Sidebar Toggle */}
{/* Divider */}
{/* Settings */}
);
}
================================================
FILE: app/frontend/src/components/panels/bottom/bottom-panel.tsx
================================================
import { useLayoutContext } from '@/contexts/layout-context';
import { useResizable } from '@/hooks/use-resizable';
import { cn } from '@/lib/utils';
import { FileText, X } from 'lucide-react';
import { ReactNode, useEffect } from 'react';
import { Button } from '../../ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
import { OutputTab } from './tabs';
interface BottomPanelProps {
children?: ReactNode;
isCollapsed: boolean;
onCollapse: () => void;
onExpand: () => void;
onToggleCollapse: () => void;
onHeightChange?: (height: number) => void;
}
export function BottomPanel({
isCollapsed,
onToggleCollapse,
onHeightChange,
}: BottomPanelProps) {
const { currentBottomTab, setBottomPanelTab } = useLayoutContext();
// Use our custom hooks for vertical resizing
const { height, isDragging, elementRef, startResize } = useResizable({
defaultHeight: 300,
minHeight: 200,
maxHeight: window.innerHeight,
side: 'bottom',
});
// Notify parent component of height changes
useEffect(() => {
onHeightChange?.(height);
}, [height, onHeightChange]);
if (isCollapsed) {
return null;
}
return (
{/* Resize handle - on the top for bottom panel */}
{!isDragging && (
)}
{/* Header with tabs and close button */}
{/* Content area */}
);
}
================================================
FILE: app/frontend/src/components/panels/bottom/tabs/backtest-output.tsx
================================================
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { cn } from '@/lib/utils';
import { MoreHorizontal } from 'lucide-react';
import { getActionColor } from './output-tab-utils';
// Component for displaying backtest progress
function BacktestProgress({ agentData }: { agentData: Record }) {
const backtestAgent = agentData['backtest'];
if (!backtestAgent) return null;
// Get the latest backtest result from the backtest results array
const backtestResults = backtestAgent.backtestResults || [];
const latestBacktestResult = backtestResults.length > 0 ? backtestResults[backtestResults.length - 1] : null;
return (
Backtest Progress
{/* Current Status */}
Backtest Runner
{backtestAgent.message || backtestAgent.status}
);
}
// Component for displaying backtest trading table (similar to CLI)
function BacktestTradingTable({ agentData }: { agentData: Record }) {
const backtestAgent = agentData['backtest'];
// console.log("backtestAgent", backtestAgent);
if (!backtestAgent || !backtestAgent.backtestResults) {
return null;
}
// Get the backtest results directly from the agent data
const backtestResults = backtestAgent.backtestResults || [];
if (backtestResults.length === 0) {
return null;
}
// Build table rows similar to CLI format
const tableRows: any[] = [];
backtestResults.forEach((backtestResult: any) => {
// Add ticker rows for this period
if (backtestResult.ticker_details) {
backtestResult.ticker_details.forEach((ticker: any) => {
tableRows.push({
type: 'ticker',
date: backtestResult.date,
ticker: ticker.ticker,
action: ticker.action,
quantity: ticker.quantity,
price: ticker.price,
shares_owned: ticker.shares_owned,
long_shares: ticker.long_shares,
short_shares: ticker.short_shares,
position_value: ticker.position_value,
bullish_count: ticker.bullish_count,
bearish_count: ticker.bearish_count,
neutral_count: ticker.neutral_count,
});
});
}
// Add portfolio summary row for this period
tableRows.push({
type: 'summary',
date: backtestResult.date,
portfolio_value: backtestResult.portfolio_value,
cash: backtestResult.cash,
portfolio_return: backtestResult.portfolio_return,
total_position_value: backtestResult.portfolio_value - backtestResult.cash,
performance_metrics: backtestResult.performance_metrics,
});
});
// Sort by date descending (newest first) and show only the last 50 rows to avoid performance issues
const recentRows = tableRows
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 50);
return (
Activity
Date
Ticker
Action
Quantity
Price
Shares
Position Value
Bullish
Bearish
Neutral
{recentRows.map((row: any, idx: number) => {
if (row.type === 'ticker') {
return (
{row.date}
{row.ticker}
{row.action?.toUpperCase() || 'HOLD'}
{row.quantity?.toLocaleString() || 0}
${row.price?.toFixed(2) || '0.00'}
{row.shares_owned?.toLocaleString() || 0}
${row.position_value?.toLocaleString() || '0'}
{row.bullish_count || 0}
{row.bearish_count || 0}
{row.neutral_count || 0}
);
}
})}
);
}
// Component for displaying backtest results
function BacktestResults({ outputData }: { outputData: any }) {
if (!outputData) {
return null;
}
console.log("outputData", outputData);
if (!outputData.performance_metrics) {
return (
Backtest Results
Backtest completed. Performance metrics will appear here.
);
}
const { performance_metrics, final_portfolio, total_days } = outputData;
return (
Backtest Results
{/* Performance Metrics */}
Performance Metrics
{performance_metrics.sharpe_ratio !== null && performance_metrics.sharpe_ratio !== undefined && (
Sharpe Ratio:
1 ? "text-green-500" : "text-red-500")}>
{performance_metrics.sharpe_ratio.toFixed(2)}
)}
{performance_metrics.sortino_ratio !== null && performance_metrics.sortino_ratio !== undefined && (
Sortino Ratio:
1 ? "text-green-500" : "text-red-500")}>
{performance_metrics.sortino_ratio.toFixed(2)}
)}
{performance_metrics.max_drawdown !== null && performance_metrics.max_drawdown !== undefined && (
Max Drawdown:
{Math.abs(performance_metrics.max_drawdown).toFixed(2)}%
)}
{/* Portfolio Summary */}
Portfolio Summary
Total Days:
{total_days}
Final Cash:
${final_portfolio.cash.toLocaleString()}
Margin Used:
${final_portfolio.margin_used.toLocaleString()}
{/* Exposure Metrics */}
Exposure Metrics
{performance_metrics.gross_exposure !== null && performance_metrics.gross_exposure !== undefined && (
Gross Exposure:
${performance_metrics.gross_exposure.toLocaleString()}
)}
{performance_metrics.net_exposure !== null && performance_metrics.net_exposure !== undefined && (
Net Exposure:
${performance_metrics.net_exposure.toLocaleString()}
)}
{performance_metrics.long_short_ratio !== null && performance_metrics.long_short_ratio !== undefined && (
Long/Short Ratio:
{performance_metrics.long_short_ratio === Infinity || performance_metrics.long_short_ratio === null ? '∞' : performance_metrics.long_short_ratio.toFixed(2)}
)}
{/* Final Positions */}
{final_portfolio.positions && (
Final Positions
Ticker
Long Shares
Short Shares
Long Cost Basis
Short Cost Basis
{Object.entries(final_portfolio.positions).map(([ticker, position]: [string, any]) => (
{ticker}
0 ? "text-green-500" : "text-muted-foreground")}>
{position.long}
0 ? "text-red-500" : "text-muted-foreground")}>
{position.short}
${position.long_cost_basis.toFixed(2)}
${position.short_cost_basis.toFixed(2)}
))}
)}
);
}
// Component for displaying real-time backtest performance
function BacktestPerformanceMetrics({ agentData }: { agentData: Record }) {
const backtestAgent = agentData['backtest'];
if (!backtestAgent || !backtestAgent.backtestResults) return null;
// Get the backtest results directly from the agent data
const backtestResults = backtestAgent.backtestResults || [];
if (backtestResults.length === 0) return null;
const firstPeriod = backtestResults[0];
const latestPeriod = backtestResults[backtestResults.length - 1];
// Calculate performance metrics
const initialValue = firstPeriod.portfolio_value;
const currentValue = latestPeriod.portfolio_value;
const totalReturn = ((currentValue - initialValue) / initialValue) * 100;
// Calculate win rate (periods with positive returns)
const periodReturns = backtestResults.slice(1).map((period: any, idx: number) => {
const prevPeriod = backtestResults[idx];
return ((period.portfolio_value - prevPeriod.portfolio_value) / prevPeriod.portfolio_value) * 100;
});
const winningPeriods = periodReturns.filter((ret: number) => ret > 0).length;
const winRate = periodReturns.length > 0 ? (winningPeriods / periodReturns.length) * 100 : 0;
// Calculate max drawdown
let maxDrawdown = 0;
let peak = initialValue;
backtestResults.forEach((period: any) => {
if (period.portfolio_value > peak) {
peak = period.portfolio_value;
}
const drawdown = ((period.portfolio_value - peak) / peak) * 100;
if (drawdown < maxDrawdown) {
maxDrawdown = drawdown;
}
});
return (
Performance
Total Return
= 0 ? "text-green-500" : "text-red-500")}>
{totalReturn >= 0 ? '+' : ''}{totalReturn.toFixed(2)}%
Win Rate
{winRate.toFixed(1)}%
Max Drawdown
{Math.abs(maxDrawdown).toFixed(2)}%
Periods Traded
{backtestResults.length}
{/* Additional metrics */}
Current Value
${currentValue?.toLocaleString()}
Initial Value
${initialValue?.toLocaleString()}
P&L
= 0 ? "text-green-500" : "text-red-500")}>
${(currentValue - initialValue).toLocaleString()}
Long/Short Ratio
{latestPeriod.long_short_ratio === Infinity || latestPeriod.long_short_ratio === null ? '∞' : latestPeriod.long_short_ratio?.toFixed(2)}
);
}
// Main component for backtest output
export function BacktestOutput({
agentData,
outputData
}: {
agentData: Record;
outputData: any;
}) {
return (
<>
{outputData && }
{agentData && agentData['backtest'] && (
)}
>
);
}
================================================
FILE: app/frontend/src/components/panels/bottom/tabs/debug-console-tab.tsx
================================================
interface DebugConsoleTabProps {
className?: string;
}
export function DebugConsoleTab({ className }: DebugConsoleTabProps) {
return (
Debug console is ready...
);
}
================================================
FILE: app/frontend/src/components/panels/bottom/tabs/index.ts
================================================
export { DebugConsoleTab } from '@/components/panels/bottom/tabs/debug-console-tab';
export { OutputTab } from '@/components/panels/bottom/tabs/output-tab';
export { ProblemsTab } from '@/components/panels/bottom/tabs/problems-tab';
export { TerminalTab } from '@/components/panels/bottom/tabs/terminal-tab';
================================================
FILE: app/frontend/src/components/panels/bottom/tabs/output-tab-utils.ts
================================================
import { CheckCircle, Clock, MoreHorizontal, XCircle } from 'lucide-react';
// Helper function to detect if content is JSON
export function isJsonString(str: string): boolean {
try {
const parsed = JSON.parse(str);
return typeof parsed === 'object' && parsed !== null;
} catch {
return false;
}
}
// Helper function to get display name for agent
export function getDisplayName(agentName: string): string {
// Remove _agent suffix first
let name = agentName.replace("_agent", "");
// Remove ID suffix (everything after the last underscore if it looks like an ID)
const lastUnderscoreIndex = name.lastIndexOf("_");
if (lastUnderscoreIndex !== -1) {
const potentialId = name.substring(lastUnderscoreIndex + 1);
// If the part after the last underscore looks like an ID (alphanumeric, 5+ chars), remove it
if (/^[a-zA-Z0-9]{5,}$/.test(potentialId)) {
name = name.substring(0, lastUnderscoreIndex);
}
}
// Replace remaining underscores with spaces and title case
return name.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase());
}
// Helper function to get status icon and color
export function getStatusIcon(status: string) {
switch (status.toLowerCase()) {
case 'complete':
return { icon: CheckCircle, color: 'text-green-500' };
case 'error':
return { icon: XCircle, color: 'text-red-500' };
case 'in_progress':
return { icon: MoreHorizontal, color: 'text-yellow-500' };
default:
return { icon: Clock, color: 'text-muted-foreground' };
}
}
// Helper function to get signal color
export function getSignalColor(signal: string): string {
switch (signal.toUpperCase()) {
case 'BULLISH':
return 'text-green-500';
case 'BEARISH':
return 'text-red-500';
case 'NEUTRAL':
return 'text-primary';
default:
return 'text-muted-foreground';
}
}
// Helper function to get action color
export function getActionColor(action: string): string {
switch (action.toUpperCase()) {
case 'BUY':
case 'COVER':
return 'text-green-500';
case 'SELL':
case 'SHORT':
return 'text-red-500';
case 'HOLD':
return 'text-primary';
default:
return 'text-muted-foreground';
}
}
// Helper function to sort agents in display order
export function sortAgents(agents: [string, any][]): [string, any][] {
return agents.sort(([agentA, dataA], [agentB, dataB]) => {
// First, sort by agent type priority (Risk Management and Portfolio Management at bottom)
const getPriority = (agentName: string) => {
if (agentName.includes("risk_management")) return 3;
if (agentName.includes("portfolio_management")) return 4;
return 1;
};
const priorityA = getPriority(agentA);
const priorityB = getPriority(agentB);
// If different priorities, sort by priority
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// If same priority, sort by timestamp (ascending - oldest first)
const timestampA = dataA.timestamp ? new Date(dataA.timestamp).getTime() : 0;
const timestampB = dataB.timestamp ? new Date(dataB.timestamp).getTime() : 0;
if (timestampA !== timestampB) {
return timestampA - timestampB;
}
// If no timestamp difference, sort alphabetically
return agentA.localeCompare(agentB);
});
}
================================================
FILE: app/frontend/src/components/panels/bottom/tabs/output-tab.tsx
================================================
import { useFlowContext } from '@/contexts/flow-context';
import { useNodeContext } from '@/contexts/node-context';
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { BacktestOutput } from './backtest-output';
import { sortAgents } from './output-tab-utils';
import { RegularOutput } from './regular-output';
interface OutputTabProps {
className?: string;
}
export function OutputTab({ className }: OutputTabProps) {
const { currentFlowId } = useFlowContext();
const { getAgentNodeDataForFlow, getOutputNodeDataForFlow } = useNodeContext();
const [updateTrigger, setUpdateTrigger] = useState(0);
// Get current flow data
const agentData = getAgentNodeDataForFlow(currentFlowId?.toString() || null);
const outputData = getOutputNodeDataForFlow(currentFlowId?.toString() || null);
// Force re-render periodically to show real-time updates
useEffect(() => {
const interval = setInterval(() => {
setUpdateTrigger(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
// Detect if this is a backtest run
const isBacktestRun = agentData && agentData['backtest'];
// Sort agents for display (exclude backtest agent from regular agent list)
const sortedAgents = sortAgents(Object.entries(agentData).filter(([agentId]) => agentId !== 'backtest'));
return (
{/* Render backtest output if this is a backtest run */}
{isBacktestRun && (
)}
{/* Render regular output if not a backtest run */}
{!isBacktestRun && (
)}
{/* Empty State */}
{!outputData && sortedAgents.length === 0 && !isBacktestRun && (
No output to display. Run an analysis to see progress and results.
)}
);
}
================================================
FILE: app/frontend/src/components/panels/bottom/tabs/problems-tab.tsx
================================================
interface ProblemsTabProps {
className?: string;
}
export function ProblemsTab({ className }: ProblemsTabProps) {
return (
);
}
================================================
FILE: app/frontend/src/components/panels/bottom/tabs/reasoning-content.tsx
================================================
import { Copy } from 'lucide-react';
import { useState } from 'react';
import { isJsonString } from './output-tab-utils';
// Component to render reasoning content with JSON formatting and copy button
export function ReasoningContent({ content }: { content: any }) {
const [copySuccess, setCopySuccess] = useState(false);
if (!content) return null;
const contentString = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
const isJson = isJsonString(contentString);
const copyToClipboard = () => {
navigator.clipboard.writeText(contentString)
.then(() => {
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
})
.catch(err => {
console.error('Failed to copy text: ', err);
});
};
return (
{copySuccess ? 'Copied!' : 'Copy'}
{isJson ? (
) : (
{contentString.split('\n').map((paragraph, idx) => (
{paragraph}
))}
)}
);
}
================================================
FILE: app/frontend/src/components/panels/bottom/tabs/regular-output.tsx
================================================
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { getActionColor, getDisplayName, getSignalColor, getStatusIcon } from './output-tab-utils';
import { ReasoningContent } from './reasoning-content';
// Progress Section Component
function ProgressSection({ sortedAgents }: { sortedAgents: [string, any][] }) {
if (sortedAgents.length === 0) return null;
return (
Progress
{sortedAgents.map(([agentId, data]) => {
const { icon: StatusIcon, color } = getStatusIcon(data.status);
const displayName = getDisplayName(agentId);
return (
{displayName}
{data.ticker && (
[{data.ticker}]
)}
{data.message || data.status}
{data.timestamp && (
{new Date(data.timestamp).toLocaleTimeString()}
)}
);
})}
);
}
// Summary Section Component
function SummarySection({ outputData }: { outputData: any }) {
if (!outputData) return null;
return (
Summary
Ticker
Action
Quantity
Confidence
{Object.entries(outputData.decisions).map(([ticker, decision]: [string, any]) => (
{ticker}
{decision.action?.toUpperCase() || 'UNKNOWN'}
{decision.quantity || 0}
{decision.confidence?.toFixed(1) || 0}%
))}
);
}
// Analysis Results Section Component
function AnalysisResultsSection({ outputData }: { outputData: any }) {
// Always call hooks at the top of the function
const [selectedTicker, setSelectedTicker] = useState('');
// Calculate tickers (safe to do even if outputData is null)
const tickers = outputData?.decisions ? Object.keys(outputData.decisions) : [];
// Set default selected ticker
useEffect(() => {
if (tickers.length > 0 && !selectedTicker) {
setSelectedTicker(tickers[0]);
}
}, [tickers, selectedTicker]);
// Early returns after all hooks are called
if (!outputData) return null;
if (tickers.length === 0) return null;
return (
Analysis
{tickers.map((ticker) => (
{ticker}
))}
{tickers.map((ticker) => {
const decision = outputData.decisions![ticker];
return (
{/* Agent Analysis */}
Agent
Signal
Confidence
Reasoning
{Object.entries(outputData.analyst_signals || {})
.filter(([agent, signals]: [string, any]) =>
ticker in signals && !agent.includes("risk_management")
)
.sort(([agentA], [agentB]) => agentA.localeCompare(agentB))
.map(([agent, signals]: [string, any]) => {
const signal = signals[ticker];
const signalType = signal.signal?.toUpperCase() || 'UNKNOWN';
const signalColor = getSignalColor(signalType);
return (
{getDisplayName(agent)}
{signalType}
{signal.confidence || 0}%
);
})}
{/* Trading Decision */}
Property
Value
Action
{decision.action?.toUpperCase() || 'UNKNOWN'}
Quantity
{decision.quantity || 0}
Confidence
{decision.confidence?.toFixed(1) || 0}%
{decision.reasoning && (
Reasoning
)}
);
})}
);
}
// Main component for regular output
export function RegularOutput({
sortedAgents,
outputData
}: {
sortedAgents: [string, any][];
outputData: any;
}) {
return (
<>
>
);
}
================================================
FILE: app/frontend/src/components/panels/bottom/tabs/terminal-tab.tsx
================================================
interface TerminalTabProps {
className?: string;
}
export function TerminalTab({ className }: TerminalTabProps) {
return (
$
Welcome to AI Hedge Fund Terminal
{'\n'}
Type commands here...
{'\n'}
$
_
);
}
================================================
FILE: app/frontend/src/components/panels/left/flow-actions.tsx
================================================
import { Button } from '@/components/ui/button';
import { useFlowContext } from '@/contexts/flow-context';
import { cn } from '@/lib/utils';
import { Plus, Save } from 'lucide-react';
interface FlowActionsProps {
onSave: () => Promise;
onCreate: () => void;
}
export function FlowActions({ onSave, onCreate }: FlowActionsProps) {
const { currentFlowName, isUnsaved } = useFlowContext();
return (
);
}
================================================
FILE: app/frontend/src/components/panels/left/flow-context-menu.tsx
================================================
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Copy, Edit, Trash2 } from 'lucide-react';
import { useEffect, useRef } from 'react';
interface FlowContextMenuProps {
isOpen: boolean;
position: { x: number; y: number };
onClose: () => void;
onEdit: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
export function FlowContextMenu({
isOpen,
position,
onClose,
onEdit,
onDuplicate,
onDelete
}: FlowContextMenuProps) {
const menuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleAction = (action: () => void) => {
action();
onClose();
};
return (
handleAction(onEdit)}
>
Edit
handleAction(onDuplicate)}
>
Duplicate
handleAction(onDelete)}
>
Delete
);
}
================================================
FILE: app/frontend/src/components/panels/left/flow-create-dialog.tsx
================================================
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useToastManager } from '@/hooks/use-toast-manager';
import { flowService } from '@/services/flow-service';
import { Flow } from '@/types/flow';
import { useEffect, useState } from 'react';
interface FlowCreateDialogProps {
isOpen: boolean;
onClose: () => void;
onFlowCreated: (flow: Flow) => void;
}
export function FlowCreateDialog({ isOpen, onClose, onFlowCreated }: FlowCreateDialogProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { success, error } = useToastManager();
// Reset form when dialog opens
useEffect(() => {
if (isOpen) {
setName('');
setDescription('');
}
}, [isOpen]);
const handleCreate = async () => {
if (!name.trim()) {
error('Flow name is required');
return;
}
setIsLoading(true);
try {
const newFlow = await flowService.createFlow({
name: name.trim(),
description: description.trim() || undefined,
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
});
success(`"${newFlow.name}" created!`);
onFlowCreated(newFlow);
onClose();
} catch (err) {
console.error('Failed to create flow:', err);
error('Failed to create flow');
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
setName('');
setDescription('');
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Handle Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux)
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (name.trim()) {
handleCreate();
}
}
};
return (
Create New Flow
Create a new flow with a custom name and description.
Cancel
{isLoading ? 'Creating...' : 'Create Flow'}
);
}
================================================
FILE: app/frontend/src/components/panels/left/flow-edit-dialog.tsx
================================================
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useTabsContext } from '@/contexts/tabs-context';
import { useToastManager } from '@/hooks/use-toast-manager';
import { flowService } from '@/services/flow-service';
import { Flow } from '@/types/flow';
import { useEffect, useState } from 'react';
interface FlowEditDialogProps {
flow: Flow | null;
isOpen: boolean;
onClose: () => void;
onFlowUpdated: () => void;
}
export function FlowEditDialog({ flow, isOpen, onClose, onFlowUpdated }: FlowEditDialogProps) {
const [name, setName] = useState(flow?.name || '');
const [description, setDescription] = useState(flow?.description || '');
const [isLoading, setIsLoading] = useState(false);
const { success, error } = useToastManager();
const { updateFlowTabTitle } = useTabsContext();
// Update form when flow changes
useEffect(() => {
if (flow) {
setName(flow.name);
setDescription(flow.description || '');
}
}, [flow]);
const handleSave = async () => {
if (!flow || !name.trim()) {
error('Flow name is required');
return;
}
setIsLoading(true);
try {
await flowService.updateFlow(flow.id, {
name: name.trim(),
description: description.trim() || undefined,
});
// Update the tab title if it's currently open
updateFlowTabTitle(flow.id, name.trim());
success(`"${name}" updated!`);
onFlowUpdated();
onClose();
} catch (err) {
console.error('Failed to update flow:', err);
error('Failed to update flow');
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
if (flow) {
setName(flow.name);
setDescription(flow.description || '');
}
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Handle Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux)
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (name.trim()) {
handleSave();
}
}
};
return (
Edit Flow
Update the name and description for your flow.
Cancel
{isLoading ? 'Saving...' : 'Save Changes'}
);
}
================================================
FILE: app/frontend/src/components/panels/left/flow-item-group.tsx
================================================
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Separator } from '@/components/ui/separator';
import { Flow } from '@/types/flow';
import FlowItem from './flow-item';
interface FlowItemGroupProps {
title: string;
flows: Flow[];
onLoadFlow: (flow: Flow) => Promise;
onDeleteFlow: (flow: Flow) => Promise;
onRefresh: () => Promise;
currentFlowId?: number | null;
}
export function FlowItemGroup({ title, flows, onLoadFlow, onDeleteFlow, onRefresh, currentFlowId }: FlowItemGroupProps) {
const groupId = title.toLowerCase().replace(/\s+/g, '-');
return (
{title}
({flows.length})
{flows.map((flow, index) => (
{index < flows.length - 1 && (
)}
))}
);
}
================================================
FILE: app/frontend/src/components/panels/left/flow-item.tsx
================================================
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useFlowConnectionState } from '@/hooks/use-flow-connection';
import { cn } from '@/lib/utils';
import { flowService } from '@/services/flow-service';
import { Flow } from '@/types/flow';
import {
Calendar,
FileText,
Layout,
MoreHorizontal,
Zap
} from 'lucide-react';
import { useState } from 'react';
import { FlowContextMenu } from './flow-context-menu';
import { FlowEditDialog } from './flow-edit-dialog';
interface FlowItemProps {
flow: Flow;
onLoadFlow: (flow: Flow) => Promise;
onDeleteFlow: (flow: Flow) => Promise;
onRefresh: () => Promise;
isActive?: boolean;
}
export default function FlowItem({ flow, onLoadFlow, onDeleteFlow, onRefresh, isActive = false }: FlowItemProps) {
const [contextMenu, setContextMenu] = useState<{ isOpen: boolean; position: { x: number; y: number } }>({
isOpen: false,
position: { x: 0, y: 0 }
});
const [editDialog, setEditDialog] = useState(false);
// Check if this flow has an active connection
const connectionState = useFlowConnectionState(flow.id.toString());
const hasActiveConnection = connectionState &&
(connectionState.state === 'connecting' || connectionState.state === 'connected');
const handleLoadFlow = async () => {
await onLoadFlow(flow);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY }
});
};
const handleMenuClick = (e: React.MouseEvent) => {
e.stopPropagation();
// Get the button's position for the menu
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({
isOpen: true,
position: { x: rect.right - 160, y: rect.bottom } // Offset menu to the left of the button
});
};
const closeContextMenu = () => {
setContextMenu(prev => ({ ...prev, isOpen: false }));
};
const handleEdit = () => {
setEditDialog(true);
};
const handleDuplicateFlow = async () => {
try {
await flowService.duplicateFlow(flow.id);
onRefresh();
} catch (error) {
console.error('Failed to duplicate flow:', error);
}
};
const handleDeleteFlow = async () => {
if (window.confirm(`Are you sure you want to delete "${flow.name}"?`)) {
try {
await onDeleteFlow(flow);
} catch (error) {
console.error('Failed to delete flow:', error);
}
}
};
const formatDateTime = (dateString: string) => {
return new Date(dateString).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true
});
};
// Filter out "default" tag
const filteredTags = flow.tags?.filter(tag => tag !== 'default') || [];
return (
<>
{flow.is_template ? (
) : (
)}
{flow.name}
{/* Active connection indicator - right aligned */}
{hasActiveConnection && (
Running
)}
{formatDateTime(flow.created_at)}
{filteredTags.length > 0 && (
{filteredTags.slice(0, 2).map(tag => (
{tag}
))}
{filteredTags.length > 2 && (
+{filteredTags.length - 2}
)}
)}
setEditDialog(false)}
onFlowUpdated={onRefresh}
/>
>
);
}
================================================
FILE: app/frontend/src/components/panels/left/flow-list.tsx
================================================
import { FlowItemGroup } from '@/components/panels/left/flow-item-group';
import { SearchBox } from '@/components/panels/search-box';
import { Accordion } from '@/components/ui/accordion';
import { useTabsContext } from '@/contexts/tabs-context';
import { Flow } from '@/types/flow';
import { FolderOpen } from 'lucide-react';
interface FlowListProps {
flows: Flow[];
searchQuery: string;
isLoading: boolean;
openGroups: string[];
filteredFlows: Flow[];
recentFlows: Flow[];
templateFlows: Flow[];
onSearchChange: (query: string) => void;
onAccordionChange: (value: string[]) => void;
onLoadFlow: (flow: Flow) => Promise;
onDeleteFlow: (flow: Flow) => Promise;
onRefresh: () => Promise;
}
export function FlowList({
flows,
searchQuery,
isLoading,
openGroups,
filteredFlows,
recentFlows,
templateFlows,
onSearchChange,
onAccordionChange,
onLoadFlow,
onDeleteFlow,
onRefresh,
}: FlowListProps) {
const { tabs, activeTabId } = useTabsContext();
// Only consider a flow active if the current active tab is a flow tab with that flow's ID
const getActiveFlowId = (): number | null => {
const activeTab = tabs.find(tab => tab.id === activeTabId);
// If no active tab or active tab is not a flow tab, no flow should be active
if (!activeTab || activeTab.type !== 'flow') {
return null;
}
// Return the flow ID from the active flow tab
return activeTab.flow?.id || null;
};
const activeFlowId = getActiveFlowId();
return (
{isLoading ? (
) : (
{recentFlows.length > 0 && (
)}
{templateFlows.length > 0 && (
)}
)}
{!isLoading && filteredFlows.length === 0 && (
{flows.length === 0 ? (
No flows saved yet
Create your first flow to get started
) : (
'No flows match your search'
)}
)}
);
}
================================================
FILE: app/frontend/src/components/panels/left/left-sidebar.tsx
================================================
import { useFlowManagementTabs } from '@/hooks/use-flow-management-tabs';
import { useResizable } from '@/hooks/use-resizable';
import { cn } from '@/lib/utils';
import { ReactNode, useEffect } from 'react';
import { FlowActions } from './flow-actions';
import { FlowCreateDialog } from './flow-create-dialog';
import { FlowList } from './flow-list';
interface LeftSidebarProps {
children?: ReactNode;
isCollapsed: boolean;
onCollapse: () => void;
onExpand: () => void;
onWidthChange?: (width: number) => void;
}
export function LeftSidebar({
isCollapsed,
onWidthChange,
}: LeftSidebarProps) {
// Use our custom hooks
const { width, isDragging, elementRef, startResize } = useResizable({
defaultWidth: 280,
minWidth: 200,
maxWidth: window.innerWidth * .90,
side: 'left',
});
// Notify parent component of width changes
useEffect(() => {
onWidthChange?.(width);
}, [width, onWidthChange]);
// Use flow management hook with tabs
const {
flows,
searchQuery,
isLoading,
openGroups,
createDialogOpen,
filteredFlows,
recentFlows,
templateFlows,
setSearchQuery,
setCreateDialogOpen,
handleAccordionChange,
handleCreateNewFlow,
handleFlowCreated,
handleSaveCurrentFlow,
handleOpenFlowInTab,
handleDeleteFlow,
handleRefresh,
} = useFlowManagementTabs();
return (
{/* Resize handle - on the right side for left sidebar */}
{!isDragging && (
)}
setCreateDialogOpen(false)}
onFlowCreated={handleFlowCreated}
/>
);
}
================================================
FILE: app/frontend/src/components/panels/right/component-actions.tsx
================================================
interface ComponentActionsProps {
}
export function ComponentActions({ }: ComponentActionsProps) {
return (
);
}
================================================
FILE: app/frontend/src/components/panels/right/component-item-group.tsx
================================================
import ComponentItem from '@/components/panels/right/component-item';
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { useFlowContext } from '@/contexts/flow-context';
import { ComponentGroup } from '@/data/sidebar-components';
interface ComponentItemGroupProps {
group: ComponentGroup;
activeItem: string | null;
}
export function ComponentItemGroup({
group,
activeItem
}: ComponentItemGroupProps) {
const { name, icon: Icon, iconColor, items } = group;
const { addComponentToFlow } = useFlowContext();
const handleItemClick = async (componentName: string) => {
try {
await addComponentToFlow(componentName);
} catch (error) {
console.error('Failed to add component to flow:', error);
}
};
return (
{name}
{items.map((item) => (
handleItemClick(item.name)}
/>
))}
);
}
================================================
FILE: app/frontend/src/components/panels/right/component-item.tsx
================================================
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { LucideIcon, Plus } from "lucide-react";
import { useState } from "react";
interface ComponentItemProps {
icon: LucideIcon;
label: string;
onClick?: () => void;
className?: string;
isActive?: boolean;
}
export default function ComponentItem({
icon: Icon,
label,
onClick,
className,
isActive = false
}: ComponentItemProps) {
const [isHovered, setIsHovered] = useState(false);
const handlePlusClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering the parent onClick
if (onClick) onClick();
};
return (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) {
onClick();
}
}}
>
{label}
{/* Add button using shadcn Button */}
);
}
================================================
FILE: app/frontend/src/components/panels/right/component-list.tsx
================================================
import { Accordion } from '@/components/ui/accordion';
import { ComponentGroup } from '@/data/sidebar-components';
import { SearchBox } from '../search-box';
import { ComponentItemGroup } from './component-item-group';
interface ComponentListProps {
componentGroups: ComponentGroup[];
searchQuery: string;
isLoading: boolean;
openGroups: string[];
filteredGroups: ComponentGroup[];
activeItem: string | null;
onSearchChange: (query: string) => void;
onAccordionChange: (value: string[]) => void;
}
export function ComponentList({
componentGroups,
searchQuery,
isLoading,
openGroups,
filteredGroups,
activeItem,
onSearchChange,
onAccordionChange,
}: ComponentListProps) {
return (
{isLoading ? (
) : (
{filteredGroups.map(group => (
))}
)}
{!isLoading && filteredGroups.length === 0 && (
{componentGroups.length === 0 ? (
No components available
Components will appear here when loaded
) : (
'No components match your search'
)}
)}
);
}
================================================
FILE: app/frontend/src/components/panels/right/right-sidebar.tsx
================================================
import { ComponentGroup, getComponentGroups } from '@/data/sidebar-components';
import { useComponentGroups } from '@/hooks/use-component-groups';
import { useResizable } from '@/hooks/use-resizable';
import { cn } from '@/lib/utils';
import { ReactNode, useEffect, useState } from 'react';
import { ComponentActions } from './component-actions';
import { ComponentList } from './component-list';
interface RightSidebarProps {
children?: ReactNode;
isCollapsed: boolean;
onCollapse: () => void;
onExpand: () => void;
onWidthChange?: (width: number) => void;
}
export function RightSidebar({
isCollapsed,
onWidthChange,
}: RightSidebarProps) {
// Use our custom hooks
const { width, isDragging, elementRef, startResize } = useResizable({
defaultWidth: 280,
minWidth: 200,
maxWidth: window.innerWidth * .90,
side: 'right',
});
// Notify parent component of width changes
useEffect(() => {
onWidthChange?.(width);
}, [width, onWidthChange]);
// State for loading component groups
const [componentGroups, setComponentGroups] = useState([]);
const [isLoading, setIsLoading] = useState(true);
// Load component groups on mount
useEffect(() => {
const loadComponentGroups = async () => {
try {
setIsLoading(true);
const groups = await getComponentGroups();
setComponentGroups(groups);
} catch (error) {
console.error('Failed to load component groups:', error);
} finally {
setIsLoading(false);
}
};
loadComponentGroups();
}, []);
const {
searchQuery,
setSearchQuery,
activeItem,
openGroups,
filteredGroups,
handleAccordionChange
} = useComponentGroups(componentGroups);
return (
{/* Resize handle - on the left side for right sidebar */}
{!isDragging && (
)}
);
}
================================================
FILE: app/frontend/src/components/panels/search-box.tsx
================================================
import { Button } from '@/components/ui/button';
import { Search } from 'lucide-react';
interface SearchBoxProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function SearchBox({
value,
onChange,
placeholder = "Search components..."
}: SearchBoxProps) {
return (
);
}
================================================
FILE: app/frontend/src/components/settings/api-keys.tsx
================================================
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { apiKeysService } from '@/services/api-keys-api';
import { Eye, EyeOff, Key, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
interface ApiKey {
key: string;
label: string;
description: string;
url: string;
placeholder: string;
}
const FINANCIAL_API_KEYS: ApiKey[] = [
{
key: 'FINANCIAL_DATASETS_API_KEY',
label: 'Financial Datasets API',
description: 'For getting financial data to power the hedge fund',
url: 'https://financialdatasets.ai/',
placeholder: 'your-financial-datasets-api-key'
}
];
const LLM_API_KEYS: ApiKey[] = [
{
key: 'ANTHROPIC_API_KEY',
label: 'Anthropic API',
description: 'For Claude models (claude-4-sonnet, claude-4.1-opus, etc.)',
url: 'https://anthropic.com/',
placeholder: 'your-anthropic-api-key'
},
{
key: 'DEEPSEEK_API_KEY',
label: 'DeepSeek API',
description: 'For DeepSeek models (deepseek-chat, deepseek-reasoner, etc.)',
url: 'https://deepseek.com/',
placeholder: 'your-deepseek-api-key'
},
{
key: 'GROQ_API_KEY',
label: 'Groq API',
description: 'For Groq-hosted models (deepseek, llama3, etc.)',
url: 'https://groq.com/',
placeholder: 'your-groq-api-key'
},
{
key: 'GOOGLE_API_KEY',
label: 'Google API',
description: 'For Gemini models (gemini-2.5-flash, gemini-2.5-pro)',
url: 'https://ai.dev/',
placeholder: 'your-google-api-key'
},
{
key: 'OPENAI_API_KEY',
label: 'OpenAI API',
description: 'For OpenAI models (gpt-4o, gpt-4o-mini, etc.)',
url: 'https://platform.openai.com/',
placeholder: 'your-openai-api-key'
},
{
key: 'OPENROUTER_API_KEY',
label: 'OpenRouter API',
description: 'For OpenRouter models (gpt-4o, gpt-4o-mini, etc.)',
url: 'https://openrouter.ai/',
placeholder: 'your-openrouter-api-key'
},
{
key: 'GIGACHAT_API_KEY',
label: 'GigaChat API',
description: 'For GigaChat models (GigaChat-2-Max, etc.)',
url: 'https://github.com/ai-forever/gigachat',
placeholder: 'your-gigachat-api-key'
}
];
export function ApiKeysSettings() {
const [apiKeys, setApiKeys] = useState>({});
const [visibleKeys, setVisibleKeys] = useState>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Load API keys from backend on component mount
useEffect(() => {
loadApiKeys();
}, []);
const loadApiKeys = async () => {
try {
setLoading(true);
setError(null);
const apiKeysSummary = await apiKeysService.getAllApiKeys();
// Load actual key values for existing keys
const keysData: Record = {};
for (const summary of apiKeysSummary) {
try {
const fullKey = await apiKeysService.getApiKey(summary.provider);
keysData[summary.provider] = fullKey.key_value;
} catch (err) {
console.warn(`Failed to load key for ${summary.provider}:`, err);
}
}
setApiKeys(keysData);
} catch (err) {
console.error('Failed to load API keys:', err);
setError('Failed to load API keys. Please try again.');
} finally {
setLoading(false);
}
};
const handleKeyChange = async (key: string, value: string) => {
// Update local state immediately for responsive UI
setApiKeys(prev => ({
...prev,
[key]: value
}));
// Auto-save with debouncing
try {
if (value.trim()) {
await apiKeysService.createOrUpdateApiKey({
provider: key,
key_value: value.trim(),
is_active: true
});
} else {
// If value is empty, delete the key
try {
await apiKeysService.deleteApiKey(key);
} catch (err) {
// Key might not exist, which is fine
console.log(`Key ${key} not found for deletion, which is expected`);
}
}
} catch (err) {
console.error(`Failed to save API key ${key}:`, err);
setError(`Failed to save ${key}. Please try again.`);
}
};
const toggleKeyVisibility = (key: string) => {
setVisibleKeys(prev => ({
...prev,
[key]: !prev[key]
}));
};
const clearKey = async (key: string) => {
try {
await apiKeysService.deleteApiKey(key);
setApiKeys(prev => {
const newKeys = { ...prev };
delete newKeys[key];
return newKeys;
});
} catch (err) {
console.error(`Failed to delete API key ${key}:`, err);
setError(`Failed to delete ${key}. Please try again.`);
}
};
const renderApiKeySection = (title: string, description: string, keys: ApiKey[], icon: React.ReactNode) => (
{icon}
{title}
{description}
{keys.map((apiKey) => (
window.open(apiKey.url, '_blank')}
>
{apiKey.label}
))}
);
if (loading) {
return (
API Keys
Loading API keys...
Please wait while we load your API keys...
);
}
return (
API Keys
Configure API endpoints and authentication credentials for financial data and language models.
Changes are automatically saved.
{/* Error Message */}
{error && (
Error
{error}
{
setError(null);
loadApiKeys();
}}
className="text-xs mt-2 p-0 h-auto text-red-500 hover:text-red-400"
>
Try again
)}
{/* Financial Data API Keys */}
{renderApiKeySection(
'Financial Data',
'API keys for accessing financial market data and datasets.',
FINANCIAL_API_KEYS,
)}
{/* LLM API Keys */}
{renderApiKeySection(
'Language Models',
'API keys for accessing various large language model providers.',
LLM_API_KEYS,
)}
{/* Security Note */}
Security Note
API keys are stored securely on your local system and changes are automatically saved.
Keep your API keys secure and don't share them with others.
);
}
================================================
FILE: app/frontend/src/components/settings/appearance.tsx
================================================
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
export function ThemeSettings() {
const { theme, setTheme } = useTheme();
const themes = [
{
id: 'light',
name: 'Light',
description: 'A clean, bright interface',
icon: Sun,
},
{
id: 'dark',
name: 'Dark',
description: 'A comfortable dark interface',
icon: Moon,
},
{
id: 'system',
name: 'System',
description: 'Use your system preference',
icon: Monitor,
},
];
return (
Theme
Customize the look and feel of your application.
Theme
Select your preferred theme or use system setting to automatically switch between light and dark modes.
{themes.map((themeOption) => {
const Icon = themeOption.icon;
const isSelected = theme === themeOption.id;
return (
setTheme(themeOption.id)}
>
{themeOption.name}
{themeOption.description}
);
})}
);
}
================================================
FILE: app/frontend/src/components/settings/index.ts
================================================
export { ApiKeysSettings } from './api-keys';
export { ThemeSettings } from './appearance';
export { Models } from './models';
export { CloudModels } from './models/cloud';
export { OllamaSettings } from './models/ollama';
================================================
FILE: app/frontend/src/components/settings/models/cloud.tsx
================================================
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { Cloud, RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
interface CloudModelsProps {
className?: string;
}
interface CloudModel {
display_name: string;
model_name: string;
provider: string;
}
interface ModelProvider {
name: string;
models: Array<{
display_name: string;
model_name: string;
}>;
}
export function CloudModels({ className }: CloudModelsProps) {
const [providers, setProviders] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchProviders = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('http://localhost:8000/language-models/providers');
if (response.ok) {
const data = await response.json();
setProviders(data.providers);
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
setError(`Failed to fetch providers: ${errorData.detail}`);
}
} catch (error) {
console.error('Failed to fetch cloud model providers:', error);
setError('Failed to connect to backend service');
}
setLoading(false);
};
useEffect(() => {
fetchProviders();
}, []);
// Flatten all models from all providers into a single array
const allModels: CloudModel[] = providers.flatMap(provider =>
provider.models.map(model => ({
...model,
provider: provider.name
}))
).sort((a, b) => a.provider.localeCompare(b.provider));
return (
{error && (
)}
Available Models
{allModels.length} models from {providers.length} providers
{loading ? (
) : allModels.length > 0 ? (
{allModels.map((model) => (
{model.display_name}
{model.model_name !== model.display_name && (
{model.model_name}
)}
{model.provider}
))}
) : (
!loading && (
)
)}
);
}
================================================
FILE: app/frontend/src/components/settings/models/ollama.tsx
================================================
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { AlertTriangle, Brain, CheckCircle, Download, Play, RefreshCw, Server, Square, Trash2, X } from 'lucide-react';
import { useEffect, useState } from 'react';
interface OllamaStatus {
installed: boolean;
running: boolean;
available_models: string[];
server_url: string;
error?: string;
}
interface RecommendedModel {
display_name: string;
model_name: string;
provider: string;
}
interface ModelWithStatus extends RecommendedModel {
isDownloaded: boolean;
}
interface DownloadProgress {
status: string;
percentage?: number;
message?: string;
phase?: string;
bytes_downloaded?: number;
total_bytes?: number;
error?: string;
}
export function OllamaSettings() {
const [ollamaStatus, setOllamaStatus] = useState(null);
const [recommendedModels, setRecommendedModels] = useState([]);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(null);
const [error, setError] = useState(null);
const [downloadProgress, setDownloadProgress] = useState>({});
const [activeDownloads, setActiveDownloads] = useState>(new Set());
const [pollIntervals, setPollIntervals] = useState>(new Set());
const [deleteConfirmation, setDeleteConfirmation] = useState<{
isOpen: boolean;
modelName: string;
displayName: string;
}>({
isOpen: false,
modelName: '',
displayName: ''
});
const [cancellingDownloads, setCancellingDownloads] = useState>(new Set());
const [cancelConfirmation, setCancelConfirmation] = useState<{
isOpen: boolean;
modelName: string;
displayName: string;
}>({
isOpen: false,
modelName: '',
displayName: ''
});
const fetchOllamaStatus = async () => {
try {
const response = await fetch('http://localhost:8000/ollama/status');
if (response.ok) {
const status = await response.json();
setOllamaStatus(status);
setError(null);
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
setError(`Failed to get status: ${errorData.detail}`);
}
} catch (error) {
console.error('Failed to fetch Ollama status:', error);
setError('Failed to connect to backend service');
}
};
const fetchRecommendedModels = async () => {
try {
const response = await fetch('http://localhost:8000/ollama/models/recommended');
if (response.ok) {
const models = await response.json();
setRecommendedModels(models);
} else {
console.error('Failed to fetch recommended models');
}
} catch (error) {
console.error('Failed to fetch recommended models:', error);
}
};
const startOllamaServer = async () => {
setActionLoading('start-server');
setError(null);
try {
const response = await fetch('http://localhost:8000/ollama/start', {
method: 'POST',
});
if (response.ok) {
await fetchOllamaStatus();
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
setError(`Failed to start server: ${errorData.detail}`);
}
} catch (error) {
console.error('Failed to start Ollama server:', error);
setError('Failed to start Ollama server');
}
setActionLoading(null);
};
const stopOllamaServer = async () => {
setActionLoading('stop-server');
setError(null);
try {
const response = await fetch('http://localhost:8000/ollama/stop', {
method: 'POST',
});
if (response.ok) {
await fetchOllamaStatus();
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
setError(`Failed to stop server: ${errorData.detail}`);
}
} catch (error) {
console.error('Failed to stop Ollama server:', error);
setError('Failed to stop Ollama server');
}
setActionLoading(null);
};
const downloadModelWithProgress = async (modelName: string) => {
setError(null);
setActiveDownloads(prev => new Set(prev).add(modelName));
setDownloadProgress(prev => ({
...prev,
[modelName]: { status: 'starting', percentage: 0, message: 'Initializing download...' }
}));
try {
// Make a POST request to the progress endpoint which returns a streaming response
const response = await fetch('http://localhost:8000/ollama/models/download/progress', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ model_name: modelName }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
setError(`Failed to start download for ${modelName}: ${errorData.detail}`);
setActiveDownloads(prev => {
const newSet = new Set(prev);
newSet.delete(modelName);
return newSet;
});
return;
}
// Read the streaming response
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (reader) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonData = line.slice(6).trim();
if (jsonData) {
const data = JSON.parse(jsonData);
setDownloadProgress(prev => ({
...prev,
[modelName]: data
}));
// Check if download is complete or failed
if (data.status === 'completed') {
setActiveDownloads(prev => {
const newSet = new Set(prev);
newSet.delete(modelName);
return newSet;
});
// Immediately clean up progress display for completed downloads
setDownloadProgress(prev => {
const newProgress = { ...prev };
delete newProgress[modelName];
return newProgress;
});
// Refresh status to show the new model with retry logic
const refreshWithRetry = async (attempts = 0) => {
try {
const response = await fetch('http://localhost:8000/ollama/status');
if (response.ok) {
const status = await response.json();
setOllamaStatus(status);
setError(null);
// Check if the model is now in the available models list
if (attempts < 5 && status && !status.available_models.includes(modelName)) {
// Wait a bit longer and try again
setTimeout(() => refreshWithRetry(attempts + 1), 2000);
}
} else if (attempts < 5) {
// If fetch failed, retry
setTimeout(() => refreshWithRetry(attempts + 1), 2000);
}
} catch (error) {
console.error('Failed to refresh status:', error);
if (attempts < 5) {
setTimeout(() => refreshWithRetry(attempts + 1), 2000);
}
}
};
setTimeout(() => refreshWithRetry(), 2000);
return; // Exit the function
} else if (data.status === 'error' || data.status === 'cancelled') {
setActiveDownloads(prev => {
const newSet = new Set(prev);
newSet.delete(modelName);
return newSet;
});
if (data.status === 'error') {
setError(`Download failed for ${modelName}: ${data.message || data.error}`);
}
// Clean up progress display after 3 seconds for errors/cancellations
setTimeout(() => {
setDownloadProgress(prev => {
const newProgress = { ...prev };
delete newProgress[modelName];
return newProgress;
});
}, 3000);
return; // Exit the function
}
}
} catch (e) {
console.error('Error parsing progress data:', e, 'Line:', line);
}
}
}
}
} finally {
reader.releaseLock();
}
}
} catch (error) {
console.error('Failed to download model with progress:', error);
setError(`Failed to download ${modelName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
setActiveDownloads(prev => {
const newSet = new Set(prev);
newSet.delete(modelName);
return newSet;
});
}
};
const performCancelDownload = async (modelName: string) => {
setError(null);
setCancellingDownloads(prev => new Set(prev).add(modelName));
try {
// Call the backend to cancel the download
const response = await fetch(`http://localhost:8000/ollama/models/download/${encodeURIComponent(modelName)}`, {
method: 'DELETE',
});
if (response.ok) {
console.log(`Successfully cancelled download for ${modelName}`);
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
console.warn(`Failed to cancel download for ${modelName}: ${errorData.detail}`);
// Don't show error to user since the UI cleanup will still happen
}
} catch (error) {
console.error('Failed to cancel download:', error);
// Don't show error to user since the UI cleanup will still happen
}
// Always clean up the UI state regardless of backend response
setActiveDownloads(prev => {
const newSet = new Set(prev);
newSet.delete(modelName);
return newSet;
});
setDownloadProgress(prev => {
const newProgress = { ...prev };
delete newProgress[modelName];
return newProgress;
});
setCancellingDownloads(prev => {
const newSet = new Set(prev);
newSet.delete(modelName);
return newSet;
});
console.log(`Cancelled download tracking for ${modelName}`);
};
const cancelDownload = (modelName: string) => {
const displayName = recommendedModels.find(m => m.model_name === modelName)?.display_name || modelName;
setCancelConfirmation({
isOpen: true,
modelName,
displayName
});
};
const deleteModel = async (modelName: string) => {
setActionLoading(`delete-${modelName}`);
setError(null);
try {
const response = await fetch(`http://localhost:8000/ollama/models/${encodeURIComponent(modelName)}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchOllamaStatus();
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
setError(`Failed to delete ${modelName}: ${errorData.detail}`);
}
} catch (error) {
console.error('Failed to delete model:', error);
setError(`Failed to delete ${modelName}`);
}
setActionLoading(null);
};
const confirmDeleteModel = async () => {
const { modelName } = deleteConfirmation;
setDeleteConfirmation({ isOpen: false, modelName: '', displayName: '' });
await deleteModel(modelName);
};
const cancelDeleteModel = () => {
setDeleteConfirmation({ isOpen: false, modelName: '', displayName: '' });
};
const confirmCancelDownload = async () => {
const { modelName } = cancelConfirmation;
setCancelConfirmation({ isOpen: false, modelName: '', displayName: '' });
await performCancelDownload(modelName);
};
const cancelCancelDownload = () => {
setCancelConfirmation({ isOpen: false, modelName: '', displayName: '' });
};
const refreshStatus = async () => {
setLoading(true);
setError(null);
await Promise.all([fetchOllamaStatus(), fetchRecommendedModels()]);
setLoading(false);
};
const checkForActiveDownloads = async () => {
// Check if Ollama is running
if (!ollamaStatus?.running) return;
try {
// Get all active downloads in one call instead of checking each model individually
const response = await fetch('http://localhost:8000/ollama/models/downloads/active');
if (response.ok) {
const activeDownloads = await response.json();
// Update state with any active downloads found (only downloading/starting status)
Object.entries(activeDownloads).forEach(([modelName, progress]) => {
const progressData = progress as DownloadProgress;
// Only add truly active downloads to avoid showing completed ones on refresh
if (progressData.status === 'downloading' || progressData.status === 'starting') {
setActiveDownloads(prev => new Set(prev).add(modelName));
setDownloadProgress(prev => ({
...prev,
[modelName]: progressData
}));
// Start monitoring this download
reconnectToDownload(modelName);
}
});
}
} catch (error) {
// Ignore errors - probably no active downloads or server not available
console.debug('No active downloads found or error checking:', error);
}
};
const reconnectToDownload = async (modelName: string) => {
// Don't reconnect if we're already tracking this download
if (activeDownloads.has(modelName)) {
console.debug(`Already tracking download for ${modelName}`);
return;
}
console.log(`Monitoring existing download for ${modelName}`);
// Poll for progress updates instead of starting a new stream
const pollProgress = async () => {
try {
// Check all active downloads instead of individual model
const response = await fetch('http://localhost:8000/ollama/models/downloads/active');
if (response.ok) {
const activeDownloads = await response.json();
const progress = activeDownloads[modelName];
if (progress) {
setDownloadProgress(prev => ({
...prev,
[modelName]: progress
}));
// Check if download is complete or failed
if (progress.status === 'completed') {
setActiveDownloads(prev => {
const newSet = new Set(prev);
newSet.delete(modelName);
return newSet;
});
// Immediately clean up progress display for completed downloads
setDownloadProgress(prev => {
const newProgress = { ...prev };
delete newProgress[modelName];
return newProgress;
});
// Refresh status to show the new model with retry logic
const refreshWithRetry = async (attempts = 0) => {
try {
const response = await fetch('http://localhost:8000/ollama/status');
if (response.ok) {
const status = await response.json();
setOllamaStatus(status);
setError(null);
// Check if the model is now in the available models list
if (attempts < 5 && status && !status.available_models.includes(modelName)) {
// Wait a bit longer and try again
setTimeout(() => refreshWithRetry(attempts + 1), 2000);
}
} else if (attempts < 5) {
// If fetch failed, retry
setTimeout(() => refreshWithRetry(attempts + 1), 2000);
}
} catch (error) {
console.error('Failed to refresh status:', error);
if (attempts < 5) {
setTimeout(() => refreshWithRetry(attempts + 1), 2000);
}
}
};
setTimeout(() => refreshWithRetry(), 2000);
return false; // Stop polling
} else if (progress.status === 'error' || progress.status === 'cancelled') {
setActiveDownloads(prev => {
const newSet = new Set(prev);
newSet.delete(modelName);
return newSet;
});
if (progress.status === 'error') {
setError(`Download failed for ${modelName}: ${progress.message || progress.error}`);
}
// Clean up progress display after 3 seconds for errors/cancellations
setTimeout(() => {
setDownloadProgress(prev => {
const newProgress = { ...prev };
delete newProgress[modelName];
return newProgress;
});
}, 3000);
return false; // Stop polling
}
return true; // Continue polling
} else {
// Model not in active downloads, remove from tracking
setActiveDownloads(prev => {
const newSet = new Set(prev);
newSet.delete(modelName);
return newSet;
});
return false; // Stop polling
}
} else {
// Error getting active downloads, stop polling
console.error(`Error getting active downloads: ${response.status}`);
return false; // Stop polling
}
} catch (error) {
console.error(`Error polling progress for ${modelName}:`, error);
return false; // Stop polling on error
}
};
// Start polling every 2 seconds
const pollInterval = setInterval(async () => {
const shouldContinue = await pollProgress();
if (!shouldContinue) {
clearInterval(pollInterval);
setPollIntervals(prev => {
const newSet = new Set(prev);
newSet.delete(pollInterval);
return newSet;
});
}
}, 2000);
// Track the interval for cleanup
setPollIntervals(prev => new Set(prev).add(pollInterval));
// Do an initial poll
const shouldContinue = await pollProgress();
if (!shouldContinue) {
clearInterval(pollInterval);
setPollIntervals(prev => {
const newSet = new Set(prev);
newSet.delete(pollInterval);
return newSet;
});
}
};
useEffect(() => {
refreshStatus();
}, []);
// Check for active downloads after we have status and models data
useEffect(() => {
if (ollamaStatus?.running && recommendedModels.length > 0) {
checkForActiveDownloads();
}
}, [ollamaStatus?.running, recommendedModels.length]); // Only depend on running status and whether we have models
// Cleanup polling intervals on unmount
useEffect(() => {
return () => {
pollIntervals.forEach(interval => clearInterval(interval));
};
}, [pollIntervals]);
const getStatusIcon = () => {
if (!ollamaStatus) return ;
if (!ollamaStatus.installed) return ;
if (!ollamaStatus.running) return ;
return ;
};
const getStatusText = () => {
if (!ollamaStatus) return "Checking...";
if (!ollamaStatus.installed) return "Not Installed";
if (!ollamaStatus.running) return "Not Running";
return "Running";
};
const getStatusColor = (): "secondary" | "destructive" | "outline" | "warning" | "success" | null | undefined => {
return "secondary";
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Create a unified list of all models (downloaded first, then available for download)
const allModels: ModelWithStatus[] = [];
// Add downloaded models first (sorted alphabetically)
if (ollamaStatus?.available_models) {
const sortedDownloaded = [...ollamaStatus.available_models].sort();
sortedDownloaded.forEach(modelName => {
// Try to find the model in recommended list for display name
const recommendedModel = recommendedModels.find(m => m.model_name === modelName);
allModels.push({
model_name: modelName,
display_name: recommendedModel?.display_name || modelName,
provider: 'Ollama',
isDownloaded: true
});
});
}
// Add non-downloaded recommended models (sorted alphabetically by display name)
// Exclude models that are already downloaded OR currently being downloaded
const nonDownloadedModels = recommendedModels
.filter(model =>
!ollamaStatus?.available_models?.includes(model.model_name) &&
!activeDownloads.has(model.model_name)
)
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.map(model => ({
...model,
isDownloaded: false
}));
allModels.push(...nonDownloadedModels);
return (
Ollama
Manage local AI models with Ollama for enhanced privacy and performance.
{getStatusIcon()}
{getStatusText()}
{error && (
)}
{!ollamaStatus?.installed && (
Ollama Not Installed
Install Ollama to use local AI models. Visit{' '}
ollama.com
{' '}
to download and install.
)}
{ollamaStatus?.installed && !ollamaStatus.running && (
Ollama Server
Ollama is installed but not currently running.
{actionLoading === 'start-server' ? 'Starting...' : 'Start Server'}
)}
{ollamaStatus?.running && (
Ollama Server Running
Server available at {ollamaStatus.server_url}
{actionLoading === 'stop-server' ? 'Stopping...' : 'Disconnect'}
)}
{ollamaStatus?.running && (
Available Models
{ollamaStatus.available_models.length} downloaded
{/* Show active downloads */}
{Object.entries(downloadProgress).map(([modelName, progress]) => (
{recommendedModels.find(m => m.model_name === modelName)?.display_name || modelName}
{progress.status === 'downloading' && 'Downloading'}
{progress.status === 'completed' && 'Completed'}
{progress.status === 'error' && 'Failed'}
{progress.status === 'cancelled' && 'Cancelled'}
{!['downloading', 'completed', 'error', 'cancelled'].includes(progress.status) && progress.status}
cancelDownload(modelName)}
disabled={cancellingDownloads.has(modelName) || ['completed', 'error', 'cancelled'].includes(progress.status)}
className="text-muted-foreground hover:text-primary h-6 w-6 p-0"
>
{cancellingDownloads.has(modelName) ? (
) : (
)}
{/* Progress bar */}
{progress.phase || progress.status}
{progress.percentage ? `${progress.percentage.toFixed(1)}%` : '...'}
{progress.bytes_downloaded && progress.total_bytes && (
{formatBytes(progress.bytes_downloaded)} / {formatBytes(progress.total_bytes)}
)}
{progress.message && (
{progress.message}
)}
))}
{allModels.length > 0 ? (
{allModels.map((model) => (
{model.display_name}
{model.model_name !== model.display_name && (
{model.model_name}
)}
{model.isDownloaded && (
<>
{
setDeleteConfirmation({
isOpen: true,
modelName: model.model_name,
displayName: model.display_name
});
}}
disabled={actionLoading === `delete-${model.model_name}`}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary h-6 w-6 p-0"
>
>
)}
{!model.isDownloaded && !activeDownloads.has(model.model_name) && (
<>
downloadModelWithProgress(model.model_name)}
className="flex items-center gap-2 h-7 text-primary hover:bg-primary/20 hover:text-primary bg-primary/10 border-primary/30 hover:border-primary/50"
>
Download
>
)}
))}
) : (
)}
)}
{/* Delete Confirmation Dialog */}
{
if (!open) cancelDeleteModel();
}}>
Delete Model
Are you sure you want to delete {deleteConfirmation.displayName} ?
Model: {deleteConfirmation.modelName}
This action cannot be undone. You will need to download the model again to use it.
Cancel
{actionLoading === `delete-${deleteConfirmation.modelName}` ? 'Deleting...' : 'Delete Model'}
{/* Cancel Download Confirmation Dialog */}
{
if (!open) cancelCancelDownload();
}}>
Cancel Download
Are you sure you want to cancel the download of {cancelConfirmation.displayName} ?
Model: {cancelConfirmation.modelName}
Any progress will be lost and you'll need to start the download again.
Continue Download
{cancellingDownloads.has(cancelConfirmation.modelName) ? 'Cancelling...' : 'Cancel Download'}
);
}
================================================
FILE: app/frontend/src/components/settings/models.tsx
================================================
import { cn } from '@/lib/utils';
import { Cloud, Server } from 'lucide-react';
import { useState } from 'react';
import { CloudModels } from './models/cloud';
import { OllamaSettings } from './models/ollama';
interface ModelsProps {
className?: string;
}
interface ModelSection {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
component: React.ComponentType;
}
export function Models({ className }: ModelsProps) {
const [selectedSection, setSelectedSection] = useState('cloud');
const modelSections: ModelSection[] = [
{
id: 'cloud',
label: 'Cloud',
icon: Cloud,
description: 'API-based models from cloud providers',
component: CloudModels,
},
{
id: 'local',
label: 'Ollama',
icon: Server,
description: 'Ollama models running locally on your machine',
component: OllamaSettings,
},
];
const renderContent = () => {
const section = modelSections.find(s => s.id === selectedSection);
if (!section) return null;
const Component = section.component;
return ;
};
return (
Models
Manage your AI models from local and cloud providers.
{/* Model Type Navigation */}
{modelSections.map((section) => {
const Icon = section.icon;
const isSelected = selectedSection === section.id;
const isDisabled = false; // Enable all tabs now that cloud models is functional
return (
!isDisabled && setSelectedSection(section.id)}
disabled={isDisabled}
className={cn(
"flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium rounded-md transition-colors",
isSelected
? "active-bg text-blue-500 shadow-sm"
: isDisabled
? "text-muted-foreground cursor-not-allowed"
: "text-primary hover:text-primary hover-bg"
)}
>
{section.label}
{isDisabled && (
Soon
)}
);
})}
{/* Content Area */}
{renderContent()}
);
}
================================================
FILE: app/frontend/src/components/settings/settings.tsx
================================================
import { cn } from '@/lib/utils';
import { CubeIcon } from '@radix-ui/react-icons';
import { Key, Palette } from 'lucide-react';
import { useState } from 'react';
import { ApiKeysSettings, Models } from './';
import { ThemeSettings } from './appearance';
interface SettingsProps {
className?: string;
}
interface SettingsNavItem {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
description?: string;
}
export function Settings({ className }: SettingsProps) {
const [selectedSection, setSelectedSection] = useState('api');
const navigationItems: SettingsNavItem[] = [
{
id: 'api',
label: 'API Keys',
icon: Key,
description: 'API endpoints and authentication',
},
{
id: 'models',
label: 'Models',
icon: CubeIcon,
description: 'Local and cloud AI models',
},
{
id: 'theme',
label: 'Theme',
icon: Palette,
description: 'Theme and display preferences',
},
];
const renderContent = () => {
switch (selectedSection) {
case 'models':
return ;
case 'theme':
return ;
case 'api':
return ;
default:
return ;
}
};
return (
{/* Left Navigation Pane */}
Settings
{navigationItems.map((item) => {
const Icon = item.icon;
const isSelected = selectedSection === item.id;
return (
setSelectedSection(item.id)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 text-left rounded-md text-sm transition-colors",
isSelected
? "active-bg text-blue-500"
: "text-primary hover-item"
)}
>
{item.label}
);
})}
{/* Right Content Pane */}
);
}
================================================
FILE: app/frontend/src/components/tabs/flow-tab-content.tsx
================================================
import { Flow } from '@/components/Flow';
import { useFlowContext } from '@/contexts/flow-context';
import { useTabsContext } from '@/contexts/tabs-context';
import { setNodeInternalState, setCurrentFlowId as setNodeStateFlowId } from '@/hooks/use-node-state';
import { cn } from '@/lib/utils';
import { flowService } from '@/services/flow-service';
import { Flow as FlowType } from '@/types/flow';
import { useEffect } from 'react';
// Import the flow connection manager to check if flow is actively running
interface FlowTabContentProps {
flow: FlowType;
className?: string;
}
export function FlowTabContent({ flow, className }: FlowTabContentProps) {
const { loadFlow } = useFlowContext();
const { activeTabId } = useTabsContext();
// Enhanced load function that restores both use-node-state and node context data
const loadFlowWithCompleteState = async (flowToLoad: FlowType) => {
try {
const flowId = flowToLoad.id.toString();
// First, set the flow ID for node state isolation
setNodeStateFlowId(flowId);
// DO NOT clear configuration state when switching tabs - useNodeState handles flow isolation automatically
// DO NOT reset runtime data when switching tabs - preserve all runtime state
// Runtime data should only be reset when explicitly starting a new run via the Play button
console.log(`[FlowTabContent] Loading flow ${flowId}, preserving all state (configuration + runtime)`);
// Load the flow using the basic context function (handles React Flow state)
await loadFlow(flowToLoad);
// Then restore internal states for each node (use-node-state data)
if (flowToLoad.nodes) {
flowToLoad.nodes.forEach((node: any) => {
if (node.data?.internal_state) {
setNodeInternalState(node.id, node.data.internal_state);
}
});
}
// NOTE: We intentionally do NOT restore nodeContextData here
// Runtime execution data (messages, analysis, agent status) should start fresh
// Only configuration data (tickers, model selections) is restored above
} catch (error) {
console.error('Failed to load flow with complete state:', error);
throw error;
}
};
// Fetch the latest flow state when this tab becomes active
useEffect(() => {
const isThisTabActive = activeTabId === `flow-${flow.id}`;
if (isThisTabActive) {
const fetchAndLoadFlow = async () => {
try {
// Fetch the latest flow data from the backend
const latestFlow = await flowService.getFlow(flow.id);
// Load the fresh flow data with complete state restoration
await loadFlowWithCompleteState(latestFlow);
} catch (error) {
console.error('Failed to fetch latest flow state:', error);
// Fallback to loading the cached flow data with complete state restoration
await loadFlowWithCompleteState(flow);
}
};
fetchAndLoadFlow();
}
}, [activeTabId, flow.id, flow, loadFlow]);
return (
);
}
================================================
FILE: app/frontend/src/components/tabs/tab-bar.tsx
================================================
import { Button } from '@/components/ui/button';
import { useTabsContext } from '@/contexts/tabs-context';
import { cn } from '@/lib/utils';
import { FileText, Layout, Settings, X } from 'lucide-react';
import { ReactNode, useState } from 'react';
interface TabBarProps {
className?: string;
}
// Get icon for tab type
const getTabIcon = (type: string): ReactNode => {
switch (type) {
case 'flow':
return ;
case 'settings':
return ;
default:
return ;
}
};
export function TabBar({ className }: TabBarProps) {
const { tabs, activeTabId, setActiveTab, closeTab, reorderTabs } = useTabsContext();
const [draggedIndex, setDraggedIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
if (tabs.length === 0) {
return null;
}
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', ''); // Required for some browsers
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedIndex !== null && draggedIndex !== index) {
setDragOverIndex(index);
}
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
if (draggedIndex !== null && draggedIndex !== dropIndex) {
reorderTabs(draggedIndex, dropIndex);
}
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
return (
{tabs.map((tab, index) => (
handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
className={cn(
"group relative flex items-center gap-2 px-4 py-2.5 cursor-pointer transition-all duration-150 min-w-0 max-w-52 select-none last:border-r-0",
// Active tab styling - VSCode style
activeTabId === tab.id
? "bg-panel before:absolute before:bottom-0 before:left-0 before:right-0 before:h-0.5 before:content-['']"
: "bg-panel hover:bg-[var(--tab-hover-background)]",
// Drag states
draggedIndex === index && "opacity-60 scale-[0.98]",
dragOverIndex === index && "ring-1 ring-[var(--tab-accent)]/30",
"hover:cursor-grab active:cursor-grabbing"
)}
style={{
borderRight: `1px solid var(--tab-border)`,
color: activeTabId === tab.id ? 'var(--tab-active-text)' : 'var(--tab-inactive-text)',
backgroundColor: dragOverIndex === index ? 'var(--tab-hover-background)' : undefined,
}}
onMouseEnter={(e) => {
if (activeTabId !== tab.id) {
e.currentTarget.style.color = 'var(--tab-hover-text)';
}
}}
onMouseLeave={(e) => {
if (activeTabId !== tab.id) {
e.currentTarget.style.color = 'var(--tab-inactive-text)';
}
}}
onClick={() => setActiveTab(tab.id)}
>
{/* Active tab accent bar */}
{activeTabId === tab.id && (
)}
{/* Tab Icon */}
{getTabIcon(tab.type)}
{/* Tab Title */}
{tab.title}
{/* Close Button */}
{
e.currentTarget.style.backgroundColor = 'var(--tab-close-hover)';
e.currentTarget.style.color = 'var(--tab-hover-text)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'inherit';
}}
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
onMouseDown={(e) => e.stopPropagation()} // Prevent drag when clicking close button
title="Close tab"
>
{/* Modified indicator dot for unsaved changes - VSCode style */}
{/* You can add this when you implement unsaved changes tracking */}
{/*
*/}
))}
);
}
================================================
FILE: app/frontend/src/components/tabs/tab-content.tsx
================================================
import { useTabsContext } from '@/contexts/tabs-context';
import { cn } from '@/lib/utils';
import { TabService } from '@/services/tab-service';
import { FileText, FolderOpen } from 'lucide-react';
import { useEffect } from 'react';
interface TabContentProps {
className?: string;
}
export function TabContent({ className }: TabContentProps) {
const { tabs, activeTabId, openTab } = useTabsContext();
const activeTab = tabs.find(tab => tab.id === activeTabId);
// Restore content for tabs that don't have it (from localStorage restoration)
useEffect(() => {
if (activeTab && !activeTab.content) {
try {
const restoredTab = TabService.restoreTab({
type: activeTab.type,
title: activeTab.title,
flow: activeTab.flow,
metadata: activeTab.metadata,
});
// Update the tab with restored content
openTab({
id: activeTab.id,
type: restoredTab.type,
title: restoredTab.title,
content: restoredTab.content,
flow: restoredTab.flow,
metadata: restoredTab.metadata,
});
} catch (error) {
console.error('Failed to restore tab content:', error);
}
}
}, [activeTab, openTab]);
if (!activeTab) {
return (
Welcome to the AI Hedge Fund
Create a flow from the left sidebar (⌘B) to open it in a tab, or open settings (⌘,) to configure your preferences.
Flows now open in tabs
);
}
// Show loading state if content is being restored
if (!activeTab.content) {
return (
Loading {activeTab.title}...
);
}
return (
{activeTab.content}
);
}
================================================
FILE: app/frontend/src/components/ui/accordion.tsx
================================================
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
svg]:rotate-180",
className
)}
{...props}
>
{children}
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
{children}
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
================================================
FILE: app/frontend/src/components/ui/badge.tsx
================================================
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md 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",
{
variants: {
variant: {
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-red-500 text-destructive-foreground shadow hover:bg-destructive/80",
warning:
"border-transparent bg-yellow-500 text-primary shadow hover:bg-yellow-500/80",
success:
"border-transparent bg-blue-500 text-primary shadow hover:bg-blue-500/80",
outline: "text-foreground",
},
}
}
)
export interface BadgeProps
extends React.HTMLAttributes,
VariantProps {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
)
}
export { Badge, badgeVariants }
================================================
FILE: app/frontend/src/components/ui/button.tsx
================================================
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {
asChild?: boolean
}
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
================================================
FILE: app/frontend/src/components/ui/card.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardFooter.displayName = "CardFooter"
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
================================================
FILE: app/frontend/src/components/ui/checkbox.tsx
================================================
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
================================================
FILE: app/frontend/src/components/ui/command.tsx
================================================
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import * as React from "react"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
const Command = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
{children}
)
}
const CommandInput = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>((props, ref) => (
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes) => {
return (
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog, CommandEmpty,
CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut
}
================================================
FILE: app/frontend/src/components/ui/dialog.tsx
================================================
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
{children}
Close
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes) => (
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes) => (
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog, DialogClose,
DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger
}
================================================
FILE: app/frontend/src/components/ui/input.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef>(
({ className, type, ...props }, ref) => {
return (
)
}
)
Input.displayName = "Input"
export { Input }
================================================
FILE: app/frontend/src/components/ui/llm-selector.tsx
================================================
import { ChevronsUpDown } from "lucide-react"
import * as React from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { type LanguageModel } from "@/data/models"
import { cn } from "@/lib/utils"
interface ModelSelectorProps {
models: LanguageModel[];
value: string;
onChange: (item: LanguageModel | null) => void;
placeholder?: string;
}
export function ModelSelector({
models,
value,
onChange,
placeholder = "Select a model..."
}: ModelSelectorProps) {
const [open, setOpen] = React.useState(false)
return (
{value
? models.find((model) => model.model_name === value)?.display_name
: placeholder}
No model found.
{models.map((model) => (
{
if (currentValue === value) {
onChange(null);
} else {
const selectedModel = models.find(m => m.model_name === currentValue);
if (selectedModel) {
onChange(selectedModel);
}
}
setOpen(false);
}}
>
{model.display_name}
{model.model_name}
{model.provider}
))}
)
}
================================================
FILE: app/frontend/src/components/ui/popover.tsx
================================================
"use client"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverContent, PopoverTrigger }
================================================
FILE: app/frontend/src/components/ui/resizable.tsx
================================================
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps) => (
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps & {
withHandle?: boolean
}) => (
div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
)}
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
================================================
FILE: app/frontend/src/components/ui/separator.tsx
================================================
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
================================================
FILE: app/frontend/src/components/ui/sheet.tsx
================================================
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef,
VariantProps {}
const SheetContent = React.forwardRef<
React.ElementRef,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
Close
{children}
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes) => (
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes) => (
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
================================================
FILE: app/frontend/src/components/ui/sidebar.tsx
================================================
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
{children}
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
{children}
)
}
if (isMobile) {
return (
Sidebar
Displays the mobile sidebar.
{children}
)
}
return (
{/* This is what handles the sidebar gap on desktop */}
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef,
React.ComponentProps
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
{
onClick?.(event)
toggleSidebar()
}}
{...props}
>
Toggle Sidebar
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef,
React.ComponentProps
>(({ className, ...props }, ref) => {
return (
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef,
React.ComponentProps
>(({ className, ...props }, ref) => {
return (
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps
} & VariantProps
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
{button}
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
{showIcon && (
)}
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => )
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar
}
================================================
FILE: app/frontend/src/components/ui/skeleton.tsx
================================================
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes) {
return (
)
}
export { Skeleton }
================================================
FILE: app/frontend/src/components/ui/sonner.tsx
================================================
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
[data-icon]]:text-blue-500",
},
}}
{...props}
/>
)
}
export { Toaster }
================================================
FILE: app/frontend/src/components/ui/table.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes
>(({ className, ...props }, ref) => (
[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes
>(({ className, ...props }, ref) => (
[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
================================================
FILE: app/frontend/src/components/ui/tabs.tsx
================================================
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsContent, TabsList, TabsTrigger }
================================================
FILE: app/frontend/src/components/ui/tooltip.tsx
================================================
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, sideOffset = 4, ...props }, ref) => (
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
================================================
FILE: app/frontend/src/contexts/flow-context.tsx
================================================
import { getMultiNodeDefinition, isMultiNodeComponent } from '@/data/multi-node-mappings';
import { getNodeTypeDefinition } from '@/data/node-mappings';
import { flowConnectionManager } from '@/hooks/use-flow-connection';
import { clearAllNodeStates, getAllNodeStates, setNodeInternalState, setCurrentFlowId as setNodeStateFlowId } from '@/hooks/use-node-state';
import { flowService } from '@/services/flow-service';
import { Flow } from '@/types/flow';
import { MarkerType, ReactFlowInstance, useReactFlow, XYPosition } from '@xyflow/react';
import { createContext, ReactNode, useCallback, useContext, useState } from 'react';
interface FlowContextType {
addComponentToFlow: (componentName: string) => Promise;
saveCurrentFlow: (name?: string, description?: string) => Promise;
loadFlow: (flow: Flow) => Promise;
createNewFlow: () => Promise;
currentFlowId: number | null;
currentFlowName: string;
isUnsaved: boolean;
reactFlowInstance: ReactFlowInstance;
}
const FlowContext = createContext(null);
export function useFlowContext() {
const context = useContext(FlowContext);
if (!context) {
throw new Error('useFlowContext must be used within a FlowProvider');
}
return context;
}
interface FlowProviderProps {
children: ReactNode;
}
export function FlowProvider({ children }: FlowProviderProps) {
const reactFlowInstance = useReactFlow();
const [currentFlowId, setCurrentFlowId] = useState(null);
const [currentFlowName, setCurrentFlowName] = useState('Untitled Flow');
const [isUnsaved, setIsUnsaved] = useState(false);
// Calculate viewport center position with optional randomness
const getViewportPosition = useCallback((addRandomness = false): XYPosition => {
let position: XYPosition = { x: 0, y: 0 }; // Default position
try {
const { zoom, x, y } = reactFlowInstance.getViewport();
// Get the React Flow container dimensions instead of window dimensions
const flowContainer = document.querySelector('.react-flow__viewport')?.parentElement;
const containerWidth = flowContainer?.clientWidth || window.innerWidth;
const containerHeight = flowContainer?.clientHeight || window.innerHeight;
position = {
x: (containerWidth / 2 - x) / zoom,
y: (containerHeight / 2 - y) / zoom,
};
} catch (err) {
console.warn('Could not get viewport', err);
}
if (addRandomness) {
position.x += Math.random() * 300;
position.y = 0;
}
return position;
}, [reactFlowInstance]);
// Mark flow as unsaved when changes are made
const markAsUnsaved = useCallback(() => {
setIsUnsaved(true);
}, []);
// Save current flow
const saveCurrentFlow = useCallback(async (name?: string, description?: string): Promise => {
try {
const nodes = reactFlowInstance.getNodes();
const edges = reactFlowInstance.getEdges();
const viewport = reactFlowInstance.getViewport();
// Collect all node internal states (from use-node-state)
const nodeStates = getAllNodeStates();
const nodeInternalStates = Object.fromEntries(nodeStates);
// Create structured data - nodeContextData will be added by enhanced save functions
const data = {
nodeStates: nodeInternalStates, // use-node-state data
// nodeContextData will be added separately by enhanced save functions
};
if (currentFlowId) {
// Update existing flow
const updatedFlow = await flowService.updateFlow(currentFlowId, {
name: name || currentFlowName,
description,
nodes,
edges,
viewport,
data,
});
setCurrentFlowName(updatedFlow.name);
setIsUnsaved(false);
// Remember this flow as the last selected
localStorage.setItem('lastSelectedFlowId', updatedFlow.id.toString());
// Ensure the flow ID is set for node state isolation
setNodeStateFlowId(updatedFlow.id.toString());
return updatedFlow;
} else {
// Create new flow
const newFlow = await flowService.createFlow({
name: name || currentFlowName,
description,
nodes,
edges,
viewport,
data,
});
setCurrentFlowId(newFlow.id);
setCurrentFlowName(newFlow.name);
setIsUnsaved(false);
// Remember this flow as the last selected
localStorage.setItem('lastSelectedFlowId', newFlow.id.toString());
// Set the flow ID for node state isolation
setNodeStateFlowId(newFlow.id.toString());
return newFlow;
}
} catch (error) {
console.error('Failed to save flow:', error);
return null;
}
}, [reactFlowInstance, currentFlowId, currentFlowName]);
// Load a flow
const loadFlow = useCallback(async (flow: Flow) => {
try {
// CRITICAL: Set the current flow ID FIRST, before rendering nodes
// This ensures useNodeState hooks initialize with the correct flow ID
setNodeStateFlowId(flow.id.toString());
setCurrentFlowId(flow.id);
setCurrentFlowName(flow.name);
// DO NOT clear configuration state when loading flows - useNodeState handles flow isolation automatically
// Only restore additional internal states if they exist in the flow data
if (flow.data) {
// Handle backward compatibility - data might be direct nodeStates or structured data
const dataToRestore = flow.data.nodeStates || flow.data;
if (dataToRestore) {
Object.entries(dataToRestore).forEach(([nodeId, nodeState]) => {
setNodeInternalState(nodeId, nodeState as Record);
});
}
// nodeContextData restoration will be handled by enhanced load functions
}
// Now render the nodes - useNodeState hooks will initialize with correct flow ID
reactFlowInstance.setNodes(flow.nodes || []);
reactFlowInstance.setEdges(flow.edges || []);
if (flow.viewport) {
reactFlowInstance.setViewport(flow.viewport);
} else {
// Fit view if no viewport data
setTimeout(() => {
reactFlowInstance.fitView();
}, 100);
}
setIsUnsaved(false);
// Remember this flow as the last selected
localStorage.setItem('lastSelectedFlowId', flow.id.toString());
// IMPORTANT: Allow components to mount first, then recover connection state
// This ensures useFlowConnection hooks are initialized before recovery
setTimeout(() => {
// Check if this flow has any stale processing states and recover them
const connection = flowConnectionManager.getConnection(flow.id.toString());
if (connection.state === 'idle') {
// No active connection, so any IN_PROGRESS states are stale and should be reset
console.log(`Flow ${flow.id} loaded - checking for stale connection states`);
}
}, 100);
} catch (error) {
console.error('Failed to load flow:', error);
}
}, [reactFlowInstance]);
// Create a new flow
const createNewFlow = useCallback(async () => {
try {
// CRITICAL: Reset flow ID FIRST, before clearing nodes
setNodeStateFlowId(null);
setCurrentFlowId(null);
setCurrentFlowName('Untitled Flow');
// Clear all node states for the current flow
clearAllNodeStates();
// Clear the React Flow canvas
reactFlowInstance.setNodes([]);
reactFlowInstance.setEdges([]);
reactFlowInstance.setViewport({ x: 0, y: 0, zoom: 1 });
setIsUnsaved(false);
// Clear any active connections when creating a new flow
// Note: We don't have a current flow ID to clear, so this is mainly cleanup
console.log('Created new flow - any previous connections should be cleaned up');
} catch (error) {
console.error('Failed to create new flow:', error);
}
}, [reactFlowInstance]);
// Add a single node to the flow
const addSingleNodeToFlow = useCallback(async (componentName: string) => {
try {
const nodeTypeDefinition = await getNodeTypeDefinition(componentName);
if (!nodeTypeDefinition) {
console.warn(`No node type definition found for component: ${componentName}`);
return;
}
const position = getViewportPosition(false);
const newNode = nodeTypeDefinition.createNode(position);
reactFlowInstance.setNodes((nodes) => [...nodes, newNode]);
markAsUnsaved();
} catch (error) {
console.error(`Failed to add component ${componentName} to flow:`, error);
}
}, [reactFlowInstance, getViewportPosition, markAsUnsaved]);
// Add a multi node (group of nodes with edges) to the flow
const addMultipleNodesToFlow = useCallback(async (name: string) => {
try {
const multiNodeDefinition = getMultiNodeDefinition(name);
if (!multiNodeDefinition) {
console.warn(`No multi node definition found for: ${name}`);
return;
}
const basePosition = getViewportPosition();
// Calculate bounding box of all nodes to center the group
const nodePositions = multiNodeDefinition.nodes.map(node => ({
x: node.offsetX,
y: node.offsetY
}));
const minX = Math.min(...nodePositions.map(pos => pos.x));
const maxX = Math.max(...nodePositions.map(pos => pos.x));
const minY = Math.min(...nodePositions.map(pos => pos.y));
const maxY = Math.max(...nodePositions.map(pos => pos.y));
// Center the group by adjusting base position
const groupCenterX = (minX + maxX) / 2;
const groupCenterY = (minY + maxY) / 2;
const adjustedBasePosition = {
x: basePosition.x - groupCenterX,
y: basePosition.y - groupCenterY,
};
// Create nodes (async)
const newNodes = await Promise.all(
multiNodeDefinition.nodes.map(async (nodeConfig) => {
try {
const nodeTypeDefinition = await getNodeTypeDefinition(nodeConfig.componentName);
if (!nodeTypeDefinition) {
console.warn(`No node type definition found for: ${nodeConfig.componentName}`);
return null;
}
const position = {
x: adjustedBasePosition.x + nodeConfig.offsetX,
y: adjustedBasePosition.y + nodeConfig.offsetY,
};
return nodeTypeDefinition.createNode(position);
} catch (error) {
console.error(`Failed to create node for ${nodeConfig.componentName}:`, error);
return null;
}
})
);
const validNodes = newNodes.filter((node): node is NonNullable => node !== null);
// Create a mapping from component names to actual node IDs
const componentNameToNodeId = new Map();
multiNodeDefinition.nodes.forEach((nodeConfig, index) => {
const correspondingNode = validNodes[index];
if (correspondingNode) {
componentNameToNodeId.set(nodeConfig.componentName, correspondingNode.id);
}
});
// Create edges using the actual node IDs
const newEdges = multiNodeDefinition.edges.map((edgeConfig) => {
const sourceNodeId = componentNameToNodeId.get(edgeConfig.source);
const targetNodeId = componentNameToNodeId.get(edgeConfig.target);
if (!sourceNodeId || !targetNodeId) {
console.warn(`Could not resolve node IDs for edge: ${edgeConfig.source} -> ${edgeConfig.target}`);
return null;
}
return {
id: `${sourceNodeId}-${targetNodeId}`,
source: sourceNodeId,
target: targetNodeId,
markerEnd: {
type: MarkerType.ArrowClosed,
},
};
}).filter((edge): edge is NonNullable => edge !== null);
// Add nodes and edges to flow
reactFlowInstance.setNodes((nodes) => [...nodes, ...validNodes]);
reactFlowInstance.setEdges((edges) => [...edges, ...newEdges]);
markAsUnsaved();
// Fit view to show all nodes after a short delay to ensure nodes are rendered
setTimeout(() => {
reactFlowInstance.fitView({ padding: 0.1, duration: 500 });
}, 100);
} catch (error) {
console.error(`Failed to add multi-node component ${name} to flow:`, error);
}
}, [reactFlowInstance, getViewportPosition, markAsUnsaved]);
// Main entry point - route to single node or multi node
const addComponentToFlow = useCallback(async (componentName: string) => {
if (isMultiNodeComponent(componentName)) {
await addMultipleNodesToFlow(componentName);
} else {
await addSingleNodeToFlow(componentName);
}
}, [addMultipleNodesToFlow, addSingleNodeToFlow]);
const value = {
addComponentToFlow,
saveCurrentFlow,
loadFlow,
createNewFlow,
currentFlowId,
currentFlowName,
isUnsaved,
reactFlowInstance,
};
return (
{children}
);
}
================================================
FILE: app/frontend/src/contexts/layout-context.tsx
================================================
import { SidebarStorageService } from '@/services/sidebar-storage';
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
interface LayoutContextType {
isBottomCollapsed: boolean;
expandBottomPanel: () => void;
collapseBottomPanel: () => void;
toggleBottomPanel: () => void;
setBottomPanelTab: (tab: string) => void;
currentBottomTab: string;
}
const LayoutContext = createContext(null);
export function useLayoutContext() {
const context = useContext(LayoutContext);
if (!context) {
throw new Error('useLayoutContext must be used within a LayoutProvider');
}
return context;
}
interface LayoutProviderProps {
children: ReactNode;
}
export function LayoutProvider({ children }: LayoutProviderProps) {
const [isBottomCollapsed, setIsBottomCollapsed] = useState(() =>
SidebarStorageService.loadBottomPanelState(true)
);
const [currentBottomTab, setCurrentBottomTab] = useState('output');
// Save bottom panel state when it changes
useEffect(() => {
SidebarStorageService.saveBottomPanelState(isBottomCollapsed);
}, [isBottomCollapsed]);
const expandBottomPanel = () => {
setIsBottomCollapsed(false);
};
const collapseBottomPanel = () => {
setIsBottomCollapsed(true);
};
const toggleBottomPanel = () => {
setIsBottomCollapsed(!isBottomCollapsed);
};
const setBottomPanelTab = (tab: string) => {
setCurrentBottomTab(tab);
};
const value = {
isBottomCollapsed,
expandBottomPanel,
collapseBottomPanel,
toggleBottomPanel,
setBottomPanelTab,
currentBottomTab,
};
return (
{children}
);
}
================================================
FILE: app/frontend/src/contexts/node-context.tsx
================================================
import { LanguageModel } from '@/data/models';
import { createContext, ReactNode, useCallback, useContext, useState } from 'react';
export type NodeStatus = 'IDLE' | 'IN_PROGRESS' | 'COMPLETE' | 'ERROR';
// Message history item
export interface MessageItem {
timestamp: string;
message: string;
ticker: string | null;
analysis: Record;
}
// Agent node state structure
export interface AgentNodeData {
status: NodeStatus;
ticker: string | null;
message: string;
lastUpdated: number;
messages: MessageItem[];
timestamp?: string;
analysis: string | null;
backtestResults?: any[];
}
// Data structure for the output node data (from complete event)
export interface OutputNodeData {
decisions: Record;
analyst_signals: Record;
// Backtest-specific fields
performance_metrics?: {
sharpe_ratio?: number;
sortino_ratio?: number;
max_drawdown?: number;
max_drawdown_date?: string;
long_short_ratio?: number;
gross_exposure?: number;
net_exposure?: number;
};
final_portfolio?: {
cash: number;
margin_used: number;
positions: Record;
};
total_days?: number;
}
// Default agent node state
const DEFAULT_AGENT_NODE_STATE: AgentNodeData = {
status: 'IDLE',
ticker: null,
message: '',
messages: [],
lastUpdated: Date.now(),
analysis: null,
};
// Helper function to create flow-aware composite keys
function createCompositeKey(flowId: string | null, nodeId: string): string {
return flowId ? `${flowId}:${nodeId}` : nodeId;
}
interface NodeContextType {
agentNodeData: Record;
outputNodeData: OutputNodeData | null;
agentModels: Record;
updateAgentNode: (flowId: string | null, nodeId: string, data: Partial | NodeStatus) => void;
updateAgentNodes: (flowId: string | null, nodeIds: string[], status: NodeStatus) => void;
setOutputNodeData: (flowId: string | null, data: OutputNodeData) => void;
setAgentModel: (flowId: string | null, nodeId: string, model: LanguageModel | null) => void;
getAgentModel: (flowId: string | null, nodeId: string) => LanguageModel | null;
getAllAgentModels: (flowId: string | null) => Record;
resetAllNodes: (flowId: string | null) => void;
resetNodeStatuses: (flowId: string | null) => void;
exportNodeContextData: (flowId: string | null) => {
agentNodeData: Record;
outputNodeData: OutputNodeData | null;
};
importNodeContextData: (flowId: string | null, data: {
agentNodeData?: Record;
outputNodeData?: OutputNodeData | null;
}) => void;
// New flow-aware functions
getAgentNodeDataForFlow: (flowId: string | null) => Record;
getOutputNodeDataForFlow: (flowId: string | null) => OutputNodeData | null;
}
const NodeContext = createContext(undefined);
export function NodeProvider({ children }: { children: ReactNode }) {
// Use composite keys for flow-aware agent node data storage
const [agentNodeData, setAgentNodeData] = useState>({});
// Flow-aware output node data storage
const [outputNodeData, setOutputNodeData] = useState>({});
// Agent models also need to be flow-aware to maintain model selections per flow
const [agentModels, setAgentModels] = useState>({});
const updateAgentNode = useCallback((flowId: string | null, nodeId: string, data: Partial | NodeStatus) => {
const compositeKey = createCompositeKey(flowId, nodeId);
// Handle string status shorthand (just passing a status string)
if (typeof data === 'string') {
setAgentNodeData(prev => {
const existingNode = prev[compositeKey] || { ...DEFAULT_AGENT_NODE_STATE };
return {
...prev,
[compositeKey]: {
...existingNode,
status: data,
lastUpdated: Date.now()
}
};
});
return;
}
// Handle data object - full update
setAgentNodeData(prev => {
const existingNode = prev[compositeKey] || { ...DEFAULT_AGENT_NODE_STATE };
const newMessages = [...existingNode.messages];
// Add message to history if it's new - use more robust checking
if (data.message && data.timestamp) {
// Check if this exact message already exists (prevent duplicates)
const messageExists = newMessages.some(msg =>
msg.timestamp === data.timestamp &&
msg.message === data.message &&
msg.ticker === data.ticker
);
if (!messageExists) {
const ticker = data.ticker || null;
const messageItem: MessageItem = {
timestamp: data.timestamp,
message: data.message,
ticker: ticker,
analysis: {} as Record,
}
// Add analysis for ticker to messageItem if ticker is not null
if (ticker && data.analysis) {
messageItem.analysis[ticker] = data.analysis;
}
newMessages.push(messageItem);
}
}
const updatedNode = {
...existingNode,
...data,
messages: newMessages,
lastUpdated: Date.now()
};
return {
...prev,
[compositeKey]: updatedNode
};
});
}, []);
const updateAgentNodes = useCallback((flowId: string | null, nodeIds: string[], status: NodeStatus) => {
if (nodeIds.length === 0) return;
setAgentNodeData(prev => {
const newStates = { ...prev };
nodeIds.forEach(id => {
const compositeKey = createCompositeKey(flowId, id);
newStates[compositeKey] = {
...(newStates[compositeKey] || { ...DEFAULT_AGENT_NODE_STATE }),
status,
lastUpdated: Date.now()
};
});
return newStates;
});
}, []);
const setAgentModel = useCallback((flowId: string | null, nodeId: string, model: LanguageModel | null) => {
const compositeKey = createCompositeKey(flowId, nodeId);
setAgentModels(prev => {
if (model === null) {
// Remove the agent model if setting to null
const { [compositeKey]: removed, ...rest } = prev;
return rest;
} else {
// Set the agent model
return {
...prev,
[compositeKey]: model
};
}
});
}, []);
const getAgentModel = useCallback((flowId: string | null, nodeId: string): LanguageModel | null => {
const compositeKey = createCompositeKey(flowId, nodeId);
return agentModels[compositeKey] || null;
}, [agentModels]);
const getAllAgentModels = useCallback((flowId: string | null): Record => {
// Return only models for the specified flow
if (!flowId) {
// If no flow ID, return models without flow prefix (backward compatibility)
return Object.fromEntries(
Object.entries(agentModels).filter(([key]) => !key.includes(':'))
);
}
const flowPrefix = `${flowId}:`;
const currentFlowModels: Record = {};
Object.entries(agentModels).forEach(([compositeKey, model]) => {
if (compositeKey.startsWith(flowPrefix)) {
const nodeId = compositeKey.substring(flowPrefix.length);
currentFlowModels[nodeId] = model;
}
});
return currentFlowModels;
}, [agentModels]);
const setOutputNodeDataForFlow = useCallback((flowId: string | null, data: OutputNodeData) => {
if (!flowId) {
// If no flow ID, use 'default' as key for backward compatibility
setOutputNodeData(prev => ({ ...prev, 'default': data }));
} else {
setOutputNodeData(prev => ({ ...prev, [flowId]: data }));
}
}, []);
const resetAllNodes = useCallback((flowId: string | null) => {
// Clear all agent data for specified flow only
if (!flowId) {
// If no flow ID, clear all data (backward compatibility)
setAgentNodeData({});
setOutputNodeData({});
} else {
// Clear only data for specified flow
const flowPrefix = `${flowId}:`;
setAgentNodeData(prev => {
const newData: Record = {};
Object.entries(prev).forEach(([key, value]) => {
if (!key.startsWith(flowPrefix)) {
newData[key] = value;
}
});
return newData;
});
// Clear output data for specified flow
setOutputNodeData(prev => {
const { [flowId]: removed, ...rest } = prev;
return rest;
});
}
// Note: We don't reset agentModels here as users would want to keep their model selections
}, []);
const resetNodeStatuses = useCallback((flowId: string | null) => {
// Reset only node statuses to IDLE, preserving all data (messages, backtestResults, etc.)
if (!flowId) {
// If no flow ID, reset all node statuses (backward compatibility)
setAgentNodeData(prev => {
const newData: Record = {};
Object.entries(prev).forEach(([key, value]) => {
newData[key] = {
...value,
status: 'IDLE',
lastUpdated: Date.now(),
};
});
return newData;
});
} else {
// Reset only statuses for specified flow
const flowPrefix = `${flowId}:`;
setAgentNodeData(prev => {
const newData: Record = {};
Object.entries(prev).forEach(([key, value]) => {
if (key.startsWith(flowPrefix)) {
// Reset status for this flow's nodes
newData[key] = {
...value,
status: 'IDLE',
lastUpdated: Date.now(),
};
} else {
// Keep other flows' data unchanged
newData[key] = value;
}
});
return newData;
});
}
// Note: We don't touch output data or agent models - only reset processing statuses
}, []);
// Export node context data for persistence
const exportNodeContextData = useCallback((flowId: string | null) => {
// Export agent data for specified flow
const currentFlowAgentData: Record = {};
const flowPrefix = flowId ? `${flowId}:` : '';
Object.entries(agentNodeData).forEach(([compositeKey, data]) => {
if (flowId) {
if (compositeKey.startsWith(flowPrefix)) {
const nodeId = compositeKey.substring(flowPrefix.length);
currentFlowAgentData[nodeId] = data;
}
} else {
// If no flow ID, export data without flow prefix (backward compatibility)
if (!compositeKey.includes(':')) {
currentFlowAgentData[compositeKey] = data;
}
}
});
// Export output data for specified flow
const currentFlowOutputData = flowId
? outputNodeData[flowId] || null
: outputNodeData['default'] || null;
return {
agentNodeData: currentFlowAgentData,
outputNodeData: currentFlowOutputData,
};
}, [agentNodeData, outputNodeData]);
// Import node context data from persistence
const importNodeContextData = useCallback((flowId: string | null, data: {
agentNodeData?: Record;
outputNodeData?: OutputNodeData | null;
}) => {
// Import agent data
if (data.agentNodeData) {
Object.entries(data.agentNodeData).forEach(([nodeId, nodeData]) => {
const compositeKey = createCompositeKey(flowId, nodeId);
setAgentNodeData(prev => ({
...prev,
[compositeKey]: nodeData,
}));
});
}
// Import output data
if (data.outputNodeData) {
if (flowId) {
setOutputNodeData(prev => ({
...prev,
[flowId]: data.outputNodeData!,
}));
} else {
setOutputNodeData(prev => ({
...prev,
'default': data.outputNodeData!,
}));
}
}
}, []);
// Helper functions to get data for a specific flow
const getAgentNodeDataForFlow = useCallback((flowId: string | null): Record => {
if (!flowId) {
// If no flow ID, return data without flow prefix (backward compatibility)
return Object.fromEntries(
Object.entries(agentNodeData).filter(([key]) => !key.includes(':'))
);
}
const flowPrefix = `${flowId}:`;
const currentFlowData: Record = {};
Object.entries(agentNodeData).forEach(([compositeKey, data]) => {
if (compositeKey.startsWith(flowPrefix)) {
const nodeId = compositeKey.substring(flowPrefix.length);
currentFlowData[nodeId] = data;
}
});
return currentFlowData;
}, [agentNodeData]);
const getOutputNodeDataForFlow = useCallback((flowId: string | null): OutputNodeData | null => {
if (!flowId) {
// If no flow ID, return 'default' data for backward compatibility
return outputNodeData['default'] || null;
}
return outputNodeData[flowId] || null;
}, [outputNodeData]);
// Context value object
const contextValue = {
// Legacy getters for backward compatibility - these will return empty data
// Components should use the explicit flow-based functions instead
agentNodeData: {},
outputNodeData: null,
agentModels,
updateAgentNode,
updateAgentNodes,
setOutputNodeData: setOutputNodeDataForFlow,
setAgentModel,
getAgentModel,
getAllAgentModels,
resetAllNodes,
resetNodeStatuses,
exportNodeContextData,
importNodeContextData,
// New flow-aware functions
getAgentNodeDataForFlow,
getOutputNodeDataForFlow,
};
return (
{children}
);
}
export function useNodeContext() {
const context = useContext(NodeContext);
if (context === undefined) {
throw new Error('useNodeContext must be used within a NodeProvider');
}
return context;
}
================================================
FILE: app/frontend/src/contexts/tabs-context.tsx
================================================
import { Flow } from '@/types/flow';
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
// Define tab types
export type TabType = 'flow' | 'settings';
export interface Tab {
id: string;
type: TabType;
title: string;
content: ReactNode;
// For flow tabs
flow?: Flow;
// For other tabs (settings, etc.)
metadata?: Record;
}
// Serializable version of Tab for localStorage (without content)
interface SerializableTab {
id: string;
type: TabType;
title: string;
flow?: Flow;
metadata?: Record;
}
interface TabsContextType {
tabs: Tab[];
activeTabId: string | null;
openTab: (tab: Omit & { id?: string }) => void;
closeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
closeAllTabs: () => void;
isTabOpen: (identifier: string, type: TabType) => boolean;
getTabByIdentifier: (identifier: string, type: TabType) => Tab | undefined;
reorderTabs: (fromIndex: number, toIndex: number) => void;
updateTabTitle: (tabId: string, newTitle: string) => void;
updateFlowTabTitle: (flowId: number, newTitle: string) => void;
}
const TabsContext = createContext(null);
export function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('useTabsContext must be used within a TabsProvider');
}
return context;
}
interface TabsProviderProps {
children: ReactNode;
}
// localStorage keys
const TABS_STORAGE_KEY = 'ai-hedge-fund-tabs';
const ACTIVE_TAB_STORAGE_KEY = 'ai-hedge-fund-active-tab';
export function TabsProvider({ children }: TabsProviderProps) {
const [tabs, setTabs] = useState([]);
const [activeTabId, setActiveTabId] = useState(null);
const [isInitialized, setIsInitialized] = useState(false);
// Generate unique tab ID
const generateTabId = useCallback((type: TabType, identifier?: string): string => {
if (type === 'flow' && identifier) {
return `flow-${identifier}`;
}
if (type === 'settings') {
return 'settings';
}
return `${type}-${Date.now()}`;
}, []);
// Save tabs to localStorage
const saveTabsToStorage = useCallback((tabsToSave: Tab[], activeId: string | null) => {
try {
// Convert tabs to serializable format (without content)
const serializableTabs: SerializableTab[] = tabsToSave.map(tab => ({
id: tab.id,
type: tab.type,
title: tab.title,
flow: tab.flow,
metadata: tab.metadata,
}));
localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(serializableTabs));
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, activeId || '');
} catch (error) {
console.error('Failed to save tabs to localStorage:', error);
}
}, []);
// Load tabs from localStorage
const loadTabsFromStorage = useCallback((): { tabs: SerializableTab[], activeTabId: string | null } => {
try {
const savedTabs = localStorage.getItem(TABS_STORAGE_KEY);
const savedActiveTabId = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
if (savedTabs) {
const parsedTabs: SerializableTab[] = JSON.parse(savedTabs);
return {
tabs: parsedTabs,
activeTabId: savedActiveTabId || null,
};
}
} catch (error) {
console.error('Failed to load tabs from localStorage:', error);
}
return { tabs: [], activeTabId: null };
}, []);
// Initialize tabs from localStorage on mount
useEffect(() => {
if (!isInitialized) {
const { tabs: savedTabs, activeTabId: savedActiveTabId } = loadTabsFromStorage();
if (savedTabs.length > 0) {
// We'll restore the content later when the tab service is available
// For now, just set up the tab structure
const restoredTabs: Tab[] = savedTabs.map(savedTab => ({
...savedTab,
content: null, // Will be filled in by TabService when tabs are accessed
}));
setTabs(restoredTabs);
setActiveTabId(savedActiveTabId);
}
setIsInitialized(true);
}
}, [isInitialized, loadTabsFromStorage]);
// Save tabs to localStorage whenever they change
useEffect(() => {
if (isInitialized) {
saveTabsToStorage(tabs, activeTabId);
}
}, [tabs, activeTabId, isInitialized, saveTabsToStorage]);
// Check if a tab is already open
const isTabOpen = useCallback((identifier: string, type: TabType): boolean => {
const tabId = generateTabId(type, identifier);
return tabs.some(tab => tab.id === tabId);
}, [tabs, generateTabId]);
// Get tab by identifier
const getTabByIdentifier = useCallback((identifier: string, type: TabType): Tab | undefined => {
const tabId = generateTabId(type, identifier);
return tabs.find(tab => tab.id === tabId);
}, [tabs, generateTabId]);
// Open a new tab or focus existing one
const openTab = useCallback((tabData: Omit & { id?: string }) => {
const tabId = tabData.id || generateTabId(tabData.type,
tabData.type === 'flow' && tabData.flow ? tabData.flow.id.toString() : undefined
);
setTabs(prevTabs => {
// Check if tab already exists
const existingTabIndex = prevTabs.findIndex(tab => tab.id === tabId);
if (existingTabIndex !== -1) {
// Tab exists, just update it and focus
const updatedTabs = [...prevTabs];
updatedTabs[existingTabIndex] = { ...tabData, id: tabId };
setActiveTabId(tabId);
return updatedTabs;
} else {
// Create new tab
const newTab: Tab = { ...tabData, id: tabId };
setActiveTabId(tabId);
return [...prevTabs, newTab];
}
});
}, [generateTabId]);
// Close a tab
const closeTab = useCallback((tabId: string) => {
setTabs(prevTabs => {
const newTabs = prevTabs.filter(tab => tab.id !== tabId);
// If closing active tab, set new active tab
if (activeTabId === tabId) {
if (newTabs.length > 0) {
// Find the index of the closed tab
const closedIndex = prevTabs.findIndex(tab => tab.id === tabId);
// Try to activate the tab to the right, or the last tab if closing the last one
const newActiveIndex = closedIndex < newTabs.length ? closedIndex : newTabs.length - 1;
setActiveTabId(newTabs[newActiveIndex]?.id || null);
} else {
setActiveTabId(null);
}
}
return newTabs;
});
}, [activeTabId]);
// Set active tab
const setActiveTab = useCallback((tabId: string) => {
if (tabs.some(tab => tab.id === tabId)) {
setActiveTabId(tabId);
}
}, [tabs]);
// Close all tabs
const closeAllTabs = useCallback(() => {
setTabs([]);
setActiveTabId(null);
}, []);
// Reorder tabs
const reorderTabs = useCallback((fromIndex: number, toIndex: number) => {
setTabs(prevTabs => {
const newTabs = [...prevTabs];
const [movedTab] = newTabs.splice(fromIndex, 1);
newTabs.splice(toIndex, 0, movedTab);
return newTabs;
});
}, []);
// Update tab title
const updateTabTitle = useCallback((tabId: string, newTitle: string) => {
setTabs(prevTabs => {
const updatedTabs = prevTabs.map(tab =>
tab.id === tabId ? { ...tab, title: newTitle } : tab
);
return updatedTabs;
});
}, []);
// Update flow tab title
const updateFlowTabTitle = useCallback((flowId: number, newTitle: string) => {
setTabs(prevTabs => {
const updatedTabs = prevTabs.map(tab => {
if (tab.type === 'flow' && tab.flow?.id === flowId) {
return {
...tab,
title: newTitle,
// Also update the flow object's name to keep it in sync
flow: tab.flow ? { ...tab.flow, name: newTitle } : tab.flow
};
}
return tab;
});
return updatedTabs;
});
}, []);
const value = {
tabs,
activeTabId,
openTab,
closeTab,
setActiveTab,
closeAllTabs,
isTabOpen,
getTabByIdentifier,
reorderTabs,
updateTabTitle,
updateFlowTabTitle,
};
return (
{children}
);
}
================================================
FILE: app/frontend/src/data/agents.ts
================================================
import { api } from '@/services/api';
export interface Agent {
key: string;
display_name: string;
description: string;
investing_style: string;
order: number;
}
// In-memory cache for agents to avoid repeated API calls
let agents: Agent[] | null = null;
/**
* Get the list of agents from the backend API
* Uses caching to avoid repeated API calls
*/
export const getAgents = async (): Promise => {
if (agents) {
return agents;
}
try {
agents = await api.getAgents();
return agents;
} catch (error) {
console.error('Failed to fetch agents:', error);
throw error; // Let the calling component handle the error
}
};
================================================
FILE: app/frontend/src/data/models.ts
================================================
import { api } from '@/services/api';
export interface LanguageModel {
display_name: string;
model_name: string;
provider: "Anthropic" | "DeepSeek" | "Google" | "Groq" | "OpenAI";
}
// Cache for models to avoid repeated API calls
let languageModels: LanguageModel[] | null = null;
/**
* Get the list of models from the backend API
* Uses caching to avoid repeated API calls
*/
export const getModels = async (): Promise => {
if (languageModels) {
return languageModels;
}
try {
languageModels = await api.getLanguageModels();
return languageModels;
} catch (error) {
console.error('Failed to fetch models:', error);
throw error; // Let the calling component handle the error
}
};
/**
* Get the default model (GPT-4.1) from the models list
*/
export const getDefaultModel = async (): Promise => {
try {
const models = await getModels();
return models.find(model => model.model_name === "gpt-4.1") || models[0] || null;
} catch (error) {
console.error('Failed to get default model:', error);
return null;
}
};
================================================
FILE: app/frontend/src/data/multi-node-mappings.ts
================================================
export interface MultiNodeDefinition {
name: string;
nodes: {
componentName: string;
offsetX: number;
offsetY: number;
}[];
edges: {
source: string;
target: string;
}[];
}
const multiNodeDefinition: Record = {
"Value Investors": {
name: "Value Investors",
nodes: [
{ componentName: "Stock Input", offsetX: 0, offsetY: 0 },
{ componentName: "Ben Graham", offsetX: 400, offsetY: -400 },
{ componentName: "Charlie Munger", offsetX: 400, offsetY: 0 },
{ componentName: "Warren Buffett", offsetX: 400, offsetY: 400 },
{ componentName: "Portfolio Manager", offsetX: 800, offsetY: 0 },
],
edges: [
{ source: "Stock Input", target: "Ben Graham" },
{ source: "Stock Input", target: "Charlie Munger" },
{ source: "Stock Input", target: "Warren Buffett" },
{ source: "Ben Graham", target: "Portfolio Manager" },
{ source: "Charlie Munger", target: "Portfolio Manager" },
{ source: "Warren Buffett", target: "Portfolio Manager" },
],
},
"Data Wizards": {
name: "Data Wizards",
nodes: [
{ componentName: "Stock Input", offsetX: 0, offsetY: 0 },
{ componentName: "Technical Analyst", offsetX: 400, offsetY: -550 },
{ componentName: "Fundamentals Analyst", offsetX: 400, offsetY: -200 },
{ componentName: "Sentiment Analyst", offsetX: 400, offsetY: 150 },
{ componentName: "Valuation Analyst", offsetX: 400, offsetY: 500 },
{ componentName: "Portfolio Manager", offsetX: 800, offsetY: 0 },
],
edges: [
{ source: "Stock Input", target: "Technical Analyst" },
{ source: "Stock Input", target: "Fundamentals Analyst" },
{ source: "Stock Input", target: "Sentiment Analyst" },
{ source: "Stock Input", target: "Valuation Analyst" },
{ source: "Technical Analyst", target: "Portfolio Manager" },
{ source: "Fundamentals Analyst", target: "Portfolio Manager" },
{ source: "Sentiment Analyst", target: "Portfolio Manager" },
{ source: "Valuation Analyst", target: "Portfolio Manager" },
],
},
"Market Mavericks": {
name: "Market Mavericks",
nodes: [
{ componentName: "Stock Input", offsetX: 0, offsetY: 0 },
{ componentName: "Michael Burry", offsetX: 400, offsetY: -400 },
{ componentName: "Bill Ackman", offsetX: 400, offsetY: 0 },
{ componentName: "Stanley Druckenmiller", offsetX: 400, offsetY: 400 },
{ componentName: "Portfolio Manager", offsetX: 800, offsetY: 0 },
],
edges: [
{ source: "Stock Input", target: "Michael Burry" },
{ source: "Stock Input", target: "Bill Ackman" },
{ source: "Stock Input", target: "Stanley Druckenmiller" },
{ source: "Michael Burry", target: "Portfolio Manager" },
{ source: "Bill Ackman", target: "Portfolio Manager" },
{ source: "Stanley Druckenmiller", target: "Portfolio Manager" },
],
},
};
export function getMultiNodeDefinition(name: string): MultiNodeDefinition | null {
return multiNodeDefinition[name] || null;
}
export function isMultiNodeComponent(componentName: string): boolean {
return componentName in multiNodeDefinition;
}
================================================
FILE: app/frontend/src/data/node-mappings.ts
================================================
import { AppNode } from "@/nodes/types";
import { Agent, getAgents } from "./agents";
// Map of sidebar item names to node creation functions
export interface NodeTypeDefinition {
createNode: (position: { x: number, y: number }) => AppNode;
}
// Cache for node type definitions to avoid repeated API calls
let nodeTypeDefinitionsCache: Record | null = null;
// Utility function to generate unique short ID suffix
const generateUniqueIdSuffix = (): string => {
// Generate a short random ID (6 characters)
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
/**
* Extract the base agent key from a unique node ID
* @param uniqueId The unique node ID with suffix (e.g., "warren_buffett_abc123")
* @returns The base agent key (e.g., "warren_buffett")
*/
export const extractBaseAgentKey = (uniqueId: string): string => {
// For agent nodes, remove the last underscore and 6-character suffix
// For other nodes like portfolio_manager, also remove the suffix
const parts = uniqueId.split('_');
if (parts.length >= 2) {
const lastPart = parts[parts.length - 1];
// If the last part is a 6-character alphanumeric string, it's likely our suffix
if (lastPart.length === 6 && /^[a-z0-9]+$/.test(lastPart)) {
return parts.slice(0, -1).join('_');
}
}
return uniqueId; // Return original if no suffix pattern found
};
// Define base node creation functions (non-agent nodes)
const baseNodeTypeDefinitions: Record = {
"Portfolio Input": {
createNode: (position: { x: number, y: number }): AppNode => ({
id: `portfolio-start-node_${generateUniqueIdSuffix()}`,
type: "portfolio-start-node",
position,
data: {
name: "Portfolio Input",
description: "Enter your portfolio including tickers, shares, and prices. Connect this node to Analysts to generate insights.",
status: "Idle",
},
}),
},
"Portfolio Manager": {
createNode: (position: { x: number, y: number }): AppNode => ({
id: `portfolio_manager_${generateUniqueIdSuffix()}`,
type: "portfolio-manager-node",
position,
data: {
name: "Portfolio Manager",
description: "Generates investment decisions based on input from Analysts.",
status: "Idle",
},
}),
},
"Stock Input": {
createNode: (position: { x: number, y: number }): AppNode => ({
id: `stock-analyzer-node_${generateUniqueIdSuffix()}`,
type: "stock-analyzer-node",
position,
data: {
name: "Stock Input",
description: "Enter individual stocks and connect this node to Analysts to generate insights.",
status: "Idle",
},
}),
},
};
/**
* Get all node type definitions, including agents fetched from the backend
*/
const getNodeTypeDefinitions = async (): Promise> => {
if (nodeTypeDefinitionsCache) {
return nodeTypeDefinitionsCache;
}
const agents = await getAgents();
// Create agent node definitions
const agentNodeDefinitions = agents.reduce((acc: Record, agent: Agent) => {
acc[agent.display_name] = {
createNode: (position: { x: number, y: number }): AppNode => ({
id: `${agent.key}_${generateUniqueIdSuffix()}`,
type: "agent-node",
position,
data: {
name: agent.display_name,
description: agent.investing_style || "",
status: "Idle",
},
}),
};
return acc;
}, {});
// Combine base and agent definitions
nodeTypeDefinitionsCache = {
...baseNodeTypeDefinitions,
...agentNodeDefinitions,
};
return nodeTypeDefinitionsCache;
};
export async function getNodeTypeDefinition(componentName: string): Promise {
const nodeTypeDefinitions = await getNodeTypeDefinitions();
return nodeTypeDefinitions[componentName] || null;
}
// Get the node ID that would be generated for a component
export async function getNodeIdForComponent(componentName: string): Promise {
const nodeTypeDefinition = await getNodeTypeDefinition(componentName);
if (!nodeTypeDefinition) {
return null;
}
// Extract ID by creating a temporary node (position doesn't matter for ID extraction)
const tempNode = nodeTypeDefinition.createNode({ x: 0, y: 0 });
return tempNode.id;
}
/**
* Clear the node type definitions cache - useful for testing or when you want to force a refresh
*/
export const clearNodeTypeDefinitionsCache = () => {
nodeTypeDefinitionsCache = null;
};
================================================
FILE: app/frontend/src/data/sidebar-components.ts
================================================
import {
BadgeDollarSign,
Bot,
Brain,
Calculator,
ChartLine,
ChartPie,
LucideIcon,
Network,
Play,
Zap
} from 'lucide-react';
import { Agent, getAgents } from './agents';
// Define component items by group
export interface ComponentItem {
name: string;
icon: LucideIcon;
}
export interface ComponentGroup {
name: string;
icon: LucideIcon;
iconColor: string;
items: ComponentItem[];
}
/**
* Get all component groups, including agents fetched from the backend
*/
export const getComponentGroups = async (): Promise => {
const agents = await getAgents();
return [
{
name: "Start Nodes",
icon: Play,
iconColor: "text-blue-500",
items: [
{ name: "Portfolio Input", icon: ChartPie },
{ name: "Stock Input", icon: ChartLine },
]
},
{
name: "Analysts",
icon: Bot,
iconColor: "text-red-500",
items: agents.map((agent: Agent) => ({
name: agent.display_name,
icon: Bot
}))
},
{
name: "Swarms",
icon: Network,
iconColor: "text-yellow-500",
items: [
{ name: "Data Wizards", icon: Calculator },
{ name: "Market Mavericks", icon: Zap },
{ name: "Value Investors", icon: BadgeDollarSign },
]
},
{
name: "End Nodes",
icon: Brain,
iconColor: "text-green-500",
items: [
{ name: "Portfolio Manager", icon: Brain },
// { name: "JSON Output", icon: FileJson },
// { name: "Investment Report", icon: FileText },
]
},
];
};
================================================
FILE: app/frontend/src/edges/index.ts
================================================
import type { EdgeTypes } from '@xyflow/react';
export const edgeTypes = {
// Add your custom edge types here!
} satisfies EdgeTypes;
================================================
FILE: app/frontend/src/hooks/use-component-groups.ts
================================================
import { ComponentGroup } from '@/data/sidebar-components';
import { useEffect, useMemo, useState } from 'react';
export function useComponentGroups(componentGroups: ComponentGroup[]) {
const [searchQuery, setSearchQuery] = useState('');
const [activeItem, setActiveItem] = useState('Chat Input');
const [openGroups, setOpenGroups] = useState([]); // Start with all groups collapsed
const [isSearching, setIsSearching] = useState(false);
// Filter groups and items based on search query
const filteredGroups = useMemo(() => {
if (!searchQuery) return componentGroups;
return componentGroups.map(group => {
// Filter items within the group
const filteredItems = group.items.filter(item =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Return group with filtered items
return {
...group,
items: filteredItems
};
}).filter(group => group.items.length > 0); // Only include groups with matching items
}, [componentGroups, searchQuery]);
// Handle search query changes
useEffect(() => {
if (searchQuery) {
setIsSearching(true);
// Open all groups that have matching items
setOpenGroups(filteredGroups.map(group => group.name));
} else if (isSearching) {
// Only reset groups when exiting search mode
setIsSearching(false);
}
}, [searchQuery, filteredGroups]);
// Handle accordion value changes
const handleAccordionChange = (value: string[]) => {
// Only update if we're not actively searching
if (!searchQuery) {
setOpenGroups(value);
} else {
// During search, we need to preserve expanded groups that have matches
const matchingGroups = filteredGroups.map(group => group.name);
// Keep all matching groups open while allowing manual toggling of others
const newValue = value.filter(group => matchingGroups.includes(group));
if (newValue.length < matchingGroups.length) {
// If user is closing a search result group, allow that
setOpenGroups(newValue);
} else {
// User is opening a new group during search
setOpenGroups(value);
}
}
};
return {
searchQuery,
setSearchQuery,
activeItem,
setActiveItem,
openGroups,
setOpenGroups,
isSearching,
filteredGroups,
handleAccordionChange
};
}
================================================
FILE: app/frontend/src/hooks/use-enhanced-flow-actions.ts
================================================
import { useFlowContext } from '@/contexts/flow-context';
import { useNodeContext } from '@/contexts/node-context';
import {
getNodeInternalState,
setNodeInternalState,
setCurrentFlowId as setNodeStateFlowId
} from '@/hooks/use-node-state';
import { flowService } from '@/services/flow-service';
import { Flow } from '@/types/flow';
import { useCallback } from 'react';
/**
* Enhanced flow actions that include complete state persistence
* (both use-node-state data and node context data)
*/
export function useEnhancedFlowActions() {
const { saveCurrentFlow, loadFlow, reactFlowInstance, currentFlowId } = useFlowContext();
const { exportNodeContextData } = useNodeContext();
// Enhanced save that includes node context data
const saveCurrentFlowWithCompleteState = useCallback(async (name?: string, description?: string): Promise => {
try {
// Get current nodes from React Flow
const currentNodes = reactFlowInstance.getNodes();
// Get node context data (runtime data: agent status, messages, output data)
const flowId = currentFlowId?.toString() || null;
const nodeContextData = exportNodeContextData(flowId);
// Enhance nodes with internal states
const nodesWithStates = currentNodes.map((node: any) => {
const internalState = getNodeInternalState(node.id);
return {
...node,
data: {
...node.data,
// Only add internal_state if there is actually state to save
...(internalState && Object.keys(internalState).length > 0 ? { internal_state: internalState } : {})
}
};
});
// Temporarily replace nodes in React Flow with enhanced nodes
reactFlowInstance.setNodes(nodesWithStates);
try {
// Use the basic save function
const savedFlow = await saveCurrentFlow(name, description);
if (savedFlow) {
// After basic save, update with node context data
const updatedFlow = await flowService.updateFlow(savedFlow.id, {
...savedFlow,
data: {
...savedFlow.data,
nodeContextData, // Add runtime data from node context
}
});
return updatedFlow;
}
return savedFlow;
} finally {
// Restore original nodes (without internal_state in React Flow)
reactFlowInstance.setNodes(currentNodes);
}
} catch (err) {
console.error('Failed to save flow with complete state:', err);
return null;
}
}, [reactFlowInstance, saveCurrentFlow, exportNodeContextData, currentFlowId]);
// Enhanced load that restores node context data
const loadFlowWithCompleteState = useCallback(async (flow: Flow) => {
try {
// First, set the flow ID for node state isolation
setNodeStateFlowId(flow.id.toString());
// DO NOT clear configuration state when loading flows - useNodeState handles flow isolation automatically
// DO NOT reset runtime data when loading flows - preserve all runtime state
// Runtime data should only be reset when explicitly starting a new run via the Play button
console.log(`[EnhancedFlowActions] Loading flow ${flow.id} (${flow.name}), preserving all state (configuration + runtime)`);
// Load the flow using the basic function (handles React Flow state)
await loadFlow(flow);
// Then restore internal states for each node (use-node-state data)
if (flow.nodes) {
flow.nodes.forEach((node: any) => {
if (node.data?.internal_state) {
setNodeInternalState(node.id, node.data.internal_state);
}
});
}
// NOTE: We intentionally do NOT restore nodeContextData here
// Runtime execution data (messages, analysis, agent status) should start fresh
// Only configuration data (tickers, model selections) is restored above
console.log('Flow loaded with complete state restoration:', flow.name);
} catch (error) {
console.error('Failed to load flow with complete state:', error);
throw error;
}
}, [loadFlow]);
return {
saveCurrentFlowWithCompleteState,
loadFlowWithCompleteState,
};
}
================================================
FILE: app/frontend/src/hooks/use-flow-connection.ts
================================================
import { useNodeContext } from '@/contexts/node-context';
import { api } from '@/services/api';
import { backtestApi } from '@/services/backtest-api';
import { BacktestRequest, HedgeFundRequest } from '@/services/types';
import { useCallback, useEffect, useRef, useState } from 'react';
// Connection state for a specific flow
export type FlowConnectionState = 'idle' | 'connecting' | 'connected' | 'error' | 'completed';
interface FlowConnectionInfo {
state: FlowConnectionState;
abortController: (() => void) | null;
startTime: number;
lastActivity: number;
error?: string;
}
// Global connection manager - tracks all active flow connections
class FlowConnectionManager {
private connections = new Map();
private listeners = new Set<() => void>();
// Get connection info for a flow
getConnection(flowId: string): FlowConnectionInfo {
return this.connections.get(flowId) || {
state: 'idle',
abortController: null,
startTime: 0,
lastActivity: 0,
};
}
// Set connection info for a flow
setConnection(flowId: string, info: Partial): void {
const existing = this.getConnection(flowId);
const updated = {
...existing,
...info,
lastActivity: Date.now(),
};
this.connections.set(flowId, updated);
this.notifyListeners();
}
// Remove connection for a flow
removeConnection(flowId: string): void {
const connection = this.connections.get(flowId);
if (connection?.abortController) {
connection.abortController();
}
this.connections.delete(flowId);
this.notifyListeners();
}
// Add listener for connection changes
addListener(listener: () => void): void {
this.listeners.add(listener);
}
// Remove listener
removeListener(listener: () => void): void {
this.listeners.delete(listener);
}
// Notify all listeners of changes
private notifyListeners(): void {
this.listeners.forEach(listener => listener());
}
}
// Global instance
export const flowConnectionManager = new FlowConnectionManager();
/**
* Hook for managing flow connections and execution
* @param flowId The ID of the flow to manage
* @returns Connection state and control functions
*/
export function useFlowConnection(flowId: string | null) {
const nodeContext = useNodeContext();
const [, forceUpdate] = useState({});
const listenerRef = useRef<() => void>();
// Force re-render when connections change
useEffect(() => {
const listener = () => forceUpdate({});
listenerRef.current = listener;
flowConnectionManager.addListener(listener);
return () => {
if (listenerRef.current) {
flowConnectionManager.removeListener(listenerRef.current);
}
};
}, []);
// Get current connection state
const connection = flowId ? flowConnectionManager.getConnection(flowId) : null;
const isConnecting = connection?.state === 'connecting';
const isConnected = connection?.state === 'connected';
const isError = connection?.state === 'error';
const isCompleted = connection?.state === 'completed';
// Check if any agents are currently processing
const isProcessing = flowId ? (() => {
const agentData = nodeContext.getAgentNodeDataForFlow(flowId);
return Object.values(agentData).some(agent => agent.status === 'IN_PROGRESS');
})() : false;
// Can run if we have a flow ID and we're not already running
const canRun = Boolean(flowId && !isConnecting && !isConnected && !isProcessing);
// Start a flow connection
const runFlow = useCallback((params: HedgeFundRequest) => {
if (!flowId || !canRun) return;
// Reset node states for this flow
nodeContext.resetAllNodes(flowId);
// Set connecting state
flowConnectionManager.setConnection(flowId, {
state: 'connecting',
startTime: Date.now(),
});
try {
// Start the API call
const abortController = api.runHedgeFund(params, nodeContext, flowId);
// Update connection with abort controller
flowConnectionManager.setConnection(flowId, {
state: 'connected',
abortController,
});
// TODO: We should enhance the API to notify us when the connection completes
// For now, we'll rely on the complete event from the SSE stream
} catch (error) {
console.error('Failed to start hedge fund run:', error);
flowConnectionManager.setConnection(flowId, {
state: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
abortController: null,
});
}
}, [flowId, canRun, nodeContext]);
// Start a backtest connection
const runBacktest = useCallback((params: BacktestRequest) => {
if (!flowId || !canRun) return;
// Reset node states for this flow
nodeContext.resetAllNodes(flowId);
// Set connecting state
flowConnectionManager.setConnection(flowId, {
state: 'connecting',
startTime: Date.now(),
});
try {
// Start the backtest API call
const abortController = backtestApi.runBacktest(params, nodeContext, flowId);
// Update connection with abort controller
flowConnectionManager.setConnection(flowId, {
state: 'connected',
abortController,
});
// TODO: We should enhance the API to notify us when the connection completes
// For now, we'll rely on the complete event from the SSE stream
} catch (error) {
console.error('Failed to start backtest:', error);
flowConnectionManager.setConnection(flowId, {
state: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
abortController: null,
});
}
}, [flowId, canRun, nodeContext]);
// Stop a flow connection
const stopFlow = useCallback(() => {
if (!flowId) return;
console.log(`[stopFlow] Stopping flow ${flowId}`);
const connection = flowConnectionManager.getConnection(flowId);
console.log(`[stopFlow] Current connection state:`, connection);
if (connection.abortController) {
console.log(`[stopFlow] Calling abort controller for flow ${flowId}`);
connection.abortController();
} else {
console.log(`[stopFlow] No abort controller found for flow ${flowId}`);
}
// Reset only node statuses when stopping, preserving all data (backtest results, messages, etc.)
nodeContext.resetNodeStatuses(flowId);
// Update connection state
flowConnectionManager.setConnection(flowId, {
state: 'idle',
abortController: null,
});
console.log(`[stopFlow] Flow ${flowId} stopped and reset to idle`);
}, [flowId, nodeContext]);
// Recover from stale states (called when loading a flow)
const recoverFlowState = useCallback(() => {
if (!flowId) return;
const connection = flowConnectionManager.getConnection(flowId);
// If we think we're connected but have no processing nodes, we're probably stale
if ((connection.state === 'connected' || connection.state === 'connecting') && !isProcessing) {
// Check if the connection is old (more than 5 minutes)
const isStale = Date.now() - connection.lastActivity > 5 * 60 * 1000;
if (isStale) {
console.log(`Recovering stale connection for flow ${flowId}`);
flowConnectionManager.setConnection(flowId, {
state: 'idle',
abortController: null,
});
}
}
}, [flowId, isProcessing]);
return {
// State
isConnecting,
isConnected,
isError,
isCompleted,
isProcessing,
canRun,
error: connection?.error,
// Actions
runFlow,
runBacktest,
stopFlow,
recoverFlowState,
};
}
// Utility hook to get connection state for any flow (for monitoring)
export function useFlowConnectionState(flowId: string | null) {
const [, forceUpdate] = useState({});
useEffect(() => {
if (!flowId) return;
const unsubscribe = flowConnectionManager.addListener(() => {
forceUpdate({});
});
return unsubscribe;
}, [flowId]);
return flowId ? flowConnectionManager.getConnection(flowId) : null;
}
================================================
FILE: app/frontend/src/hooks/use-flow-history.ts
================================================
import { Edge, Node, useReactFlow } from '@xyflow/react';
import { useCallback, useRef, useState } from 'react';
interface FlowSnapshot {
nodes: Node[];
edges: Edge[];
timestamp: number;
}
interface UseFlowHistoryOptions {
maxHistorySize?: number;
flowId?: number | null;
}
export function useFlowHistory({ maxHistorySize = 50, flowId }: UseFlowHistoryOptions = {}) {
const { getNodes, getEdges, setNodes, setEdges } = useReactFlow();
const [historyIndexes, setHistoryIndexes] = useState>({});
const histories = useRef>({});
const isUndoRedoAction = useRef(false);
// Get flow-specific history key
const getFlowKey = useCallback((id: number | null) => {
return id ? `flow-${id}` : 'new-flow';
}, []);
// Get current flow's history and index
const getCurrentFlowHistory = useCallback(() => {
const flowKey = getFlowKey(flowId ?? null);
if (!histories.current[flowKey]) {
histories.current[flowKey] = [];
}
return histories.current[flowKey];
}, [flowId, getFlowKey]);
const getCurrentHistoryIndex = useCallback(() => {
const flowKey = getFlowKey(flowId ?? null);
return historyIndexes[flowKey] ?? -1;
}, [flowId, getFlowKey, historyIndexes]);
const setCurrentHistoryIndex = useCallback((index: number) => {
const flowKey = getFlowKey(flowId ?? null);
setHistoryIndexes(prev => ({ ...prev, [flowKey]: index }));
}, [flowId, getFlowKey]);
// Create a snapshot of current state (excluding UI-only properties)
const createSnapshot = useCallback((): FlowSnapshot => {
// Strip UI-only properties from nodes (like selection state)
const cleanNodes = getNodes().map(node => {
const { selected, ...cleanNode } = node;
return cleanNode;
});
// Create clean copies
return {
nodes: JSON.parse(JSON.stringify(cleanNodes)),
edges: JSON.parse(JSON.stringify(getEdges())),
timestamp: Date.now(),
};
}, [getNodes, getEdges]);
// Check if two snapshots are meaningfully different (ignoring UI-only changes)
const snapshotsAreDifferent = useCallback((snapshot1: FlowSnapshot, snapshot2: FlowSnapshot): boolean => {
// Compare serialized versions to check for meaningful differences
const nodes1Str = JSON.stringify(snapshot1.nodes);
const nodes2Str = JSON.stringify(snapshot2.nodes);
const edges1Str = JSON.stringify(snapshot1.edges);
const edges2Str = JSON.stringify(snapshot2.edges);
return nodes1Str !== nodes2Str || edges1Str !== edges2Str;
}, []);
// Take a snapshot and add it to history
const takeSnapshot = useCallback(() => {
// Don't take snapshots during undo/redo operations
if (isUndoRedoAction.current) {
return;
}
const snapshot = createSnapshot();
const currentHistory = getCurrentFlowHistory();
const currentIndex = getCurrentHistoryIndex();
// Don't add duplicate snapshots (when only UI-only properties changed)
if (currentHistory.length > 0) {
const lastSnapshot = currentHistory[currentIndex];
if (lastSnapshot && !snapshotsAreDifferent(snapshot, lastSnapshot)) {
return; // Skip duplicate snapshot
}
}
const newHistory = [...currentHistory];
// If we're not at the end of history, remove future snapshots
if (currentIndex < newHistory.length - 1) {
newHistory.splice(currentIndex + 1);
}
// Add new snapshot
newHistory.push(snapshot);
// Update the flow's history
const flowKey = getFlowKey(flowId ?? null);
// Limit history size
if (newHistory.length > maxHistorySize) {
newHistory.shift();
setCurrentHistoryIndex(maxHistorySize - 1);
} else {
setCurrentHistoryIndex(currentIndex + 1);
}
histories.current[flowKey] = newHistory;
}, [createSnapshot, getCurrentFlowHistory, getCurrentHistoryIndex, maxHistorySize, snapshotsAreDifferent, getFlowKey, flowId, setCurrentHistoryIndex]);
// Restore a snapshot
const restoreSnapshot = useCallback((snapshot: FlowSnapshot) => {
isUndoRedoAction.current = true;
setNodes(snapshot.nodes);
setEdges(snapshot.edges);
// Reset flag after React has processed the state updates
setTimeout(() => {
isUndoRedoAction.current = false;
}, 0);
}, [setNodes, setEdges]);
// Undo last action
const undo = useCallback(() => {
const currentIndex = getCurrentHistoryIndex();
const currentHistory = getCurrentFlowHistory();
if (currentIndex > 0) {
const prevSnapshot = currentHistory[currentIndex - 1];
restoreSnapshot(prevSnapshot);
setCurrentHistoryIndex(currentIndex - 1);
}
}, [getCurrentHistoryIndex, getCurrentFlowHistory, restoreSnapshot, setCurrentHistoryIndex]);
// Redo next action
const redo = useCallback(() => {
const currentIndex = getCurrentHistoryIndex();
const currentHistory = getCurrentFlowHistory();
if (currentIndex < currentHistory.length - 1) {
const nextSnapshot = currentHistory[currentIndex + 1];
restoreSnapshot(nextSnapshot);
setCurrentHistoryIndex(currentIndex + 1);
}
}, [getCurrentHistoryIndex, getCurrentFlowHistory, restoreSnapshot, setCurrentHistoryIndex]);
// Check if undo is available
const canUndo = getCurrentHistoryIndex() > 0;
// Check if redo is available
const canRedo = getCurrentHistoryIndex() < getCurrentFlowHistory().length - 1;
// Clear history for current flow
const clearHistory = useCallback(() => {
const flowKey = getFlowKey(flowId ?? null);
histories.current[flowKey] = [];
setCurrentHistoryIndex(-1);
}, [getFlowKey, flowId, setCurrentHistoryIndex]);
return {
takeSnapshot,
undo,
redo,
canUndo,
canRedo,
clearHistory,
};
}
================================================
FILE: app/frontend/src/hooks/use-flow-management-tabs.ts
================================================
import { useFlowContext } from '@/contexts/flow-context';
import { useNodeContext } from '@/contexts/node-context';
import { useTabsContext } from '@/contexts/tabs-context';
import {
clearFlowNodeStates,
getNodeInternalState,
setNodeInternalState
} from '@/hooks/use-node-state';
import { useToastManager } from '@/hooks/use-toast-manager';
import { flowService } from '@/services/flow-service';
import { TabService } from '@/services/tab-service';
import { Flow } from '@/types/flow';
import { useCallback, useEffect, useState } from 'react';
export interface UseFlowManagementTabsReturn {
// State
flows: Flow[];
searchQuery: string;
isLoading: boolean;
openGroups: string[];
createDialogOpen: boolean;
// Computed values
filteredFlows: Flow[];
recentFlows: Flow[];
templateFlows: Flow[];
// Actions
setSearchQuery: (query: string) => void;
setOpenGroups: (groups: string[]) => void;
setCreateDialogOpen: (open: boolean) => void;
handleAccordionChange: (value: string[]) => void;
handleCreateNewFlow: () => void;
handleFlowCreated: (newFlow: Flow) => Promise;
handleSaveCurrentFlow: () => Promise;
handleOpenFlowInTab: (flow: Flow) => Promise;
handleDeleteFlow: (flow: Flow) => Promise;
handleRefresh: () => Promise;
// Internal functions (for testing/advanced use)
loadFlows: () => Promise;
createDefaultFlow: () => Promise;
}
export function useFlowManagementTabs(): UseFlowManagementTabsReturn {
// Get flow context, node context, tabs context, and toast manager
const { saveCurrentFlow, reactFlowInstance, currentFlowId } = useFlowContext();
const { exportNodeContextData } = useNodeContext();
const { openTab, isTabOpen, closeTab } = useTabsContext();
const { success, error } = useToastManager();
// State for flows
const [flows, setFlows] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [openGroups, setOpenGroups] = useState(['recent-flows']);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Enhanced save function that includes internal node states AND node context data
const saveCurrentFlowWithStates = useCallback(async (): Promise => {
try {
// Get current nodes from React Flow
const currentNodes = reactFlowInstance.getNodes();
// Get node context data (runtime data: agent status, messages, output data)
const flowId = currentFlowId?.toString() || null;
const nodeContextData = exportNodeContextData(flowId);
// Enhance nodes with internal states
const nodesWithStates = currentNodes.map((node: any) => {
const internalState = getNodeInternalState(node.id);
return {
...node,
data: {
...node.data,
// Only add internal_state if there is actually state to save
...(internalState && Object.keys(internalState).length > 0 ? { internal_state: internalState } : {})
}
};
});
// Temporarily replace nodes in React Flow with enhanced nodes
reactFlowInstance.setNodes(nodesWithStates);
try {
// Use the context's save function which handles currentFlowId properly
const savedFlow = await saveCurrentFlow();
if (savedFlow) {
// After basic save, update with node context data
const updatedFlow = await flowService.updateFlow(savedFlow.id, {
...savedFlow,
data: {
...savedFlow.data,
nodeContextData, // Add runtime data from node context
}
});
return updatedFlow;
}
return savedFlow;
} finally {
// Restore original nodes (without internal_state in React Flow)
reactFlowInstance.setNodes(currentNodes);
}
} catch (err) {
console.error('Failed to save flow with states:', err);
return null;
}
}, [reactFlowInstance, saveCurrentFlow, exportNodeContextData, currentFlowId]);
// Create default flow for new users
const createDefaultFlow = useCallback(async () => {
try {
// Get current React Flow state, fallback to empty arrays if nothing exists
const nodes = reactFlowInstance?.getNodes() || [];
const edges = reactFlowInstance?.getEdges() || [];
const viewport = reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 };
const defaultFlow = await flowService.createDefaultFlow(nodes, edges, viewport);
setFlows([defaultFlow]);
// Open the default flow in a tab
const tabData = TabService.createFlowTab(defaultFlow);
openTab(tabData);
} catch (error) {
console.error('Failed to create default flow:', error);
}
}, [reactFlowInstance, openTab]);
// Load flows from API
const loadFlows = useCallback(async () => {
setIsLoading(true);
try {
const flowsData = await flowService.getFlows();
setFlows(flowsData);
// Don't automatically create or open tabs on startup
// Let users explicitly open tabs by clicking on flows
// Tabs will be restored from localStorage if they exist
} catch (error) {
console.error('Error loading flows:', error);
} finally {
setIsLoading(false);
}
}, []);
// Load flows on mount
useEffect(() => {
loadFlows();
}, [loadFlows]);
// Filter flows based on search query
const filteredFlows = flows.filter(flow =>
flow.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
flow.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
flow.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
// Sort flows by updated_at descending, then group them
const sortedFlows = [...filteredFlows].sort((a, b) => {
const dateA = new Date(a.updated_at || a.created_at);
const dateB = new Date(b.updated_at || b.created_at);
return dateB.getTime() - dateA.getTime();
});
// Group flows
const recentFlows = sortedFlows.filter(f => !f.is_template).slice(0, 10);
const templateFlows = sortedFlows.filter(f => f.is_template);
// Event handlers
const handleAccordionChange = useCallback((value: string[]) => {
setOpenGroups(value);
}, []);
const handleCreateNewFlow = useCallback(() => {
setCreateDialogOpen(true);
}, []);
const handleFlowCreated = useCallback(async (newFlow: Flow) => {
// Open the new flow in a tab
const tabData = TabService.createFlowTab(newFlow);
openTab(tabData);
// Remember it
localStorage.setItem('lastSelectedFlowId', newFlow.id.toString());
// Refresh the flows list to show the new flow
await loadFlows();
}, [openTab, loadFlows]);
const handleSaveCurrentFlow = useCallback(async () => {
try {
const savedFlow = await saveCurrentFlowWithStates();
if (savedFlow) {
// Remember the saved flow
localStorage.setItem('lastSelectedFlowId', savedFlow.id.toString());
// Refresh the flows list
await loadFlows();
success(`"${savedFlow.name}" saved!`, 'flow-save');
} else {
error('Failed to save flow', 'flow-save-error');
}
} catch (err) {
console.error('Failed to save flow:', err);
error('Failed to save flow', 'flow-save-error');
}
}, [saveCurrentFlowWithStates, loadFlows, success, error]);
const handleOpenFlowInTab = useCallback(async (flow: Flow) => {
try {
// Always fetch the full flow data including nodes, edges, and viewport
// This ensures we have the latest data from the backend
const fullFlow = await flowService.getFlow(flow.id);
// Create tab data with configuration restoration only
const createTabWithConfigRestore = (flowData: Flow) => {
const tabData = TabService.createFlowTab(flowData);
// Enhance the tab content to restore only configuration data when the tab is activated
return {
...tabData,
onActivate: () => {
// NOTE: We intentionally do NOT restore nodeContextData here
// Runtime execution data (messages, analysis, agent status) should start fresh
// Restore internal states for each node (use-node-state data - configuration only)
if (flowData.nodes) {
flowData.nodes.forEach((node: any) => {
if (node.data?.internal_state) {
setNodeInternalState(node.id, node.data.internal_state);
}
});
}
}
};
};
// Check if tab is already open
if (isTabOpen(flow.id.toString(), 'flow')) {
// Tab exists - update it with fresh data and focus it
const tabId = `flow-${flow.id}`;
const enhancedTabData = createTabWithConfigRestore(fullFlow);
// Update the existing tab with fresh data
openTab({
id: tabId,
type: enhancedTabData.type,
title: enhancedTabData.title,
content: enhancedTabData.content,
flow: enhancedTabData.flow,
metadata: enhancedTabData.metadata,
});
// Trigger the enhanced restoration
if (enhancedTabData.onActivate) {
enhancedTabData.onActivate();
}
} else {
// Create new tab with fresh data
const enhancedTabData = createTabWithConfigRestore(fullFlow);
openTab(enhancedTabData);
// Trigger the enhanced restoration for new tab
if (enhancedTabData.onActivate) {
enhancedTabData.onActivate();
}
}
// Remember the selected flow
localStorage.setItem('lastSelectedFlowId', fullFlow.id.toString());
} catch (err) {
console.error('Failed to open flow in tab:', err);
error('Failed to load flow data');
}
}, [isTabOpen, openTab, error]);
const handleRefresh = useCallback(async () => {
await loadFlows();
}, [loadFlows]);
const handleDeleteFlow = useCallback(async (flow: Flow) => {
try {
await flowService.deleteFlow(flow.id);
// Close the tab if it's open
const tabId = `flow-${flow.id}`;
closeTab(tabId);
// Clear node states for the deleted flow
clearFlowNodeStates(flow.id.toString());
// Remove from localStorage if it was the last selected
const lastSelectedFlowId = localStorage.getItem('lastSelectedFlowId');
if (lastSelectedFlowId === flow.id.toString()) {
localStorage.removeItem('lastSelectedFlowId');
}
// Refresh the flows list
await loadFlows();
} catch (error) {
console.error('Failed to delete flow:', error);
}
}, [loadFlows, closeTab]);
return {
// State
flows,
searchQuery,
isLoading,
openGroups,
createDialogOpen,
// Computed values
filteredFlows,
recentFlows,
templateFlows,
// Actions
setSearchQuery,
setOpenGroups,
setCreateDialogOpen,
handleAccordionChange,
handleCreateNewFlow,
handleFlowCreated,
handleSaveCurrentFlow,
handleOpenFlowInTab,
handleDeleteFlow,
handleRefresh,
// Internal functions
loadFlows,
createDefaultFlow,
};
}
================================================
FILE: app/frontend/src/hooks/use-flow-management.ts
================================================
import { useFlowContext } from '@/contexts/flow-context';
import { useNodeContext } from '@/contexts/node-context';
import {
clearFlowNodeStates,
getNodeInternalState,
setNodeInternalState,
setCurrentFlowId as setNodeStateFlowId
} from '@/hooks/use-node-state';
import { useToastManager } from '@/hooks/use-toast-manager';
import { flowService } from '@/services/flow-service';
import { Flow } from '@/types/flow';
import { useCallback, useEffect, useState } from 'react';
export interface UseFlowManagementReturn {
// State
flows: Flow[];
searchQuery: string;
isLoading: boolean;
openGroups: string[];
createDialogOpen: boolean;
// Computed values
filteredFlows: Flow[];
recentFlows: Flow[];
templateFlows: Flow[];
// Actions
setSearchQuery: (query: string) => void;
setOpenGroups: (groups: string[]) => void;
setCreateDialogOpen: (open: boolean) => void;
handleAccordionChange: (value: string[]) => void;
handleCreateNewFlow: () => void;
handleFlowCreated: (newFlow: Flow) => Promise;
handleSaveCurrentFlow: () => Promise;
handleLoadFlow: (flow: Flow) => Promise;
handleDeleteFlow: (flow: Flow) => Promise;
handleRefresh: () => Promise;
// Internal functions (for testing/advanced use)
loadFlows: () => Promise;
createDefaultFlow: () => Promise;
}
export function useFlowManagement(): UseFlowManagementReturn {
// Get flow context, node context, and toast manager
const { saveCurrentFlow, loadFlow, reactFlowInstance, currentFlowId } = useFlowContext();
const { exportNodeContextData } = useNodeContext();
const { success, error } = useToastManager();
// State for flows
const [flows, setFlows] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [openGroups, setOpenGroups] = useState(['recent-flows']);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Enhanced save function that includes internal node states AND node context data
const saveCurrentFlowWithStates = useCallback(async (): Promise => {
try {
// Get current nodes from React Flow
const currentNodes = reactFlowInstance.getNodes();
// Get node context data (runtime data: agent status, messages, output data)
const flowId = currentFlowId?.toString() || null;
const nodeContextData = exportNodeContextData(flowId);
// Enhance nodes with internal states
const nodesWithStates = currentNodes.map((node: any) => {
const internalState = getNodeInternalState(node.id);
return {
...node,
data: {
...node.data,
// Only add internal_state if there is actually state to save
...(internalState && Object.keys(internalState).length > 0 ? { internal_state: internalState } : {})
}
};
});
// Temporarily replace nodes in React Flow with enhanced nodes
reactFlowInstance.setNodes(nodesWithStates);
try {
// Use the context's save function which handles currentFlowId properly
const savedFlow = await saveCurrentFlow();
if (savedFlow) {
// After basic save, update with node context data
const updatedFlow = await flowService.updateFlow(savedFlow.id, {
...savedFlow,
data: {
...savedFlow.data,
nodeContextData, // Add runtime data from node context
}
});
return updatedFlow;
}
return savedFlow;
} finally {
// Restore original nodes (without internal_state in React Flow)
reactFlowInstance.setNodes(currentNodes);
}
} catch (err) {
console.error('Failed to save flow with states:', err);
return null;
}
}, [reactFlowInstance, saveCurrentFlow, exportNodeContextData, currentFlowId]);
// Enhanced load function that restores internal node states AND node context data
const loadFlowWithStates = useCallback(async (flow: Flow) => {
try {
// First, set the flow ID for node state isolation
setNodeStateFlowId(flow.id.toString());
// DO NOT clear configuration state when loading flows - useNodeState handles flow isolation automatically
// DO NOT reset runtime data when loading flows - preserve all runtime state
// Runtime data should only be reset when explicitly starting a new run via the Play button
console.log(`[FlowManagement] Loading flow ${flow.id} (${flow.name}), preserving all state (configuration + runtime)`);
// Load the flow using the context (this handles currentFlowId, currentFlowName, etc.)
await loadFlow(flow);
// Then restore internal states for each node (use-node-state data)
if (flow.nodes) {
flow.nodes.forEach((node: any) => {
if (node.data?.internal_state) {
setNodeInternalState(node.id, node.data.internal_state);
}
});
}
// NOTE: We intentionally do NOT restore nodeContextData here
// Runtime execution data (messages, analysis, agent status) should start fresh
// Only configuration data (tickers, model selections) is restored above
console.log('Flow loaded with complete state restoration:', flow.name);
} catch (error) {
console.error('Failed to load flow with states:', error);
throw error; // Re-throw to handle in calling function
}
}, [loadFlow]);
// Create default flow for new users
const createDefaultFlow = useCallback(async () => {
try {
console.log('Creating default flow for new user...');
// Get current React Flow state, fallback to empty arrays if nothing exists
const nodes = reactFlowInstance?.getNodes() || [];
const edges = reactFlowInstance?.getEdges() || [];
const viewport = reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 };
const defaultFlow = await flowService.createDefaultFlow(nodes, edges, viewport);
console.log('Default flow created:', defaultFlow);
setFlows([defaultFlow]);
// Set the flow ID for node state isolation before loading
setNodeStateFlowId(defaultFlow.id.toString());
await loadFlowWithStates(defaultFlow);
console.log('Default flow loaded successfully');
} catch (error) {
console.error('Failed to create default flow:', error);
}
}, [reactFlowInstance, loadFlowWithStates]);
// Load flows from API
const loadFlows = useCallback(async () => {
setIsLoading(true);
try {
console.log('Loading flows from API...');
const flowsData = await flowService.getFlows();
console.log('Loaded flows:', flowsData);
setFlows(flowsData);
if (flowsData.length === 0) {
// Create default flow if user has no flows
console.log('No flows found, creating default flow...');
await createDefaultFlow();
} else {
// Try to restore the last selected flow from localStorage
const lastSelectedFlowId = localStorage.getItem('lastSelectedFlowId');
let flowToLoad = null;
if (lastSelectedFlowId) {
// Try to find the last selected flow
flowToLoad = flowsData.find(flow => flow.id === parseInt(lastSelectedFlowId));
if (flowToLoad) {
console.log('Restoring last selected flow:', flowToLoad.name);
}
}
// If no last selected flow or it doesn't exist anymore, use the most recent
if (!flowToLoad) {
flowToLoad = flowsData.reduce((latest, current) => {
const latestDate = new Date(latest.updated_at || latest.created_at);
const currentDate = new Date(current.updated_at || current.created_at);
return currentDate > latestDate ? current : latest;
});
console.log('Loading most recent flow:', flowToLoad.name);
}
// Fetch the full flow data before loading
const fullFlow = await flowService.getFlow(flowToLoad.id);
await loadFlowWithStates(fullFlow);
}
} catch (error) {
console.error('Error loading flows:', error);
} finally {
setIsLoading(false);
}
}, [createDefaultFlow, loadFlowWithStates]);
// Load flows on mount
useEffect(() => {
loadFlows();
}, [loadFlows]);
// Filter flows based on search query
const filteredFlows = flows.filter(flow =>
flow.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
flow.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
flow.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
// Sort flows by updated_at descending, then group them
const sortedFlows = [...filteredFlows].sort((a, b) => {
const dateA = new Date(a.updated_at || a.created_at);
const dateB = new Date(b.updated_at || b.created_at);
return dateB.getTime() - dateA.getTime();
});
// Group flows
const recentFlows = sortedFlows.filter(f => !f.is_template).slice(0, 10);
const templateFlows = sortedFlows.filter(f => f.is_template);
// Event handlers
const handleAccordionChange = useCallback((value: string[]) => {
setOpenGroups(value);
}, []);
const handleCreateNewFlow = useCallback(() => {
setCreateDialogOpen(true);
}, []);
const handleFlowCreated = useCallback(async (newFlow: Flow) => {
// Load the new flow and remember it
await loadFlowWithStates(newFlow);
localStorage.setItem('lastSelectedFlowId', newFlow.id.toString());
// Refresh the flows list to show the new flow
await loadFlows();
}, [loadFlowWithStates, loadFlows]);
const handleSaveCurrentFlow = useCallback(async () => {
try {
const savedFlow = await saveCurrentFlowWithStates();
if (savedFlow) {
// Remember the saved flow
localStorage.setItem('lastSelectedFlowId', savedFlow.id.toString());
// Refresh the flows list
await loadFlows();
success(`"${savedFlow.name}" saved!`, 'flow-save');
} else {
error('Failed to save flow', 'flow-save-error');
}
} catch (err) {
console.error('Failed to save flow:', err);
error('Failed to save flow', 'flow-save-error');
}
}, [saveCurrentFlowWithStates, loadFlows, success, error]);
const handleLoadFlow = useCallback(async (flow: Flow) => {
try {
// Fetch the full flow data including nodes, edges, and viewport
const fullFlow = await flowService.getFlow(flow.id);
await loadFlowWithStates(fullFlow);
// Remember the selected flow
localStorage.setItem('lastSelectedFlowId', flow.id.toString());
console.log('Flow loaded:', fullFlow.name);
} catch (error) {
console.error('Failed to load flow:', error);
}
}, [loadFlowWithStates]);
const handleRefresh = useCallback(async () => {
await loadFlows();
}, [loadFlows]);
const handleDeleteFlow = useCallback(async (flow: Flow) => {
try {
await flowService.deleteFlow(flow.id);
// Clear node states for the deleted flow
clearFlowNodeStates(flow.id.toString());
// Remove from localStorage if it was the last selected
const lastSelectedFlowId = localStorage.getItem('lastSelectedFlowId');
if (lastSelectedFlowId === flow.id.toString()) {
localStorage.removeItem('lastSelectedFlowId');
}
// Refresh the flows list
await loadFlows();
} catch (error) {
console.error('Failed to delete flow:', error);
}
}, [loadFlows]);
return {
// State
flows,
searchQuery,
isLoading,
openGroups,
createDialogOpen,
// Computed values
filteredFlows,
recentFlows,
templateFlows,
// Actions
setSearchQuery,
setOpenGroups,
setCreateDialogOpen,
handleAccordionChange,
handleCreateNewFlow,
handleFlowCreated,
handleSaveCurrentFlow,
handleLoadFlow,
handleDeleteFlow,
handleRefresh,
// Internal functions
loadFlows,
createDefaultFlow,
};
}
================================================
FILE: app/frontend/src/hooks/use-keyboard-shortcuts.ts
================================================
import { useEffect } from 'react';
interface KeyboardShortcut {
key: string;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
callback: () => void;
preventDefault?: boolean;
}
interface UseKeyboardShortcutsProps {
shortcuts: KeyboardShortcut[];
}
export function useKeyboardShortcuts({ shortcuts }: UseKeyboardShortcutsProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
shortcuts.forEach(({ key, ctrlKey, metaKey, shiftKey, altKey, callback, preventDefault = true }) => {
const isCtrlMatch = ctrlKey ? event.ctrlKey : !event.ctrlKey;
const isMetaMatch = metaKey ? event.metaKey : !event.metaKey;
const isShiftMatch = shiftKey ? event.shiftKey : !event.shiftKey;
const isAltMatch = altKey ? event.altKey : !event.altKey;
const isKeyMatch = event.key.toLowerCase() === key.toLowerCase();
// For save shortcut, we want either Ctrl+S OR Cmd+S
const isSaveShortcut = key.toLowerCase() === 's' && (ctrlKey || metaKey);
const matchesSaveShortcut = isSaveShortcut && (event.ctrlKey || event.metaKey) && isKeyMatch;
// For shortcuts that should work with either Ctrl or Cmd
const isModifierShortcut = (ctrlKey || metaKey) && (event.ctrlKey || event.metaKey);
const matchesModifierShortcut = isModifierShortcut && isKeyMatch && isShiftMatch && isAltMatch;
if (matchesSaveShortcut || matchesModifierShortcut || (isKeyMatch && isCtrlMatch && isMetaMatch && isShiftMatch && isAltMatch)) {
if (preventDefault) {
event.preventDefault();
}
callback();
}
});
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [shortcuts]);
}
// Convenience hook specifically for common shortcuts
export function useFlowKeyboardShortcuts(saveFlow: (showToast?: boolean) => void) {
const shortcuts: KeyboardShortcut[] = [
{
key: 's',
ctrlKey: true, // This will match either Ctrl+S or Cmd+S due to our logic above
metaKey: true,
callback: () => saveFlow(true),
preventDefault: true,
},
];
useKeyboardShortcuts({ shortcuts });
}
// Convenience hook for layout keyboard shortcuts
export function useLayoutKeyboardShortcuts(
toggleRightSidebar: () => void,
toggleLeftSidebar?: () => void,
fitView?: () => void,
undo?: () => void,
redo?: () => void,
toggleBottomPanel?: () => void,
openSettings?: () => void
) {
const shortcuts: KeyboardShortcut[] = [
{
key: 'i',
ctrlKey: true, // This will match either Ctrl+I or Cmd+I due to our logic above
metaKey: true,
callback: toggleRightSidebar,
preventDefault: true,
},
];
// Add left sidebar toggle if provided
if (toggleLeftSidebar) {
shortcuts.push({
key: 'b',
ctrlKey: true, // This will match either Ctrl+B or Cmd+B
metaKey: true,
callback: toggleLeftSidebar,
preventDefault: true,
});
}
// Add fit view shortcut if provided
if (fitView) {
shortcuts.push({
key: '0',
ctrlKey: true, // This will match either Ctrl+O or Cmd+O
metaKey: true,
callback: fitView,
preventDefault: true,
});
}
// Add undo shortcut if provided
if (undo) {
shortcuts.push({
key: 'z',
ctrlKey: true, // This will match either Ctrl+Z or Cmd+Z
metaKey: true,
callback: undo,
preventDefault: true,
});
}
// Add redo shortcut if provided
if (redo) {
shortcuts.push({
key: 'z',
ctrlKey: true, // This will match either Ctrl+Shift+Z or Cmd+Shift+Z
metaKey: true,
shiftKey: true,
callback: redo,
preventDefault: true,
});
}
// Add bottom panel toggle shortcut if provided
if (toggleBottomPanel) {
shortcuts.push({
key: 'j',
ctrlKey: true, // This will match either Ctrl+J or Cmd+J (like VSCode)
metaKey: true,
callback: toggleBottomPanel,
preventDefault: true,
});
}
// Add settings shortcut if provided
if (openSettings) {
shortcuts.push({
key: 'j',
ctrlKey: true, // This will match either Ctrl+Shift+J or Cmd+Shift+J
metaKey: true,
shiftKey: true,
callback: openSettings,
preventDefault: true,
});
// Add settings shortcut if provided
shortcuts.push({
key: ',',
ctrlKey: true, // This will match either Ctrl+, or Cmd+,
metaKey: true,
callback: openSettings,
preventDefault: true,
});
}
useKeyboardShortcuts({ shortcuts });
}
================================================
FILE: app/frontend/src/hooks/use-mobile.tsx
================================================
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
================================================
FILE: app/frontend/src/hooks/use-node-state.ts
================================================
import { useCallback, useEffect, useState } from 'react';
// =============================================================================
// FLOW STATE MANAGER - Handles global state and flow isolation
// =============================================================================
class FlowStateManager {
private nodeStatesMap = new Map>();
private stateChangeListeners = new Set<() => void>();
private flowIdChangeListeners = new Set<() => void>();
private currentFlowId: string | null = null;
// Flow ID Management
setCurrentFlowId(flowId: string | null): void {
const oldFlowId = this.currentFlowId;
this.currentFlowId = flowId;
if (oldFlowId !== flowId) {
this.notifyFlowIdChange();
}
}
getCurrentFlowId(): string | null {
return this.currentFlowId;
}
// Key Generation
private createCompositeKey(nodeId: string): string {
return this.currentFlowId ? `${this.currentFlowId}:${nodeId}` : nodeId;
}
// State Access
getNodeState(nodeId: string, stateKey: string): any {
const compositeKey = this.createCompositeKey(nodeId);
const nodeState = this.nodeStatesMap.get(compositeKey);
return nodeState?.[stateKey];
}
setNodeState(nodeId: string, stateKey: string, value: any): void {
const compositeKey = this.createCompositeKey(nodeId);
if (!this.nodeStatesMap.has(compositeKey)) {
this.nodeStatesMap.set(compositeKey, {});
}
this.nodeStatesMap.get(compositeKey)![stateKey] = value;
this.notifyStateChange();
}
// Node Management
getNodeInternalState(nodeId: string): Record | undefined {
const compositeKey = this.createCompositeKey(nodeId);
return this.nodeStatesMap.get(compositeKey);
}
setNodeInternalState(nodeId: string, state: Record): void {
const compositeKey = this.createCompositeKey(nodeId);
this.nodeStatesMap.set(compositeKey, { ...state });
this.notifyStateChange();
}
clearNodeInternalState(nodeId: string): void {
const compositeKey = this.createCompositeKey(nodeId);
this.nodeStatesMap.delete(compositeKey);
this.notifyStateChange();
}
// Flow Management
getAllNodeStates(): Map> {
if (!this.currentFlowId) {
// Backward compatibility - return all states
return new Map(this.nodeStatesMap);
}
// Filter states for current flow and strip flow prefix
const currentFlowStates = new Map>();
const flowPrefix = `${this.currentFlowId}:`;
for (const [compositeKey, state] of this.nodeStatesMap.entries()) {
if (compositeKey.startsWith(flowPrefix)) {
const nodeId = compositeKey.substring(flowPrefix.length);
currentFlowStates.set(nodeId, state);
}
}
return currentFlowStates;
}
clearAllNodeStates(): void {
if (!this.currentFlowId) {
// Backward compatibility - clear all states
this.nodeStatesMap.clear();
} else {
// Clear only current flow's states
const flowPrefix = `${this.currentFlowId}:`;
const keysToDelete = Array.from(this.nodeStatesMap.keys())
.filter(key => key.startsWith(flowPrefix));
keysToDelete.forEach(key => this.nodeStatesMap.delete(key));
}
this.notifyStateChange();
}
clearFlowNodeStates(flowId: string): void {
const flowPrefix = `${flowId}:`;
const keysToDelete = Array.from(this.nodeStatesMap.keys())
.filter(key => key.startsWith(flowPrefix));
keysToDelete.forEach(key => this.nodeStatesMap.delete(key));
this.notifyStateChange();
}
// Listener Management
addStateChangeListener(listener: () => void): () => void {
this.stateChangeListeners.add(listener);
return () => this.stateChangeListeners.delete(listener);
}
addFlowIdChangeListener(listener: () => void): () => void {
this.flowIdChangeListeners.add(listener);
return () => this.flowIdChangeListeners.delete(listener);
}
private notifyStateChange(): void {
this.stateChangeListeners.forEach(listener => listener());
}
private notifyFlowIdChange(): void {
this.flowIdChangeListeners.forEach(listener => listener());
}
}
// Global instance
const flowStateManager = new FlowStateManager();
// =============================================================================
// PUBLIC API - Clean interface for external use
// =============================================================================
export interface UseNodeStateReturn {
0: T;
1: (value: T | ((prev: T) => T)) => void;
}
// Flow Management
export function setCurrentFlowId(flowId: string | null): void {
flowStateManager.setCurrentFlowId(flowId);
}
// Node State Management
export function getNodeInternalState(nodeId: string): Record | undefined {
return flowStateManager.getNodeInternalState(nodeId);
}
export function setNodeInternalState(nodeId: string, state: Record): void {
flowStateManager.setNodeInternalState(nodeId, state);
}
export function clearNodeInternalState(nodeId: string): void {
flowStateManager.clearNodeInternalState(nodeId);
}
// Flow State Management
export function getAllNodeStates(): Map> {
return flowStateManager.getAllNodeStates();
}
export function clearAllNodeStates(): void {
flowStateManager.clearAllNodeStates();
}
export function clearFlowNodeStates(flowId: string): void {
flowStateManager.clearFlowNodeStates(flowId);
}
export function addStateChangeListener(listener: () => void): () => void {
return flowStateManager.addStateChangeListener(listener);
}
// =============================================================================
// REACT HOOKS - Focused on React integration
// =============================================================================
/**
* Drop-in replacement for useState that automatically persists state across
* flow saves/loads and provides flow isolation.
*
* @param nodeId - The ID of the node (from NodeProps)
* @param stateKey - Unique key for this state value within the node
* @param defaultValue - Default value for the state
* @returns [value, setValue] tuple like useState
*/
export function useNodeState(
nodeId: string,
stateKey: string,
defaultValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// Initialize with stored value or default
const getStoredValue = useCallback((): T => {
const storedValue = flowStateManager.getNodeState(nodeId, stateKey);
return storedValue !== undefined ? storedValue : defaultValue;
}, [nodeId, stateKey, defaultValue]);
const [value, setValue] = useState(getStoredValue);
const [, forceUpdate] = useState({});
// Handle flow changes - reset to stored value for new flow
useEffect(() => {
const unsubscribe = flowStateManager.addFlowIdChangeListener(() => {
// Use setTimeout to defer the state update to avoid updating during render
setTimeout(() => {
const newValue = getStoredValue();
setValue(newValue);
forceUpdate({}); // Force re-render
}, 0);
});
return unsubscribe;
}, [getStoredValue]);
// Handle external state changes - update if this specific state changed
useEffect(() => {
const unsubscribe = flowStateManager.addStateChangeListener(() => {
const storedValue = flowStateManager.getNodeState(nodeId, stateKey);
if (storedValue !== undefined) {
// Use setTimeout to defer the state update to avoid updating during render
setTimeout(() => {
setValue(prevValue => {
if (prevValue !== storedValue) {
console.debug(`[useNodeState] Updated ${nodeId}.${stateKey}:`, storedValue);
return storedValue;
}
return prevValue;
});
}, 0);
}
});
return unsubscribe;
}, [nodeId, stateKey]);
// Persist value when it changes
const setValueAndPersist = useCallback((newValue: T | ((prev: T) => T)) => {
setValue(prevValue => {
const finalValue = typeof newValue === 'function'
? (newValue as (prev: T) => T)(prevValue)
: newValue;
flowStateManager.setNodeState(nodeId, stateKey, finalValue);
return finalValue;
});
}, [nodeId, stateKey]);
// Initialize stored state on mount or flow change
useEffect(() => {
const storedValue = flowStateManager.getNodeState(nodeId, stateKey);
if (storedValue === undefined) {
// Use setTimeout to defer the state update to avoid updating during render
setTimeout(() => {
flowStateManager.setNodeState(nodeId, stateKey, value);
}, 0);
}
}, [nodeId, stateKey, value]);
return [value, setValueAndPersist];
}
================================================
FILE: app/frontend/src/hooks/use-output-node-connection.ts
================================================
import { getConnectedEdges, useReactFlow } from '@xyflow/react';
import { useMemo } from 'react';
import { useFlowContext } from '@/contexts/flow-context';
import { useNodeContext } from '@/contexts/node-context';
/**
* Custom hook to determine output node connection state and processing status
* @param nodeId - The ID of the output node
* @returns Object containing connection state and processing status
*/
export function useOutputNodeConnection(nodeId: string) {
const { currentFlowId } = useFlowContext();
const { getAgentNodeDataForFlow, getOutputNodeDataForFlow } = useNodeContext();
const { getNodes, getEdges } = useReactFlow();
// Get data for the current flow
const flowId = currentFlowId?.toString() || null;
const agentNodeData = getAgentNodeDataForFlow(flowId);
const outputNodeData = getOutputNodeDataForFlow(flowId);
return useMemo(() => {
// Get all nodes and edges
const nodes = getNodes();
const edges = getEdges();
// Find edges connected to this output node
const connectedEdges = getConnectedEdges([{ id: nodeId }] as any, edges);
const connectedAgentIds = connectedEdges
.filter(edge => edge.target === nodeId)
.map(edge => edge.source)
.filter(sourceId => {
const sourceNode = nodes.find(n => n.id === sourceId);
return sourceNode?.type === 'agent-node';
});
// Check if any connected agents are running
const isAnyAgentRunning = connectedAgentIds.some(agentId =>
agentNodeData[agentId]?.status === 'IN_PROGRESS'
);
// Check if processing (any agent is running)
const isProcessing = isAnyAgentRunning;
// Check if output is available
const isOutputAvailable = outputNodeData !== null && outputNodeData !== undefined;
// Check if connected to any agents
const isConnected = connectedAgentIds.length > 0;
return {
isProcessing,
isAnyAgentRunning,
isOutputAvailable,
isConnected,
connectedAgentIds: new Set(connectedAgentIds),
};
}, [nodeId, agentNodeData, outputNodeData, getNodes, getEdges]);
}
================================================
FILE: app/frontend/src/hooks/use-resizable.ts
================================================
import { useEffect, useRef, useState } from 'react';
interface UseResizableOptions {
minWidth?: number;
maxWidth?: number;
defaultWidth?: number;
minHeight?: number;
maxHeight?: number;
defaultHeight?: number;
side?: 'left' | 'right' | 'bottom';
}
export function useResizable({
minWidth = 200,
maxWidth = 500,
defaultWidth = 250,
minHeight = 200,
maxHeight = 600,
defaultHeight = 300,
side = 'left'
}: UseResizableOptions = {}) {
const [width, setWidth] = useState(defaultWidth);
const [height, setHeight] = useState(defaultHeight);
const [isDragging, setIsDragging] = useState(false);
const elementRef = useRef(null);
// Add a ref for tracking dragging state - updates synchronously unlike state
const isDraggingRef = useRef(false);
// Handle manual resizing with mouse
const startResize = (e: React.MouseEvent) => {
e.preventDefault();
// Set both the ref (for immediate use in mousemove) and state (for rendering)
isDraggingRef.current = true;
setIsDragging(true);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', stopResize);
};
const handleMouseMove = (e: MouseEvent) => {
// Use the ref value instead of state for checking
if (!isDraggingRef.current) return;
// Get element's position
const elementRect = elementRef.current?.getBoundingClientRect();
if (!elementRect) return;
if (side === 'bottom') {
// For bottom panel: dragging up decreases height
const newHeight = elementRect.bottom - e.clientY;
const clampedHeight = Math.max(minHeight, Math.min(maxHeight, newHeight));
setHeight(clampedHeight);
} else {
// For horizontal resizing (left/right sidebars)
let newWidth;
if (side === 'left') {
// For left sidebar: dragging right increases width
newWidth = e.clientX - elementRect.left;
} else {
// For right sidebar: dragging left decreases width
newWidth = elementRect.right - e.clientX;
}
// Calculate new width (limit between minWidth and maxWidth)
newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
setWidth(newWidth);
}
};
const stopResize = () => {
// Update both ref and state
isDraggingRef.current = false;
setIsDragging(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', stopResize);
};
// Clean up event listeners when component unmounts
useEffect(() => {
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', stopResize);
};
}, []);
return {
width,
height,
isDragging,
elementRef,
startResize
};
}
================================================
FILE: app/frontend/src/hooks/use-toast-manager.ts
================================================
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
type ToastType = 'success' | 'error' | 'info' | 'warning';
type ToastPosition = 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
interface ToastOptions {
duration?: number;
position?: ToastPosition;
preventDuplicates?: boolean;
}
interface ToastState {
[key: string]: boolean;
}
/**
* A generalized toast manager hook that handles duplicate prevention and provides
* convenience methods for different toast types.
*
* @example
* ```typescript
* const { success, error, info, warning } = useToastManager();
*
* // Basic usage
* success('Data saved!');
* error('Something went wrong');
*
* // With custom ID to prevent duplicates
* success('Flow saved!', 'flow-save');
*
* // With custom options
* info('Processing...', 'process', {
* duration: 5000,
* position: 'top-center'
* });
*
* // Disable duplicate prevention
* warning('Warning!', undefined, { preventDuplicates: false });
* ```
*/
export function useToastManager() {
const [visibleToasts, setVisibleToasts] = useState({});
const showToast = useCallback((
type: ToastType,
message: string,
toastId?: string,
options: ToastOptions = {}
) => {
const {
duration = 2000,
position = 'top-center',
preventDuplicates = true
} = options;
// Use message as ID if no toastId provided
const id = toastId || message;
// Check if we should prevent duplicates
if (preventDuplicates && visibleToasts[id]) {
return;
}
// Mark toast as visible
if (preventDuplicates) {
setVisibleToasts(prev => ({ ...prev, [id]: true }));
}
const onDismiss = () => {
if (preventDuplicates) {
setVisibleToasts(prev => ({ ...prev, [id]: false }));
}
};
const onAutoClose = () => {
if (preventDuplicates) {
setVisibleToasts(prev => ({ ...prev, [id]: false }));
}
};
// Show the appropriate toast type
switch (type) {
case 'success':
toast.success(message, {
duration,
position,
onDismiss,
onAutoClose,
});
break;
case 'error':
toast.error(message, {
duration,
position,
onDismiss,
onAutoClose,
});
break;
case 'info':
toast.info(message, {
duration,
position,
onDismiss,
onAutoClose,
});
break;
case 'warning':
toast.warning(message, {
duration,
position,
onDismiss,
onAutoClose,
});
break;
}
}, [visibleToasts]);
// Convenience methods for specific toast types
const success = useCallback((message: string, toastId?: string, options?: ToastOptions) => {
showToast('success', message, toastId, options);
}, [showToast]);
const error = useCallback((message: string, toastId?: string, options?: ToastOptions) => {
showToast('error', message, toastId, options);
}, [showToast]);
const info = useCallback((message: string, toastId?: string, options?: ToastOptions) => {
showToast('info', message, toastId, options);
}, [showToast]);
const warning = useCallback((message: string, toastId?: string, options?: ToastOptions) => {
showToast('warning', message, toastId, options);
}, [showToast]);
return {
showToast,
success,
error,
info,
warning,
visibleToasts,
};
}
================================================
FILE: app/frontend/src/index.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 94%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 85%;
--node-border: 0 0% 88%;
--node-border-hover: 0 0% 75%;
--node-border-selected: 0 0% 10%;
--status-border: 0 0% 85%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--panel-bg: 0 0% 100%;
--node-bg: 0 0% 100%;
/* Hover colors for light theme */
--hover-background: rgb(156 163 175 / 0.3); /* gray-400/30 */
--hover-foreground: 0 0% 15%;
/* Active/selected colors for light theme */
--active-background: rgb(229 231 235 / 0.8); /* gray-200/80 */
--active-foreground: 0 0% 10%;
/* Ramp Grey Colors */
--ramp-grey-100: #f5f5f5;
--ramp-grey-200: #e6e6e6;
--ramp-grey-300: #d9d9d9;
--ramp-grey-400: #b3b3b3;
--ramp-grey-500: #757575;
--ramp-grey-600: #444444;
--ramp-grey-700: #383838;
--ramp-grey-800: #2c2c2c;
--ramp-grey-900: #1e1e1e;
--ramp-grey-1000: #111111;
/* Tab Colors - Light Theme */
--tab-active-text: #1a1a1a;
--tab-inactive-text: #666666;
--tab-hover-text: #1a1a1a;
--tab-background: #ffffff;
--tab-hover-background: #f0f0f0;
--tab-border: #e0e0e0;
--tab-accent: #007acc;
--tab-icon-active: #007acc;
--tab-icon-inactive: #666666;
--tab-close-hover: #e0e0e0;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 25%;
--node-border: 0 0% 25%;
--node-border-hover: 0 0% 40%;
--node-border-selected: 0 0% 90%;
--status-border: 0 0% 25%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--panel-bg: 240 3% 11%;
--node-bg: 240 3% 11%;
--sidebar-background: 240 5.9% 16%;
/* Hover colors for dark theme */
--hover-background: rgb(55 65 81 / 0.5); /* gray-700/50 */
--hover-foreground: 0 0% 95%;
/* Active/selected colors for dark theme */
--active-background: rgb(55 65 81 / 0.8); /* gray-700/80 */
--active-foreground: 0 0% 98%;
/* Tab Colors - Dark Theme */
--tab-active-text: #cccccc;
--tab-inactive-text: #969696;
--tab-hover-text: #cccccc;
--tab-background: #1e1e1e;
--tab-hover-background: #1e1e1e;
--tab-border: #333333;
--tab-accent: #007acc;
--tab-icon-active: #007acc;
--tab-icon-inactive: #858585;
--tab-close-hover: #464647;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer utilities {
.bg-node {
background-color: hsl(var(--node-bg));
}
.border-status {
border-color: hsl(var(--status-border));
}
.border-node {
border-color: hsl(var(--node-border));
}
.border-node-hover {
border-color: hsl(var(--node-border-hover));
}
.border-node-selected {
border-color: hsl(var(--node-border-selected));
}
.hover-bg {
@apply hover:bg-[var(--hover-background)];
}
.hover-text {
@apply hover:text-[hsl(var(--hover-foreground))];
}
.hover-item {
@apply hover:bg-[var(--hover-background)] hover:text-[hsl(var(--hover-foreground))];
}
.active-bg {
@apply bg-[var(--active-background)];
}
.active-text {
@apply text-[hsl(var(--active-foreground))];
}
.active-item {
@apply bg-[var(--active-background)] text-[hsl(var(--active-foreground))];
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
@font-face {
font-family: "geist";
font-style: normal;
font-weight: 100 900;
src: url(/fonts/geist.woff2) format("woff2");
}
@font-face {
font-family: "geist-mono";
font-style: normal;
font-weight: 100 900;
src: url(/fonts/geist-mono.woff2) format("woff2");
}
}
.skeleton {
* {
pointer-events: none !important;
}
*[class^="text-"] {
color: transparent;
@apply rounded-md bg-foreground/20 select-none animate-pulse;
}
.skeleton-bg {
@apply bg-foreground/10;
}
.skeleton-div {
@apply bg-foreground/20 animate-pulse;
}
}
.ProseMirror {
outline: none;
}
.cm-editor,
.cm-gutters {
@apply bg-background dark:bg-zinc-800 outline-none selection:bg-zinc-900 !important;
}
.ͼo.cm-focused>.cm-scroller>.cm-selectionLayer .cm-selectionBackground,
.ͼo.cm-selectionBackground,
.ͼo.cm-content::selection {
@apply bg-zinc-200 dark:bg-zinc-900 !important;
}
.cm-activeLine,
.cm-activeLineGutter {
@apply bg-transparent !important;
}
.cm-activeLine {
@apply rounded-r-sm !important;
}
.cm-lineNumbers {
@apply min-w-7;
}
.cm-foldGutter {
@apply min-w-3;
}
.cm-lineNumbers .cm-activeLineGutter {
@apply rounded-l-sm !important;
}
.suggestion-highlight {
@apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
}
/* Animated border for in-progress agent nodes */
.node-in-progress {
position: relative;
border: none !important;
}
.animated-border-container {
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border-radius: 0.5rem;
overflow: hidden;
z-index: 0;
pointer-events: none;
}
.animated-border-container::after {
content: "";
position: absolute;
inset: 0;
border-radius: 0.5rem;
background: linear-gradient(90deg,
#2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4
);
background-size: 200% 100%;
animation: gradientFlow 3s linear infinite;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
padding: 3px;
}
@keyframes gradientFlow {
0% {
background-position: 0% 0%;
}
100% {
background-position: 200% 0%;
}
}
/* Gradient animation for in-progress elements */
.gradient-animation {
background: linear-gradient(90deg,
#2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4
);
background-size: 200% 100%;
animation: gradientFlow 3s linear infinite;
}
/* Gradient text animation */
.gradient-text {
background: linear-gradient(90deg,
#2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4
);
background-size: 200% 100%;
animation: gradientFlow 3s linear infinite;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
/* Remove white lines between ReactFlow Controls buttons */
.react-flow__controls .react-flow__controls-button {
border-right: none !important;
}
.react-flow__controls .react-flow__controls-button + .react-flow__controls-button {
border-left: none !important;
}
================================================
FILE: app/frontend/src/lib/utils.ts
================================================
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Platform detection utility
export function isMac(): boolean {
return typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
}
// Keyboard shortcut formatting utility
export function formatKeyboardShortcut(key: string): string {
const modifierKey = isMac() ? '⌘' : 'Ctrl';
return `${modifierKey}${key.toUpperCase()}`;
}
// Provider color utility for consistent styling across components
export function getProviderColor(provider: string): string {
return 'bg-gray-600/20 text-primary border-gray-600/40';
// switch (provider.toLowerCase()) {
// case 'anthropic':
// return 'bg-orange-600/20 text-orange-300 border-orange-600/40';
// case 'google':
// return 'bg-green-600/20 text-green-300 border-green-600/40';
// case 'groq':
// return 'bg-red-600/20 text-red-300 border-red-600/40';
// case 'deepseek':
// return 'bg-blue-600/20 text-blue-300 border-blue-600/40';
// case 'openai':
// return 'bg-gray-900/60 text-gray-200 border-gray-700/60';
// case 'ollama':
// return 'bg-white/90 text-gray-800 border-gray-300';
// default:
// return 'bg-gray-600/20 text-gray-300 border-gray-600/40';
// }
}
================================================
FILE: app/frontend/src/main.tsx
================================================
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { NodeProvider } from './contexts/node-context';
import { ThemeProvider } from './providers/theme-provider';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
);
================================================
FILE: app/frontend/src/nodes/components/agent-node.tsx
================================================
import { type NodeProps } from '@xyflow/react';
import { Bot } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { CardContent } from '@/components/ui/card';
import { ModelSelector } from '@/components/ui/llm-selector';
import { useFlowContext } from '@/contexts/flow-context';
import { useNodeContext } from '@/contexts/node-context';
import { getModels, LanguageModel } from '@/data/models';
import { useNodeState } from '@/hooks/use-node-state';
import { cn } from '@/lib/utils';
import { type AgentNode } from '../types';
import { getStatusColor } from '../utils';
import { AgentOutputDialog } from './agent-output-dialog';
import { NodeShell } from './node-shell';
export function AgentNode({
data,
selected,
id,
isConnectable,
}: NodeProps) {
const { currentFlowId } = useFlowContext();
const { getAgentNodeDataForFlow, setAgentModel, getAgentModel } = useNodeContext();
// Get agent node data for the current flow
const agentNodeData = getAgentNodeDataForFlow(currentFlowId?.toString() || null);
const nodeData = agentNodeData[id] || {
status: 'IDLE',
ticker: null,
message: '',
messages: [],
lastUpdated: 0
};
const status = nodeData.status;
const isInProgress = status === 'IN_PROGRESS';
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Use persistent state hooks
const [availableModels, setAvailableModels] = useNodeState(id, 'availableModels', []);
const [selectedModel, setSelectedModel] = useNodeState(id, 'selectedModel', null);
// Load models on mount
useEffect(() => {
const loadModels = async () => {
try {
const models = await getModels();
setAvailableModels(models);
} catch (error) {
console.error('Failed to load models:', error);
// Keep empty array as fallback
}
};
loadModels();
}, [setAvailableModels]);
// Update the node context when the model changes
useEffect(() => {
const flowId = currentFlowId?.toString() || null;
const currentContextModel = getAgentModel(flowId, id);
if (selectedModel !== currentContextModel) {
setAgentModel(flowId, id, selectedModel);
}
}, [selectedModel, id, currentFlowId, setAgentModel, getAgentModel]);
const handleModelChange = (model: LanguageModel | null) => {
setSelectedModel(model);
};
const handleUseGlobalModel = () => {
setSelectedModel(null);
};
return (
}
iconColor={getStatusColor(status)}
name={data.name || "Agent"}
description={data.description}
status={status}
>
Status
{status.toLowerCase().replace(/_/g, ' ')}
{nodeData.message && (
{nodeData.message !== "Done" && nodeData.message}
{nodeData.ticker && ({nodeData.ticker}) }
)}
Advanced
Model
{selectedModel && (
Reset to Auto
)}
);
}
================================================
FILE: app/frontend/src/nodes/components/agent-output-dialog.tsx
================================================
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { useNodeContext } from '@/contexts/node-context';
import { formatTimeFromTimestamp } from '@/utils/date-utils';
import { formatContent } from '@/utils/text-utils';
import { AlignJustify, Copy, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface AgentOutputDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
name: string;
nodeId: string;
flowId: string | null;
}
export function AgentOutputDialog({
isOpen,
onOpenChange,
name,
nodeId,
flowId
}: AgentOutputDialogProps) {
const { getAgentNodeDataForFlow } = useNodeContext();
// Use the passed flowId instead of getting it from flow context
const agentNodeData = getAgentNodeDataForFlow(flowId);
const nodeData = agentNodeData[nodeId] || {
status: 'IDLE',
ticker: null,
message: '',
messages: [],
lastUpdated: 0
};
const messages = nodeData.messages || [];
const nodeStatus = nodeData.status;
const [copySuccess, setCopySuccess] = useState(false);
const [selectedTicker, setSelectedTicker] = useState(null);
const initialFocusRef = useRef(null);
// Collect all analysis from all messages into a single analysis dictionary
const allAnalysis = messages
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) // Sort by timestamp
.reduce>((acc, msg) => {
// Add analysis from this message to our accumulated analysis
if (msg.analysis && Object.keys(msg.analysis).length > 0) {
// Filter out null values before adding to our accumulated decisions
const validDecisions = Object.entries(msg.analysis)
.filter(([_, value]) => value !== null && value !== undefined)
.reduce((obj, [key, value]) => {
obj[key] = value;
return obj;
}, {} as Record);
if (Object.keys(validDecisions).length > 0) {
// Combine with accumulated decisions, newer messages overwrite older ones for the same ticker
return { ...acc, ...validDecisions };
}
}
return acc;
}, {});
// Get all unique tickers that have decisions
const tickersWithDecisions = Object.keys(allAnalysis);
// Reset selected ticker when node changes
useEffect(() => {
setSelectedTicker(null);
}, [nodeId]);
// If no ticker is selected but we have decisions, select the first one
useEffect(() => {
if (tickersWithDecisions.length > 0 && (!selectedTicker || !tickersWithDecisions.includes(selectedTicker))) {
setSelectedTicker(tickersWithDecisions[0]);
}
}, [tickersWithDecisions, selectedTicker]);
// Get the selected decision text
const selectedDecision = selectedTicker && allAnalysis[selectedTicker] ? allAnalysis[selectedTicker] : null;
const copyToClipboard = () => {
if (selectedDecision) {
navigator.clipboard.writeText(selectedDecision)
.then(() => {
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
})
.catch(err => {
console.error('Failed to copy text: ', err);
});
}
};
return (
e.preventDefault()}
>
{name}
{/* Activity Log Section */}
Log
{messages.length > 0 ? (
{messages
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) // Sort newest first for log
.map((msg, idx) => (
{msg.ticker && [{msg.ticker}] }
{msg.message}
{formatTimeFromTimestamp(msg.timestamp)}
))}
) : (
No activity available
)}
{/* Analysis Section */}
Analysis
{/* Ticker selector */}
{tickersWithDecisions.length > 0 && (
Ticker:
setSelectedTicker(e.target.value)}
autoFocus={false}
>
{tickersWithDecisions.map((ticker) => (
{ticker}
))}
)}
{tickersWithDecisions.length > 0 ? (
{selectedTicker && (
Summary for {selectedTicker}
{selectedDecision && (
{copySuccess ? 'Copied!' : 'Copy'}
)}
)}
{selectedDecision ? (
(() => {
const { isJson, formattedContent } = formatContent(selectedDecision);
if (isJson) {
// Use react-syntax-highlighter for better JSON rendering
return (
{formattedContent as string}
);
} else {
// Display as regular text paragraphs
return (
(formattedContent as string[]).map((paragraph, idx) => (
{paragraph}
))
);
}
})()
) : nodeStatus === 'IN_PROGRESS' ? (
Analysis in progress...
) : (
No analysis available for {selectedTicker}
)}
) : nodeStatus === 'IN_PROGRESS' ? (
Analysis in progress...
) : nodeStatus === 'COMPLETE' ? (
Analysis completed with no results
) : nodeStatus === 'ERROR' ? (
Analysis failed
) : (
No analysis available
)}
);
}
================================================
FILE: app/frontend/src/nodes/components/investment-report-dialog.tsx
================================================
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { extractBaseAgentKey } from '@/data/node-mappings';
import { createAgentDisplayNames } from '@/utils/text-utils';
import { ArrowDown, ArrowUp, Minus } from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface InvestmentReportDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
outputNodeData: any;
connectedAgentIds: Set;
}
type ActionType = 'long' | 'short' | 'hold';
export function InvestmentReportDialog({
isOpen,
onOpenChange,
outputNodeData,
connectedAgentIds,
}: InvestmentReportDialogProps) {
// Check if this is a backtest result and return early if it is
// Backtest results should be displayed in the backtest output tab, not in the investment report dialog
if (outputNodeData?.decisions?.backtest?.type === 'backtest_complete') {
return null;
}
// Return early if no output data
if (!outputNodeData || !outputNodeData.decisions) {
return null;
}
const getActionIcon = (action: ActionType) => {
switch (action) {
case 'long':
return ;
case 'short':
return ;
case 'hold':
return ;
default:
return null;
}
};
const getSignalBadge = (signal: string) => {
const variant = signal === 'bullish' ? 'success' :
signal === 'bearish' ? 'destructive' : 'outline';
return (
{signal}
);
};
const getConfidenceBadge = (confidence: number) => {
let variant = 'outline';
if (confidence >= 50) variant = 'success';
else if (confidence >= 0) variant = 'warning';
else variant = 'outline';
const rounded = Number(confidence.toFixed(1));
return (
{rounded}%
);
};
// Extract unique tickers from the data
const tickers = Object.keys(outputNodeData.decisions || {});
// Use the unique node IDs directly since they're now stored as keys in analyst_signals
const connectedUniqueAgentIds = Array.from(connectedAgentIds);
const agents = Object.keys(outputNodeData.analyst_signals || {})
.filter(agent =>
extractBaseAgentKey(agent) !== 'risk_management_agent' && connectedUniqueAgentIds.includes(agent)
);
const agentDisplayNames = createAgentDisplayNames(agents);
return (
Investment Report
{/* Summary Section */}
Summary
Recommended trading actions based on analyst signals
Ticker
Price
Action
Quantity
Confidence
{tickers.map(ticker => {
const decision = outputNodeData.decisions[ticker];
const currentPrice = outputNodeData.current_prices?.[ticker] || 'N/A';
return (
{ticker}
${typeof currentPrice === 'number' ? currentPrice.toFixed(2) : currentPrice}
{getActionIcon(decision.action as ActionType)}