diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..d6763d044 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +OPENROUTER_API_KEY=sk-or-v1-your-key-here + +# Storage backend: json (default), postgresql, or mysql +DATABASE_TYPE=json + +# PostgreSQL connection string (only used when DATABASE_TYPE=postgresql) +# Format: postgresql+psycopg2://user:password@host:port/dbname +POSTGRESQL_URL=postgresql+psycopg2://user:password@localhost:5432/llmcouncil + +# MySQL connection string (only used when DATABASE_TYPE=mysql) +# Format: mysql+pymysql://user:password@host:port/dbname +MYSQL_URL=mysql+pymysql://user:password@localhost:3306/llmcouncil + +# ==================== FEATURE 4: TOOLS & MEMORY ==================== +# Free tools are enabled automatically (calculator, wikipedia, arxiv, duckduckgo, yahoo finance) +ENABLE_TAVILY=false +TAVILY_API_KEY= + +# Embeddings for memory (default: free local embeddings) +ENABLE_OPENAI_EMBEDDINGS=false +OPENAI_API_KEY= + +# Memory and graph flags +ENABLE_MEMORY=true +ENABLE_LANGGRAPH=false diff --git a/.gitignore b/.gitignore index 4c2041a54..7dd5ecdf1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ data/ # Frontend frontend/node_modules/ frontend/dist/ -frontend/.vite/ \ No newline at end of file +frontend/.vite/ + +# Scripts +scripts/ \ No newline at end of file diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 000000000..3379b318f --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,341 @@ +# LLM Council - Features + +## Overview +LLM Council is a multi-model AI system that combines responses from multiple AI models through a democratic voting process, delivering superior answers through collective intelligence. + +--- + +## Core Features + +### 🎯 **3-Stage Council Process** +The heart of LLM Council - a democratic approach to AI responses: + +1. **Stage 1: Individual Responses** + - Multiple AI models respond independently to your question + - Each model brings its unique perspective and strengths + - Responses are collected in parallel for speed + +2. **Stage 2: Peer Ranking** + - Each model evaluates and ranks all responses anonymously + - Blind peer review eliminates model bias + - Aggregate rankings determine the best answers + +3. **Stage 3: Final Synthesis** + - Chairman model synthesizes insights from all stages + - Combines best aspects of top-ranked responses + - Delivers a comprehensive, well-reasoned answer + +**Models Used:** +- Council Members: `google/gemini-2.0-flash-exp`, `anthropic/claude-3.5-sonnet`, `qwen/qwen2.5-7b-instruct` +- Chairman: `anthropic/claude-3.5-sonnet` + +--- + +## Feature 1: TOON Integration + +### 📦 **Token Optimization via TOON Format** +TOON (Tree Object Notation) reduces token usage by 30-60% compared to JSON/text. + +**Benefits:** +- Saves costs on API calls +- Faster response times +- More efficient data transfer +- Real-time token savings display in UI + +**Implementation:** +- Stage 1 & 2 responses compressed with TOON +- Automatic token counting with tiktoken +- Displays: `JSON tokens`, `TOON tokens`, `Saved %` + +--- + +## Feature 2: Database Migration + +### 💾 **Multi-Database Storage Backend** +Flexible storage with automatic switching based on configuration. + +**Supported Backends:** +- **JSON Files** (default) - Zero setup, works immediately +- **PostgreSQL** - Production-ready, ACID compliant +- **MySQL** - Popular, widely supported + +**Key Features:** +- Unified storage API - same code works with all backends +- Automatic database initialization +- SQLAlchemy models for PostgreSQL/MySQL +- Environment variable configuration (`DATABASE_TYPE`) + +**Storage Schema:** +```sql +conversations ( + id VARCHAR(36) PRIMARY KEY, + created_at TIMESTAMP, + updated_at TIMESTAMP, + title VARCHAR(500), + messages JSON -- Native JSONB in PostgreSQL +) +``` + +--- + +## Feature 3: Follow-up Questions with Context + +### 💬 **Conversation Memory & Context** +Natural multi-turn conversations with full context awareness. + +**How It Works:** +- Last 6 messages (3 exchanges) sent as context +- Context passed to all council members +- Enables follow-up questions and clarifications +- Seamless conversation continuity + +**Benefits:** +- "What about X?" style questions work naturally +- Models understand conversation history +- No need to repeat previous information +- Smarter, context-aware responses + +--- + +## Feature 4: Advanced AI Capabilities + +### 🛠️ **Tool Integration** +5 free tools + 1 optional paid tool for enhanced capabilities. + +**FREE Tools (Always Available):** +1. **Calculator** - Python REPL for calculations +2. **Wikipedia** - Factual information lookup +3. **ArXiv** - Research paper search +4. **DuckDuckGo** - Web search for current info +5. **Yahoo Finance** - Stock prices and market data + +**PAID Tools (Optional):** +- **Tavily** - Advanced web search (requires API key + flag) + +**Auto-Detection:** +- System detects when tools are needed +- Automatically selects appropriate tool +- Results fed into council discussion +- Tool outputs shown in metadata + +### 🧠 **Memory System** +Vector-based memory for conversation recall across sessions. + +**Features:** +- ChromaDB vector store per conversation +- Semantic search for relevant past exchanges +- FREE local embeddings (HuggingFace) +- OPTIONAL OpenAI embeddings (better quality) + +**How It Works:** +- Each conversation has its own memory collection +- Past exchanges stored as vectors +- Relevant context retrieved automatically +- Enhances long-term conversation continuity + +### 🔀 **LangGraph Support** +Graph-based workflow orchestration (advanced feature). + +**Status:** Framework ready, disabled by default +- Set `ENABLE_LANGGRAPH=true` to activate +- Complex routing and conditional workflows +- Most users don't need this + +--- + +## Feature 5: Conversation Management + +### 🗑️ **Delete Conversations** +Remove unwanted conversations with confirmation. + +**Features:** +- 3-dot menu (⋮) for actions +- Confirmation dialog before deletion +- Works with all storage backends +- Auto-clears current conversation if deleted + +### ✏️ **Edit Conversation Titles** +Inline title editing for better organization. + +**Features:** +- Click ✏️ in 3-dot menu +- Inline text input appears +- Press Enter to save, Escape to cancel +- Real-time UI updates across all views + +**Keyboard Shortcuts:** +- `Enter` - Save changes +- `Escape` - Cancel editing + +### 💬 **Temporary Chat Mode** +Private conversations that don't save to storage. + +**Use Cases:** +- Sensitive queries +- Quick one-off questions +- Testing without cluttering history + +**Implementation:** +- Backend API ready (`temporary: true` flag) +- No storage persistence +- No title generation +- No memory tracking + +--- + +## Additional Features + +### 🎨 **Modern UI/UX** +- Clean, professional interface +- Real-time streaming responses +- Stage-by-stage progress indicators +- Token savings display +- Responsive design + +### 🔒 **Privacy & Security** +- Local-first JSON storage option +- Optional database backends +- Temporary chat mode for sensitive data +- No data leaves your server (except API calls to AI providers) + +### ⚡ **Performance** +- Parallel API calls to all models +- Streaming responses for instant feedback +- TOON compression saves 30-60% tokens +- Efficient database queries with indexes + +### 🔧 **Configuration** +- Environment variable based setup +- Flag-based feature control +- Easy API key management +- Multiple storage backend options + +--- + +## Technical Stack + +### Backend: +- **FastAPI** - High-performance async API framework +- **SQLAlchemy** - ORM for database operations +- **LangChain** - Tool integration framework +- **ChromaDB** - Vector database for memory +- **TOON** - Token-efficient data format + +### Frontend: +- **React** - UI library +- **Vite** - Build tool and dev server +- **Server-Sent Events** - Real-time streaming + +### AI/ML: +- **OpenRouter** - Multi-model API gateway +- **HuggingFace** - Free local embeddings +- **Sentence Transformers** - Text embedding models + +--- + +## Configuration Flags + +### Database: +```bash +DATABASE_TYPE=json # json, postgresql, or mysql +POSTGRESQL_URL=... # If using PostgreSQL +MYSQL_URL=... # If using MySQL +``` + +### Feature 4 - Tools & Memory: +```bash +# Free tools (always enabled) +# - Calculator, Wikipedia, ArXiv, DuckDuckGo, Yahoo Finance + +# Paid tools (optional) +ENABLE_TAVILY=false # Advanced web search +TAVILY_API_KEY= + +# Memory system +ENABLE_MEMORY=true # Vector-based conversation memory +ENABLE_OPENAI_EMBEDDINGS=false # Use OpenAI embeddings (vs free local) +OPENAI_API_KEY= + +# Advanced features +ENABLE_LANGGRAPH=false # Graph-based workflows +``` + +--- + +## Feature Comparison + +| Feature | Free Tier | Premium (Optional) | +|---------|-----------|-------------------| +| 3-Stage Council | ✅ | ✅ | +| TOON Compression | ✅ | ✅ | +| Database Storage | ✅ | ✅ | +| Context Memory | ✅ | ✅ | +| Calculator | ✅ | ✅ | +| Wikipedia | ✅ | ✅ | +| ArXiv Search | ✅ | ✅ | +| DuckDuckGo Search | ✅ | ✅ | +| Yahoo Finance | ✅ | ✅ | +| Local Embeddings | ✅ | ✅ | +| Conversation Management | ✅ | ✅ | +| Tavily Search | ❌ | ✅ (API key) | +| OpenAI Embeddings | ❌ | ✅ (API key) | +| LangGraph | ✅ | ✅ | + +--- + +## Roadmap + +### Planned Features: +- [ ] Conversation folders/tags +- [ ] Export conversations (Markdown, PDF) +- [ ] Custom model selection +- [ ] Prompt templates +- [ ] API usage statistics +- [ ] Multi-user support +- [ ] Dark mode +- [ ] Mobile responsive design +- [ ] Keyboard shortcuts +- [ ] Conversation search + +### Potential Integrations: +- [ ] More AI providers (Groq, Together AI) +- [ ] More tools (Google Scholar, WolframAlpha) +- [ ] Voice input/output +- [ ] Image generation +- [ ] Code execution sandbox +- [ ] Document Q&A + +--- + +## Credits + +**Core Technologies:** +- OpenRouter for multi-model API access +- TOON format for token optimization +- LangChain for tool orchestration +- ChromaDB for vector storage + +**Open Source Libraries:** +- FastAPI, React, SQLAlchemy, Vite +- HuggingFace Transformers +- Sentence Transformers + +--- + +## License & Contributing + +This is an open-source project. Contributions welcome! + +**Key Features for Contributors:** +- Clean, modular architecture +- Storage backend abstraction +- Flag-based feature control +- Comprehensive documentation +- Professional code standards + +For detailed implementation guides, see: +- `contributions/feature1_plan.md` - TOON Integration +- `contributions/feature2_plan.md` - Database Migration +- `contributions/feature3_plan.md` - Context & Follow-ups +- `contributions/feature4_plan.md` - Tools & Memory +- `contributions/feature5.md` - Conversation Management diff --git a/README.md b/README.md index 23599b3cf..9d6e8b2fa 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,21 @@ In a bit more detail, here is what happens when you submit a query: 2. **Stage 2: Review**. Each individual LLM is given the responses of the other LLMs. Under the hood, the LLM identities are anonymized so that the LLM can't play favorites when judging their outputs. The LLM is asked to rank them in accuracy and insight. 3. **Stage 3: Final response**. The designated Chairman of the LLM Council takes all of the model's responses and compiles them into a single final answer that is presented to the user. +## Features + +This enhanced version includes 5 major features: + +🎯 **Feature 1: TOON Integration** - Token optimization using TOON format (30-60% savings) +💾 **Feature 2: Multi-Database Support** - JSON, PostgreSQL, or MySQL storage backends +💬 **Feature 3: Context & Follow-ups** - Natural multi-turn conversations with memory +🛠️ **Feature 4: Advanced AI Tools** - Calculator, Wikipedia, ArXiv, DuckDuckGo, Yahoo Finance, Memory System +⚙️ **Feature 5: Conversation Management** - Delete, edit titles, temporary chat mode with 3-dot menu UI + +See [FEATURES.md](FEATURES.md) for detailed feature documentation and [RUN.md](RUN.md) for complete setup instructions. + ## Vibe Code Alert -This project was 99% vibe coded as a fun Saturday hack because I wanted to explore and evaluate a number of LLMs side by side in the process of [reading books together with LLMs](https://x.com/karpathy/status/1990577951671509438). It's nice and useful to see multiple responses side by side, and also the cross-opinions of all LLMs on each other's outputs. I'm not going to support it in any way, it's provided here as is for other people's inspiration and I don't intend to improve it. Code is ephemeral now and libraries are over, ask your LLM to change it in whatever way you like. +This project was originally 99% vibe coded as a fun Saturday hack. This fork extends it with production-ready features including database support, tool integration, memory systems, and conversation management - all while maintaining the original vision of collaborative LLM decision-making. ## Setup @@ -34,14 +46,50 @@ cd .. ### 2. Configure API Key -Create a `.env` file in the project root: +Copy the example environment file and add your API key: + +```bash +cp .env.example .env +# Edit .env and add your OpenRouter API key +``` +Required configuration: ```bash OPENROUTER_API_KEY=sk-or-v1-... ``` Get your API key at [openrouter.ai](https://openrouter.ai/). Make sure to purchase the credits you need, or sign up for automatic top up. +**Optional configurations** (see [.env.example](.env.example) for all options): +- Storage backend: JSON (default), PostgreSQL, or MySQL +- Feature 4: Tools (Calculator, Wikipedia, etc.) and Memory system +- All free tools enabled by default, optional paid tools (Tavily, OpenAI embeddings) + +### 3. Database setup (optional) + +The app defaults to JSON file storage (`DATABASE_TYPE=json`). To use a database instead: + +**PostgreSQL** +1. Create a database and user: + ```bash + createdb llmcouncil + createuser llmcouncil_user + psql -c "ALTER USER llmcouncil_user WITH PASSWORD 'change-me';" + psql -c "GRANT ALL PRIVILEGES ON DATABASE llmcouncil TO llmcouncil_user;" + ``` +2. Set `DATABASE_TYPE=postgresql` and update `POSTGRESQL_URL` in `.env`. +3. Restart the backend; tables auto-create on startup. + +**MySQL** +1. Create a database and user: + ```bash + mysql -u root -p -e "CREATE DATABASE llmcouncil;" + mysql -u root -p -e "CREATE USER 'llmcouncil_user'@'%' IDENTIFIED BY 'change-me';" + mysql -u root -p -e "GRANT ALL PRIVILEGES ON llmcouncil.* TO 'llmcouncil_user'@'%';" + ``` +2. Set `DATABASE_TYPE=mysql` and update `MYSQL_URL` in `.env`. +3. Restart the backend; tables auto-create on startup. + ### 3. Configure Models (Optional) Edit `backend/config.py` to customize the council: @@ -81,7 +129,19 @@ Then open http://localhost:5173 in your browser. ## Tech Stack -- **Backend:** FastAPI (Python 3.10+), async httpx, OpenRouter API -- **Frontend:** React + Vite, react-markdown for rendering -- **Storage:** JSON files in `data/conversations/` +- **Backend:** FastAPI (Python 3.10+), async httpx, OpenRouter API, LangChain, SQLAlchemy +- **Frontend:** React + Vite, react-markdown for rendering, Server-Sent Events for streaming +- **Storage:** JSON files (default), PostgreSQL, or MySQL with unified storage API +- **AI Tools:** Calculator, Wikipedia, ArXiv, DuckDuckGo Search, Yahoo Finance +- **Memory:** ChromaDB with local embeddings (HuggingFace) or optional OpenAI embeddings - **Package Management:** uv for Python, npm for JavaScript + +## Documentation + +- **[FEATURES.md](FEATURES.md)** - Complete feature documentation +- **[RUN.md](RUN.md)** - Detailed setup and running instructions +- **[contributions/](contributions/)** - Feature implementation details + +## License + +Open source - contributions welcome! diff --git a/RUN.md b/RUN.md new file mode 100644 index 000000000..8fa357ef3 --- /dev/null +++ b/RUN.md @@ -0,0 +1,519 @@ +# How to Run LLM Council + +Quick start guide to get LLM Council running on your machine. + +--- + +## Prerequisites + +### Required: +- **Python 3.10+** ([Download](https://python.org)) +- **Node.js 18+** ([Download](https://nodejs.org)) +- **uv** - Python package manager ([Install](https://docs.astral.sh/uv/)) + +### Optional (for database): +- **PostgreSQL 12+** (if using PostgreSQL storage) +- **MySQL 8+** (if using MySQL storage) + +--- + +## Quick Start (5 minutes) + +### 1. Get OpenRouter API Key +1. Go to [https://openrouter.ai](https://openrouter.ai) +2. Sign up for a free account +3. Generate API key +4. Add credits ($5 recommended for testing) + +### 2. Clone & Setup +```bash +# Clone repository +git clone +cd llm-council + +# Install backend dependencies +uv sync + +# Install frontend dependencies +cd frontend +npm install +cd .. +``` + +### 3. Configure Environment +```bash +# Copy example environment file +cp .env.example .env + +# Edit .env and add your OpenRouter API key +nano .env # or use any text editor +``` + +**Required in `.env`:** +```bash +OPENROUTER_API_KEY=sk-or-v1-your-key-here +``` + +### 4. Run the Application + +**Option A: Two Terminals (Recommended)** + +Terminal 1 - Backend: +```bash +uv run python -m backend.main +``` + +Terminal 2 - Frontend: +```bash +cd frontend +npm run dev +``` + +**Option B: Background Processes** +```bash +# Start backend in background +uv run python -m backend.main & + +# Start frontend +cd frontend +npm run dev +``` + +### 5. Open Application +Open your browser to: **http://localhost:5173** + +--- + +## Configuration Options + +### Storage Backend + +**JSON (Default - Zero Setup):** +```bash +DATABASE_TYPE=json +``` + +**PostgreSQL:** +```bash +DATABASE_TYPE=postgresql +POSTGRESQL_URL=postgresql+psycopg2://user:password@localhost:5432/llmcouncil +``` + +**MySQL:** +```bash +DATABASE_TYPE=mysql +MYSQL_URL=mysql+pymysql://user:password@localhost:3306/llmcouncil +``` + +### Feature Flags + +**Feature 4: Tools & Memory** +```bash +# All free tools enabled by default +# (Calculator, Wikipedia, ArXiv, DuckDuckGo, Yahoo Finance) + +# Optional: Paid tools +ENABLE_TAVILY=false +TAVILY_API_KEY= + +# Memory system (free local embeddings) +ENABLE_MEMORY=true + +# Optional: Better embeddings +ENABLE_OPENAI_EMBEDDINGS=false +OPENAI_API_KEY= + +# Advanced: LangGraph workflows +ENABLE_LANGGRAPH=false +``` + +--- + +## Detailed Setup + +### Database Setup (Optional) + +If using PostgreSQL or MySQL instead of JSON: + +**PostgreSQL:** +```bash +# Install PostgreSQL +brew install postgresql # macOS +# or apt-get install postgresql # Linux + +# Start PostgreSQL +brew services start postgresql + +# Create database +createdb llmcouncil + +# Update .env +DATABASE_TYPE=postgresql +POSTGRESQL_URL=postgresql+psycopg2://your_user:your_password@localhost:5432/llmcouncil +``` + +**MySQL:** +```bash +# Install MySQL +brew install mysql # macOS +# or apt-get install mysql-server # Linux + +# Start MySQL +brew services start mysql + +# Create database +mysql -u root -p +CREATE DATABASE llmcouncil; +exit; + +# Update .env +DATABASE_TYPE=mysql +MYSQL_URL=mysql+pymysql://root:your_password@localhost:3306/llmcouncil +``` + +**Auto Initialization:** +- Tables are created automatically on first run +- No manual schema setup needed + +--- + +## Development Mode + +### Backend Development +```bash +# Run with auto-reload +uv run uvicorn backend.main:app --reload --host 0.0.0.0 --port 8001 +``` + +### Frontend Development +```bash +cd frontend +npm run dev +# Vite auto-reloads on file changes +``` + +### View Logs +```bash +# Backend logs +uv run python -m backend.main 2>&1 | tee backend.log + +# Check logs +tail -f backend.log +``` + +--- + +## Testing + +### Test Backend API +```bash +# Health check +curl http://localhost:8001/ + +# List conversations +curl http://localhost:8001/api/conversations + +# Create conversation +curl -X POST http://localhost:8001/api/conversations \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +### Test Frontend +1. Open http://localhost:5173 +2. Click "+ New Conversation" +3. Type a question +4. Watch 3-stage council process +5. See final answer + +### Test Features + +**Test Delete:** +1. Hover over conversation → click ⋮ +2. Click "Delete" +3. Confirm + +**Test Edit Title:** +1. Hover over conversation → click ⋮ +2. Click "Edit title" +3. Type new title → press Enter + +**Test Tools:** +- Ask: "What's the price of AAPL stock?" +- Ask: "Calculate 12345 * 67890" +- Ask: "Search for latest AI news" + +--- + +## Troubleshooting + +### Port Already in Use +```bash +# Kill process on port 8001 (backend) +lsof -ti:8001 | xargs kill -9 + +# Kill process on port 5173 (frontend) +lsof -ti:5173 | xargs kill -9 +``` + +### Backend Won't Start +```bash +# Check Python version +python --version # Must be 3.10+ + +# Reinstall dependencies +rm -rf .venv +uv sync +``` + +### Frontend Won't Start +```bash +# Check Node version +node --version # Must be 18+ + +# Reinstall dependencies +cd frontend +rm -rf node_modules package-lock.json +npm install +``` + +### Database Connection Error +```bash +# Check database is running +psql -l # PostgreSQL +mysql -u root -p # MySQL + +# Verify connection string in .env +# Format: protocol://user:password@host:port/database +``` + +### API Key Issues +```bash +# Verify API key in .env +cat .env | grep OPENROUTER_API_KEY + +# Test API key manually +curl https://openrouter.ai/api/v1/models \ + -H "Authorization: Bearer YOUR_KEY_HERE" +``` + +### Memory/Tools Not Working +```bash +# Check dependencies installed +uv pip list | grep -E "langchain|chromadb|sentence-transformers" + +# Reinstall if missing +uv sync +``` + +--- + +## Production Deployment + +### Environment Setup +```bash +# Use production API keys +OPENROUTER_API_KEY=your-production-key + +# Use database (not JSON) +DATABASE_TYPE=postgresql +POSTGRESQL_URL=your-production-db-url + +# Security +SECRET_KEY=your-secret-key # Add if implementing auth +``` + +### Build Frontend +```bash +cd frontend +npm run build +# Serves from dist/ folder +``` + +### Run Production Backend +```bash +# Use production ASGI server +pip install gunicorn +gunicorn backend.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8001 +``` + +### Serve Frontend +```bash +# Option 1: Nginx +# Configure nginx to serve frontend/dist/ + +# Option 2: Node static server +npm install -g serve +serve -s frontend/dist -l 5173 +``` + +--- + +## Docker Deployment (Optional) + +### Backend Dockerfile +```dockerfile +FROM python:3.10 +WORKDIR /app +COPY . . +RUN pip install uv && uv sync +CMD ["uv", "run", "python", "-m", "backend.main"] +EXPOSE 8001 +``` + +### Frontend Dockerfile +```dockerfile +FROM node:18 +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build +CMD ["npm", "run", "preview"] +EXPOSE 5173 +``` + +### Docker Compose +```yaml +version: '3.8' +services: + backend: + build: . + ports: + - "8001:8001" + env_file: + - .env + depends_on: + - db + + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + ports: + - "5173:5173" + + db: + image: postgres:15 + environment: + POSTGRES_DB: llmcouncil + POSTGRES_PASSWORD: your_password + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: +``` + +Run: +```bash +docker-compose up -d +``` + +--- + +## Performance Tips + +### Backend: +- Use PostgreSQL/MySQL instead of JSON for better performance +- Enable database connection pooling +- Use Redis for caching (future feature) +- Set `ENABLE_MEMORY=false` if not needed + +### Frontend: +- Build for production: `npm run build` +- Enable gzip compression +- Use CDN for static assets +- Implement lazy loading + +--- + +## Monitoring + +### Check System Status +```bash +# Backend health +curl http://localhost:8001/ + +# Database connections +# PostgreSQL: SELECT * FROM pg_stat_activity; +# MySQL: SHOW PROCESSLIST; +``` + +### View Storage Info +```bash +# JSON mode +ls -lh data/conversations/ + +# Database mode +# Check via psql/mysql CLI +``` + +### Monitor API Usage +- Check OpenRouter dashboard for usage +- Monitor token consumption +- Track TOON savings + +--- + +## Support + +### Common Commands Reference +```bash +# Start backend +uv run python -m backend.main + +# Start frontend +cd frontend && npm run dev + +# View logs +tail -f backend.log + +# Reset database (PostgreSQL) +dropdb llmcouncil && createdb llmcouncil + +# Clear conversations (JSON mode) +rm -rf data/conversations/* + +# Update dependencies +uv sync && cd frontend && npm install +``` + +### Get Help +- Check documentation in `contributions/` folder +- Review `.env.example` for configuration options +- Open issue on GitHub + +--- + +## Quick Commands Summary + +```bash +# 🚀 QUICK START (Copy-paste these 5 commands) +uv sync # Install backend +cd frontend && npm install && cd .. # Install frontend +cp .env.example .env # Create config +# Edit .env and add OPENROUTER_API_KEY +uv run python -m backend.main & # Start backend +cd frontend && npm run dev # Start frontend (opens browser) +``` + +**Access:** http://localhost:5173 + +--- + +## System Requirements + +**Minimum:** +- 2 CPU cores +- 4GB RAM +- 2GB disk space + +**Recommended:** +- 4+ CPU cores +- 8GB+ RAM +- 10GB disk space (for database) + +**Platform:** +- macOS, Linux, Windows (WSL2) +- Docker (optional) diff --git a/backend/config.py b/backend/config.py index a9cf7c473..d1a02783f 100644 --- a/backend/config.py +++ b/backend/config.py @@ -9,15 +9,24 @@ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") # Council members - list of OpenRouter model identifiers +# NOTE: Free-tier friendly defaults; swap back to premium list when you add credit. +# Premium set (commented): COUNCIL_MODELS = [ "openai/gpt-5.1", "google/gemini-3-pro-preview", "anthropic/claude-sonnet-4.5", "x-ai/grok-4", ] +# COUNCIL_MODELS = [ +# "google/gemma-2-9b-it", +# "mistralai/mistral-7b-instruct", +# "qwen/qwen2.5-7b-instruct", +# "meta-llama/llama-3.2-3b-instruct", +# ] # Chairman model - synthesizes final response CHAIRMAN_MODEL = "google/gemini-3-pro-preview" +# CHAIRMAN_MODEL = "google/gemma-2-9b-it" # OpenRouter API endpoint OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions" diff --git a/backend/council.py b/backend/council.py index 5069abec9..a20813096 100644 --- a/backend/council.py +++ b/backend/council.py @@ -1,21 +1,77 @@ """3-stage LLM Council orchestration.""" -from typing import List, Dict, Any, Tuple +from typing import List, Dict, Any, Tuple, Optional +import re +import toon +import json from .openrouter import query_models_parallel, query_model from .config import COUNCIL_MODELS, CHAIRMAN_MODEL +from .tools import get_available_tools +from .memory import CouncilMemorySystem -async def stage1_collect_responses(user_query: str) -> List[Dict[str, Any]]: +async def stage1_collect_responses( + user_query: str, + context: Optional[List[Dict[str, Any]]] = None, + conversation_id: Optional[str] = None +) -> Tuple[List[Dict[str, Any]], List[Dict[str, str]]]: """ Stage 1: Collect individual responses from all council models. Args: user_query: The user's question + context: Previous messages for conversation continuity Returns: - List of dicts with 'model' and 'response' keys + Tuple of (stage1_results, tool_outputs) """ - messages = [{"role": "user", "content": user_query}] + # Build messages with context + messages = [] + + # Add context if available + if context and len(context) > 0: + # Take last 6 messages (3 exchanges) to avoid token limit + recent_context = context[-6:] + + # Build context summary + context_text = "Previous conversation:\n\n" + for msg in recent_context: + if msg['role'] == 'user': + context_text += f"User: {msg['content']}\n\n" + elif msg['role'] == 'assistant' and 'stage3' in msg: + # Use final council answer from Stage 3 + final_answer = msg['stage3']['response'] + # Truncate if too long (keep first 200 chars) + if len(final_answer) > 200: + final_answer = final_answer[:200] + "..." + context_text += f"Council: {final_answer}\n\n" + + # Add context as system message + messages.append({ + "role": "system", + "content": context_text.strip() + }) + + # Memory-based context + memory_ctx = "" + if conversation_id: + memory = CouncilMemorySystem(conversation_id) + memory_ctx = memory.get_context(user_query) + if memory_ctx: + messages.append({"role": "system", "content": f"Relevant past exchanges:\n{memory_ctx}"}) + + # Add tool context if the query suggests tool usage + tool_outputs: List[Dict[str, str]] = [] + if requires_tools(user_query): + tool_outputs = run_tools_for_query(user_query) + if tool_outputs: + tool_text = "Tool outputs:\n" + "\n".join( + f"- {item['tool']}: {item['result']}" for item in tool_outputs + ) + messages.append({"role": "system", "content": tool_text}) + + # Add current query + messages.append({"role": "user", "content": user_query}) # Query all models in parallel responses = await query_models_parallel(COUNCIL_MODELS, messages) @@ -29,7 +85,7 @@ async def stage1_collect_responses(user_query: str) -> List[Dict[str, Any]]: "response": response.get('content', '') }) - return stage1_results + return stage1_results, tool_outputs async def stage2_collect_rankings( @@ -115,7 +171,8 @@ async def stage2_collect_rankings( async def stage3_synthesize_final( user_query: str, stage1_results: List[Dict[str, Any]], - stage2_results: List[Dict[str, Any]] + stage2_results: List[Dict[str, Any]], + tool_outputs: Optional[List[Dict[str, str]]] = None ) -> Dict[str, Any]: """ Stage 3: Chairman synthesizes final response. @@ -128,27 +185,29 @@ async def stage3_synthesize_final( Returns: Dict with 'model' and 'response' keys """ - # Build comprehensive context for chairman - stage1_text = "\n\n".join([ - f"Model: {result['model']}\nResponse: {result['response']}" - for result in stage1_results - ]) + # Build comprehensive context for chairman using TOON format + # TOON reduces token usage by 30-60% compared to JSON/text formatting + stage1_text = toon.encode(stage1_results) + stage2_text = toon.encode(stage2_results) - stage2_text = "\n\n".join([ - f"Model: {result['model']}\nRanking: {result['ranking']}" - for result in stage2_results - ]) + tools_text = "" + if tool_outputs: + tools_text = "TOOL OUTPUTS:\n" + "\n".join( + f"- {t.get('tool')}: {t.get('result')}" for t in tool_outputs + ) chairman_prompt = f"""You are the Chairman of an LLM Council. Multiple AI models have provided responses to a user's question, and then ranked each other's responses. Original Question: {user_query} -STAGE 1 - Individual Responses: +STAGE 1 - Individual Responses (TOON format): {stage1_text} -STAGE 2 - Peer Rankings: +STAGE 2 - Peer Rankings (TOON format): {stage2_text} +{tools_text} + Your task as Chairman is to synthesize all of this information into a single, comprehensive, accurate answer to the user's original question. Consider: - The individual responses and their insights - The peer rankings and what they reveal about response quality @@ -208,6 +267,183 @@ def parse_ranking_from_text(ranking_text: str) -> List[str]: return matches +def _has_finance_signal(query: str) -> bool: + q = query.lower() + finance_signals = {"price", "stock", "stocks", "shares", "ticker", "market cap", "quote"} + return any(sig in q for sig in finance_signals) + + +def _has_calc_signal(query: str) -> bool: + q = query.lower() + calc_signals = {"calculate", "compute", "math", "sum", "multiply", "divide", "add", "subtract"} + return any(sig in q for sig in calc_signals) + + +def _has_search_signal(query: str) -> bool: + q = query.lower() + search_signals = {"search", "latest", "news", "current", "recent"} + return any(sig in q for sig in search_signals) + + +def _has_research_signal(query: str) -> bool: + q = query.lower() + research_signals = {"wikipedia", "wiki", "research", "paper", "arxiv", "definition", "history"} + return any(sig in q for sig in research_signals) + + +def requires_tools(query: str) -> bool: + """Heuristic: only run tools when signals are clear.""" + return ( + _has_finance_signal(query) + or _has_calc_signal(query) + or _has_search_signal(query) + or _has_research_signal(query) + ) + + +def run_tools_for_query(query: str, limit: int = 3) -> List[Dict[str, str]]: + """ + Run available tools against the query to enrich context. + Returns a list of {tool, result} entries. + """ + results: List[Dict[str, str]] = [] + tools = get_available_tools() + stock_tool = next((t for t in tools if t.name == "stock_data"), None) + web_tool = next((t for t in tools if t.name == "web_search"), None) + finance_intent = _has_finance_signal(query) + + # If ticker-like symbols are present, try them first (in order) + if finance_intent: + tickers = extract_ticker_candidates(query) + if tickers and stock_tool: + results.extend(run_stock_for_tickers(stock_tool, tickers, limit)) + if results: + return results + + # Fallback: try to infer tickers from web search output, then query stock tool + if not results and stock_tool and web_tool: + try: + web_output = web_tool.run(query) + inferred_tickers = extract_ticker_candidates(str(web_output)) + if inferred_tickers: + results.extend(run_stock_for_tickers(stock_tool, inferred_tickers, limit)) + if results: + return results + except Exception: + pass + + for tool in tools: + # Skip stock tool here; handled above + if tool.name == "stock_data": + continue + # Skip web tool if it was already used for inference + if tool.name == "web_search" and web_tool is not None: + continue + if len(results) >= limit: + break + # Skip tools that don't match intent + if tool.name == "calculator": + if not _has_calc_signal(query): + continue + if tool.name == "wikipedia" or tool.name == "arxiv": + if not _has_research_signal(query): + continue + if tool.name == "web_search": + if not _has_search_signal(query): + continue + try: + output = tool.run(query) + if output: + # Truncate very long outputs to keep prompts tight + if isinstance(output, str) and len(output) > 500: + output = output[:500] + "..." + results.append({"tool": tool.name, "result": str(output)}) + except Exception: + # Silently skip tool failures to avoid breaking the request path + continue + + return results + + +def run_stock_for_tickers(stock_tool, tickers: List[str], limit: int) -> List[Dict[str, str]]: + """Run stock tool for a list of tickers and return valid price outputs.""" + results: List[Dict[str, str]] = [] + seen = set() + + for ticker in tickers: + if len(results) >= limit: + break + if ticker in seen: + continue + seen.add(ticker) + try: + output = stock_tool.run(ticker) + if not output: + continue + output_str = str(output) + # Treat responses with a dollar sign or price marker as valid + if "$" in output_str or "price=" in output_str.lower(): + results.append({"tool": stock_tool.name, "result": output_str}) + except Exception: + continue + + return results + + +def extract_ticker_candidates(text: str) -> List[str]: + """ + Extract probable stock tickers from text. + Returns a list ordered by appearance; includes simple name->ticker mappings. + """ + if not text: + return [] + + stop_words = { + "THE", "AND", "FOR", "WITH", "TODAY", "PRICE", "STOCK", "STOCKS", "HOW", + "WHAT", "IS", "ARE", "OF", "IN", "ON", "TO", "BY", "VS", "VERSUS", "GOOD", + "BETTER", "BAD", "SHARE", "SHARES", "MARKET", "QUESTION", "ABOUT" + } + + name_map = { + "APPLE": "AAPL", + "TESLA": "TSLA", + "GOOGLE": "GOOGL", + "ALPHABET": "GOOGL", + "MICROSOFT": "MSFT", + "AMAZON": "AMZN", + "META": "META", + "FACEBOOK": "META", + "NVIDIA": "NVDA", + "NETFLIX": "NFLX", + "AMD": "AMD", + "IBM": "IBM", + "SHOPIFY": "SHOP", + "SNOW": "SNOW", + } + + tokens = re.findall(r"\b[A-Z]{1,10}\b", text.upper()) + seen = set() + candidates: List[str] = [] + + for tok in tokens: + mapped = name_map.get(tok) + if mapped: + if mapped not in seen: + seen.add(mapped) + candidates.append(mapped) + continue + + if tok in stop_words: + continue + + if 1 <= len(tok) <= 5: + if tok not in seen: + seen.add(tok) + candidates.append(tok) + + return candidates + + def calculate_aggregate_rankings( stage2_results: List[Dict[str, Any]], label_to_model: Dict[str, str] @@ -293,7 +529,7 @@ async def generate_conversation_title(user_query: str) -> str: return title -async def run_full_council(user_query: str) -> Tuple[List, List, Dict, Dict]: +async def run_full_council(user_query: str, conversation_id: Optional[str] = None) -> Tuple[List, List, Dict, Dict]: """ Run the complete 3-stage council process. @@ -303,8 +539,8 @@ async def run_full_council(user_query: str) -> Tuple[List, List, Dict, Dict]: Returns: Tuple of (stage1_results, stage2_results, stage3_result, metadata) """ - # Stage 1: Collect individual responses - stage1_results = await stage1_collect_responses(user_query) + # Stage 1: Collect individual responses (+ tool outputs) + stage1_results, tool_outputs = await stage1_collect_responses(user_query, conversation_id=conversation_id) # If no models responded successfully, return error if not stage1_results: @@ -323,13 +559,71 @@ async def run_full_council(user_query: str) -> Tuple[List, List, Dict, Dict]: stage3_result = await stage3_synthesize_final( user_query, stage1_results, - stage2_results + stage2_results, + tool_outputs=tool_outputs ) + # Save exchange to memory + if conversation_id: + memory = CouncilMemorySystem(conversation_id) + memory.save_exchange(user_query, stage3_result.get("response", "")) + + # Calculate token savings from TOON + token_savings = calculate_token_savings(stage1_results, stage2_results) + # Prepare metadata metadata = { "label_to_model": label_to_model, - "aggregate_rankings": aggregate_rankings + "aggregate_rankings": aggregate_rankings, + "token_savings": token_savings, + "tool_outputs": tool_outputs } return stage1_results, stage2_results, stage3_result, metadata + + +def calculate_token_savings( + stage1_results: List[Dict[str, Any]], + stage2_results: List[Dict[str, Any]] +) -> Dict[str, Any]: + """ + Calculate token savings from using TOON format. + + Args: + stage1_results: Stage 1 responses + stage2_results: Stage 2 rankings + + Returns: + Dict with token counts and savings + """ + try: + import tiktoken + + enc = tiktoken.get_encoding("cl100k_base") + + # Calculate tokens for JSON format + json_str = json.dumps({"stage1": stage1_results, "stage2": stage2_results}) + json_tokens = len(enc.encode(json_str)) + + # Calculate tokens for TOON format + toon_str = toon.encode({"stage1": stage1_results, "stage2": stage2_results}) + toon_tokens = len(enc.encode(toon_str)) + + saved = json_tokens - toon_tokens + percent = (saved / json_tokens * 100) if json_tokens > 0 else 0 + + return { + "json_tokens": json_tokens, + "toon_tokens": toon_tokens, + "saved_tokens": saved, + "saved_percent": round(percent, 1) + } + except Exception as e: + # If token calculation fails, return empty dict + return { + "json_tokens": 0, + "toon_tokens": 0, + "saved_tokens": 0, + "saved_percent": 0, + "error": str(e) + } diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 000000000..d91b58d76 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,130 @@ +"""Database configuration with flag-based PostgreSQL/MySQL selection.""" + +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from sqlalchemy.pool import NullPool +from typing import Literal + +# Create base for models +Base = declarative_base() + +# Database type selection from environment +DB_TYPE = os.getenv("DATABASE_TYPE", "json").lower() # Options: "postgresql", "mysql", "json" + +# Get database connection strings from environment +POSTGRESQL_URL = os.getenv("POSTGRESQL_URL", "postgresql://user:password@localhost:5432/llmcouncil") +MYSQL_URL = os.getenv("MYSQL_URL", "mysql+pymysql://user:password@localhost:3306/llmcouncil") + + +def get_database_url() -> str: + """ + Get database URL based on DATABASE_TYPE flag. + + Returns: + Database connection URL + + Raises: + ValueError: If DATABASE_TYPE is invalid + """ + if DB_TYPE == "postgresql": + return POSTGRESQL_URL + elif DB_TYPE == "mysql": + return MYSQL_URL + elif DB_TYPE == "json": + # Return None for JSON file storage (backward compatible) + return None + else: + raise ValueError( + f"Invalid DATABASE_TYPE: {DB_TYPE}. " + "Must be 'postgresql', 'mysql', or 'json'" + ) + + +def create_database_engine(): + """ + Create SQLAlchemy engine based on database type. + + Returns: + SQLAlchemy engine or None if using JSON storage + """ + database_url = get_database_url() + + if database_url is None: + # JSON file storage mode + return None + + # Create engine based on database type + if DB_TYPE == "postgresql": + engine = create_engine( + database_url, + pool_pre_ping=True, # Verify connections before using + echo=False, # Set to True for SQL debugging + ) + elif DB_TYPE == "mysql": + engine = create_engine( + database_url, + pool_pre_ping=True, + echo=False, + pool_recycle=3600, # Recycle connections after 1 hour + ) + else: + raise ValueError(f"Unsupported database type: {DB_TYPE}") + + return engine + + +# Create engine and session factory +engine = create_database_engine() + +if engine is not None: + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +else: + SessionLocal = None + + +def get_db(): + """ + Dependency for FastAPI to get database session. + + Yields: + Database session + """ + if SessionLocal is None: + raise RuntimeError("Database not configured. Set DATABASE_TYPE in .env") + + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_database(): + """ + Initialize database tables. + Call this on application startup. + """ + if engine is None: + print("Using JSON file storage (DATABASE_TYPE=json)") + return + + print(f"Initializing {DB_TYPE.upper()} database...") + + # Import models to register them + from . import models + + # Create all tables + Base.metadata.create_all(bind=engine) + + print(f"{DB_TYPE.upper()} database initialized successfully!") + + +def get_storage_type() -> Literal["postgresql", "mysql", "json"]: + """Get the current storage type.""" + return DB_TYPE + + +def is_using_database() -> bool: + """Check if using database (PostgreSQL/MySQL) or JSON files.""" + return DB_TYPE in ["postgresql", "mysql"] diff --git a/backend/main.py b/backend/main.py index e33ce59a6..8c9ba6d09 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,20 +4,32 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional import uuid import json import asyncio from . import storage from .council import run_full_council, generate_conversation_title, stage1_collect_responses, stage2_collect_rankings, stage3_synthesize_final, calculate_aggregate_rankings +from .database import init_database app = FastAPI(title="LLM Council API") +# Initialize database on startup +@app.on_event("startup") +async def startup_event(): + """Initialize database tables if using database storage.""" + init_database() + # Enable CORS for local development app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173", "http://localhost:3000"], + allow_origins=[ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -32,6 +44,13 @@ class CreateConversationRequest(BaseModel): class SendMessageRequest(BaseModel): """Request to send a message in a conversation.""" content: str + context: Optional[List[Dict[str, Any]]] = None + temporary: Optional[bool] = False # If True, don't save to storage + + +class UpdateTitleRequest(BaseModel): + """Request to update conversation title.""" + title: str class ConversationMetadata(BaseModel): @@ -79,12 +98,67 @@ async def get_conversation(conversation_id: str): return conversation +@app.patch("/api/conversations/{conversation_id}/title") +async def update_conversation_title(conversation_id: str, request: UpdateTitleRequest): + """ + Update the title of a conversation. + + Works with all storage backends: JSON, PostgreSQL, MySQL. + """ + # Check if conversation exists + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Update title + storage.update_conversation_title(conversation_id, request.title) + + return { + "success": True, + "message": "Title updated", + "title": request.title + } + + +@app.delete("/api/conversations/{conversation_id}") +async def delete_conversation(conversation_id: str): + """ + Delete a conversation. + + Works with all storage backends: JSON, PostgreSQL, MySQL. + """ + success = storage.delete_conversation(conversation_id) + if not success: + raise HTTPException(status_code=404, detail="Conversation not found") + return {"success": True, "message": "Conversation deleted"} + + @app.post("/api/conversations/{conversation_id}/message") async def send_message(conversation_id: str, request: SendMessageRequest): """ Send a message and run the 3-stage council process. Returns the complete response with all stages. + + Supports temporary mode: if request.temporary=True, conversation is not saved to storage. """ + # For temporary mode, skip conversation existence check and storage operations + if request.temporary: + # Run the 3-stage council process without saving + stage1_results, stage2_results, stage3_result, metadata = await run_full_council( + request.content, + conversation_id=None # No conversation_id for temporary chat + ) + + # Return the complete response with metadata (not saved to storage) + return { + "stage1": stage1_results, + "stage2": stage2_results, + "stage3": stage3_result, + "metadata": metadata, + "temporary": True + } + + # Normal mode: save to storage # Check if conversation exists conversation = storage.get_conversation(conversation_id) if conversation is None: @@ -103,7 +177,8 @@ async def send_message(conversation_id: str, request: SendMessageRequest): # Run the 3-stage council process stage1_results, stage2_results, stage3_result, metadata = await run_full_council( - request.content + request.content, + conversation_id=conversation_id ) # Add assistant message with all stages @@ -111,7 +186,8 @@ async def send_message(conversation_id: str, request: SendMessageRequest): conversation_id, stage1_results, stage2_results, - stage3_result + stage3_result, + metadata ) # Return the complete response with metadata @@ -128,57 +204,88 @@ async def send_message_stream(conversation_id: str, request: SendMessageRequest) """ Send a message and stream the 3-stage council process. Returns Server-Sent Events as each stage completes. - """ - # Check if conversation exists - conversation = storage.get_conversation(conversation_id) - if conversation is None: - raise HTTPException(status_code=404, detail="Conversation not found") - # Check if this is the first message - is_first_message = len(conversation["messages"]) == 0 + Supports temporary mode: if request.temporary=True, conversation is not saved to storage. + """ + # For temporary mode, skip conversation check + if not request.temporary: + # Check if conversation exists (normal mode only) + conversation = storage.get_conversation(conversation_id) + if conversation is None: + raise HTTPException(status_code=404, detail="Conversation not found") + is_first_message = len(conversation["messages"]) == 0 + else: + is_first_message = False # No title generation for temporary chats async def event_generator(): try: - # Add user message - storage.add_user_message(conversation_id, request.content) + # Add user message (skip for temporary mode) + if not request.temporary: + storage.add_user_message(conversation_id, request.content) - # Start title generation in parallel (don't await yet) - title_task = None - if is_first_message: - title_task = asyncio.create_task(generate_conversation_title(request.content)) + # Start title generation in parallel (don't await yet) + title_task = None + if is_first_message: + title_task = asyncio.create_task(generate_conversation_title(request.content)) + else: + title_task = None - # Stage 1: Collect responses + # Collect metadata across stages for persistence + msg_metadata = {} + + # Stage 1: Collect responses (with context) yield f"data: {json.dumps({'type': 'stage1_start'})}\n\n" - stage1_results = await stage1_collect_responses(request.content) - yield f"data: {json.dumps({'type': 'stage1_complete', 'data': stage1_results})}\n\n" + stage1_results, tool_outputs = await stage1_collect_responses( + request.content, + context=request.context, + conversation_id=None if request.temporary else conversation_id + ) + msg_metadata["tool_outputs"] = tool_outputs + yield f"data: {json.dumps({'type': 'stage1_complete', 'data': stage1_results, 'metadata': {'tool_outputs': tool_outputs}})}\n\n" # Stage 2: Collect rankings yield f"data: {json.dumps({'type': 'stage2_start'})}\n\n" stage2_results, label_to_model = await stage2_collect_rankings(request.content, stage1_results) aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model) + msg_metadata["label_to_model"] = label_to_model + msg_metadata["aggregate_rankings"] = aggregate_rankings yield f"data: {json.dumps({'type': 'stage2_complete', 'data': stage2_results, 'metadata': {'label_to_model': label_to_model, 'aggregate_rankings': aggregate_rankings}})}\n\n" # Stage 3: Synthesize final answer yield f"data: {json.dumps({'type': 'stage3_start'})}\n\n" - stage3_result = await stage3_synthesize_final(request.content, stage1_results, stage2_results) - yield f"data: {json.dumps({'type': 'stage3_complete', 'data': stage3_result})}\n\n" + stage3_result = await stage3_synthesize_final( + request.content, + stage1_results, + stage2_results, + tool_outputs=tool_outputs + ) + + # Calculate token savings + from .council import calculate_token_savings + token_savings = calculate_token_savings(stage1_results, stage2_results) - # Wait for title generation if it was started + msg_metadata["token_savings"] = token_savings + + yield f"data: {json.dumps({'type': 'stage3_complete', 'data': stage3_result, 'metadata': {'token_savings': token_savings, 'temporary': request.temporary}})}\n\n" + + # Wait for title generation if it was started (normal mode only) if title_task: title = await title_task storage.update_conversation_title(conversation_id, title) yield f"data: {json.dumps({'type': 'title_complete', 'data': {'title': title}})}\n\n" - # Save complete assistant message - storage.add_assistant_message( - conversation_id, - stage1_results, - stage2_results, - stage3_result - ) + # Save complete assistant message (skip for temporary mode) + if not request.temporary: + storage.add_assistant_message( + conversation_id, + stage1_results, + stage2_results, + stage3_result, + msg_metadata + ) # Send completion event - yield f"data: {json.dumps({'type': 'complete'})}\n\n" + yield f"data: {json.dumps({'type': 'complete', 'temporary': request.temporary})}\n\n" except Exception as e: # Send error event diff --git a/backend/memory.py b/backend/memory.py new file mode 100644 index 000000000..0268c0a65 --- /dev/null +++ b/backend/memory.py @@ -0,0 +1,77 @@ +"""Conversation memory with optional embeddings backend. + +Defaults to free local sentence-transformers; can switch to OpenAI embeddings +when ENABLE_OPENAI_EMBEDDINGS=true and OPENAI_API_KEY is set. +""" + +from __future__ import annotations + +import os +from typing import Optional +from pathlib import Path + +from langchain_community.embeddings import HuggingFaceEmbeddings +from langchain_community.vectorstores import Chroma + + +def get_embeddings(): + """Return embeddings implementation based on env flags.""" + if os.getenv("ENABLE_OPENAI_EMBEDDINGS", "false").lower() == "true": + api_key = os.getenv("OPENAI_API_KEY") + if api_key: + try: + from langchain_openai import OpenAIEmbeddings + + return OpenAIEmbeddings(api_key=api_key) + except Exception: + # Fall back to local embeddings on failure + pass + + # Free local embeddings + return HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2") + + +class CouncilMemorySystem: + """Lightweight per-conversation memory backed by Chroma.""" + + def __init__(self, conversation_id: str): + self.enabled = os.getenv("ENABLE_MEMORY", "true").lower() == "true" + self.conversation_id = conversation_id + self.retriever = None + self.vectorstore = None + + if not self.enabled: + return + + embeddings = get_embeddings() + store_path = Path("./data/memory") / conversation_id + store_path.mkdir(parents=True, exist_ok=True) + + self.vectorstore = Chroma( + collection_name=f"conv_{conversation_id}", + embedding_function=embeddings, + persist_directory=str(store_path), + ) + self.retriever = self.vectorstore.as_retriever(search_kwargs={"k": 3}) + + def get_context(self, query: str) -> str: + """Retrieve relevant context for a query.""" + if not self.enabled or self.retriever is None: + return "" + try: + docs = self.retriever.get_relevant_documents(query) + if not docs: + return "" + return "\n".join(doc.page_content for doc in docs if doc.page_content).strip() + except Exception: + return "" + + def save_exchange(self, user_msg: str, assistant_msg: str): + """Persist a user/assistant exchange.""" + if not self.enabled or self.vectorstore is None: + return + try: + content = f"User: {user_msg}\nAssistant: {assistant_msg}" + self.vectorstore.add_texts([content]) + except Exception: + return diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 000000000..094a8666f --- /dev/null +++ b/backend/models.py @@ -0,0 +1,47 @@ +"""SQLAlchemy models for PostgreSQL and MySQL.""" + +from sqlalchemy import Column, String, Text, DateTime, JSON, Index +from sqlalchemy.sql import func +from .database import Base + + +class Conversation(Base): + """ + Conversation model - stores conversation metadata and messages. + + Compatible with both PostgreSQL and MySQL. + """ + + __tablename__ = "conversations" + + # Primary key + id = Column(String(36), primary_key=True, index=True) # UUID + + # Metadata + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + title = Column(String(500), nullable=False, default="New Conversation") + + # Messages stored as JSON + # PostgreSQL: Uses native JSONB (faster) + # MySQL: Uses JSON type (MySQL 5.7.8+) + messages = Column(JSON, nullable=False, default=list) + + # Indexes for performance + __table_args__ = ( + Index("idx_created_at", "created_at"), + Index("idx_title", "title"), + ) + + def to_dict(self): + """Convert model to dictionary.""" + return { + "id": self.id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "title": self.title, + "messages": self.messages or [], + } + + def __repr__(self): + return f"" diff --git a/backend/storage.py b/backend/storage.py index 180111da4..28b7b2df2 100644 --- a/backend/storage.py +++ b/backend/storage.py @@ -1,4 +1,10 @@ -"""JSON-based storage for conversations.""" +""" +Storage layer with automatic switching between Database (PostgreSQL/MySQL) and JSON files. + +Based on DATABASE_TYPE environment variable: +- "postgresql" or "mysql": Use database storage +- "json" (default): Use JSON file storage (backward compatible) +""" import json import os @@ -6,7 +12,11 @@ from typing import List, Dict, Any, Optional from pathlib import Path from .config import DATA_DIR +from .database import get_storage_type, is_using_database, SessionLocal +from .models import Conversation as ConversationModel + +# ==================== JSON FILE STORAGE (Original) ==================== def ensure_data_dir(): """Ensure the data directory exists.""" @@ -18,16 +28,8 @@ def get_conversation_path(conversation_id: str) -> str: return os.path.join(DATA_DIR, f"{conversation_id}.json") -def create_conversation(conversation_id: str) -> Dict[str, Any]: - """ - Create a new conversation. - - Args: - conversation_id: Unique identifier for the conversation - - Returns: - New conversation dict - """ +def _json_create_conversation(conversation_id: str) -> Dict[str, Any]: + """Create conversation in JSON file.""" ensure_data_dir() conversation = { @@ -37,7 +39,6 @@ def create_conversation(conversation_id: str) -> Dict[str, Any]: "messages": [] } - # Save to file path = get_conversation_path(conversation_id) with open(path, 'w') as f: json.dump(conversation, f, indent=2) @@ -45,16 +46,8 @@ def create_conversation(conversation_id: str) -> Dict[str, Any]: return conversation -def get_conversation(conversation_id: str) -> Optional[Dict[str, Any]]: - """ - Load a conversation from storage. - - Args: - conversation_id: Unique identifier for the conversation - - Returns: - Conversation dict or None if not found - """ +def _json_get_conversation(conversation_id: str) -> Optional[Dict[str, Any]]: + """Get conversation from JSON file.""" path = get_conversation_path(conversation_id) if not os.path.exists(path): @@ -64,13 +57,8 @@ def get_conversation(conversation_id: str) -> Optional[Dict[str, Any]]: return json.load(f) -def save_conversation(conversation: Dict[str, Any]): - """ - Save a conversation to storage. - - Args: - conversation: Conversation dict to save - """ +def _json_save_conversation(conversation: Dict[str, Any]): + """Save conversation to JSON file.""" ensure_data_dir() path = get_conversation_path(conversation['id']) @@ -78,13 +66,8 @@ def save_conversation(conversation: Dict[str, Any]): json.dump(conversation, f, indent=2) -def list_conversations() -> List[Dict[str, Any]]: - """ - List all conversations (metadata only). - - Returns: - List of conversation metadata dicts - """ +def _json_list_conversations() -> List[Dict[str, Any]]: + """List all conversations from JSON files.""" ensure_data_dir() conversations = [] @@ -93,7 +76,6 @@ def list_conversations() -> List[Dict[str, Any]]: path = os.path.join(DATA_DIR, filename) with open(path, 'r') as f: data = json.load(f) - # Return metadata only conversations.append({ "id": data["id"], "created_at": data["created_at"], @@ -101,12 +83,179 @@ def list_conversations() -> List[Dict[str, Any]]: "message_count": len(data["messages"]) }) - # Sort by creation time, newest first conversations.sort(key=lambda x: x["created_at"], reverse=True) - return conversations +def _json_delete_conversation(conversation_id: str) -> bool: + """Delete conversation from JSON file.""" + path = get_conversation_path(conversation_id) + + if not os.path.exists(path): + return False + + os.remove(path) + return True + + +# ==================== DATABASE STORAGE (New) ==================== + +def _db_create_conversation(conversation_id: str) -> Dict[str, Any]: + """Create conversation in database.""" + db = SessionLocal() + try: + conversation = ConversationModel( + id=conversation_id, + title="New Conversation", + messages=[] + ) + db.add(conversation) + db.commit() + db.refresh(conversation) + return conversation.to_dict() + finally: + db.close() + + +def _db_get_conversation(conversation_id: str) -> Optional[Dict[str, Any]]: + """Get conversation from database.""" + db = SessionLocal() + try: + conversation = db.query(ConversationModel).filter( + ConversationModel.id == conversation_id + ).first() + + if conversation is None: + return None + + return conversation.to_dict() + finally: + db.close() + + +def _db_save_conversation(conversation: Dict[str, Any]): + """Save conversation to database.""" + db = SessionLocal() + try: + db_conversation = db.query(ConversationModel).filter( + ConversationModel.id == conversation['id'] + ).first() + + if db_conversation: + db_conversation.title = conversation['title'] + db_conversation.messages = conversation['messages'] + db.commit() + finally: + db.close() + + +def _db_list_conversations() -> List[Dict[str, Any]]: + """List all conversations from database.""" + db = SessionLocal() + try: + conversations = db.query(ConversationModel).order_by( + ConversationModel.created_at.desc() + ).all() + + return [ + { + "id": c.id, + "created_at": c.created_at.isoformat(), + "title": c.title, + "message_count": len(c.messages or []) + } + for c in conversations + ] + finally: + db.close() + + +def _db_delete_conversation(conversation_id: str) -> bool: + """Delete conversation from database.""" + db = SessionLocal() + try: + conversation = db.query(ConversationModel).filter( + ConversationModel.id == conversation_id + ).first() + + if conversation is None: + return False + + db.delete(conversation) + db.commit() + return True + finally: + db.close() + + +# ==================== UNIFIED API (Auto-switches based on flag) ==================== + +def create_conversation(conversation_id: str) -> Dict[str, Any]: + """ + Create a new conversation. + + Automatically uses database or JSON based on DATABASE_TYPE. + + Args: + conversation_id: Unique identifier for the conversation + + Returns: + New conversation dict + """ + if is_using_database(): + return _db_create_conversation(conversation_id) + else: + return _json_create_conversation(conversation_id) + + +def get_conversation(conversation_id: str) -> Optional[Dict[str, Any]]: + """ + Load a conversation from storage. + + Automatically uses database or JSON based on DATABASE_TYPE. + + Args: + conversation_id: Unique identifier for the conversation + + Returns: + Conversation dict or None if not found + """ + if is_using_database(): + return _db_get_conversation(conversation_id) + else: + return _json_get_conversation(conversation_id) + + +def save_conversation(conversation: Dict[str, Any]): + """ + Save a conversation to storage. + + Automatically uses database or JSON based on DATABASE_TYPE. + + Args: + conversation: Conversation dict to save + """ + if is_using_database(): + _db_save_conversation(conversation) + else: + _json_save_conversation(conversation) + + +def list_conversations() -> List[Dict[str, Any]]: + """ + List all conversations (metadata only). + + Automatically uses database or JSON based on DATABASE_TYPE. + + Returns: + List of conversation metadata dicts + """ + if is_using_database(): + return _db_list_conversations() + else: + return _json_list_conversations() + + def add_user_message(conversation_id: str, content: str): """ Add a user message to a conversation. @@ -131,7 +280,8 @@ def add_assistant_message( conversation_id: str, stage1: List[Dict[str, Any]], stage2: List[Dict[str, Any]], - stage3: Dict[str, Any] + stage3: Dict[str, Any], + metadata: Optional[Dict[str, Any]] = None ): """ Add an assistant message with all 3 stages to a conversation. @@ -141,6 +291,7 @@ def add_assistant_message( stage1: List of individual model responses stage2: List of model rankings stage3: Final synthesized response + metadata: Optional metadata (e.g., tool outputs, token savings) """ conversation = get_conversation(conversation_id) if conversation is None: @@ -150,7 +301,8 @@ def add_assistant_message( "role": "assistant", "stage1": stage1, "stage2": stage2, - "stage3": stage3 + "stage3": stage3, + "metadata": metadata }) save_conversation(conversation) @@ -170,3 +322,44 @@ def update_conversation_title(conversation_id: str, title: str): conversation["title"] = title save_conversation(conversation) + + +def delete_conversation(conversation_id: str) -> bool: + """ + Delete a conversation from storage. + + Automatically uses database or JSON based on DATABASE_TYPE. + Works with JSON, PostgreSQL, and MySQL backends. + + Args: + conversation_id: Unique identifier for the conversation + + Returns: + True if conversation was deleted, False if not found + """ + if is_using_database(): + return _db_delete_conversation(conversation_id) + else: + return _json_delete_conversation(conversation_id) + + +# ==================== UTILITY FUNCTIONS ==================== + +def get_storage_info() -> Dict[str, str]: + """ + Get information about current storage backend. + + Returns: + Dict with storage type and status + """ + storage_type = get_storage_type() + + return { + "type": storage_type, + "using_database": is_using_database(), + "description": { + "postgresql": "PostgreSQL database storage", + "mysql": "MySQL database storage", + "json": "JSON file storage (default)" + }.get(storage_type, "Unknown") + } diff --git a/backend/tools.py b/backend/tools.py new file mode 100644 index 000000000..d2e87ed75 --- /dev/null +++ b/backend/tools.py @@ -0,0 +1,179 @@ +"""LangChain tool integrations with flag-based enablement. + +Implements a small set of always-free tools plus optional paid search (Tavily). +Use `get_available_tools()` to fetch the enabled tools list based on environment flags. +""" + +from __future__ import annotations + +import os +from typing import List + +# Tool import: prefer langchain_core, fall back to langchain.tools for older installs +try: + from langchain_core.tools import Tool # type: ignore +except ImportError: # pragma: no cover + from langchain.tools import Tool # type: ignore + +from langchain_community.tools import DuckDuckGoSearchRun, WikipediaQueryRun, ArxivQueryRun +from langchain_community.utilities import WikipediaAPIWrapper +import yfinance as yf + +# Optional: Python REPL from langchain_experimental; fall back to a simple evaluator +try: + from langchain_experimental.tools import PythonREPLTool # type: ignore +except ImportError: # pragma: no cover + PythonREPLTool = None + +# Optional: Tavily (paid, flag + key) +try: + from langchain_community.tools.tavily_search import TavilySearchResults +except Exception: # pragma: no cover + TavilySearchResults = None + + +def calculator_tool() -> Tool: + """Calculator/REPL tool (always available, no API key).""" + if PythonREPLTool is not None: + repl = PythonREPLTool() + return Tool( + name="calculator", + func=repl.run, + description="Execute Python code for calculations or quick logic (e.g., '2+2', 'sum([1,2,3])').", + ) + + # Fallback: minimal evaluator using math only + import math + + def _safe_eval(expr: str) -> str: + try: + allowed_globals = {"__builtins__": {}} + allowed_locals = {"math": math} + result = eval(expr, allowed_globals, allowed_locals) + return str(result) + except Exception as exc: # pragma: no cover + return f"Error: {exc}" + + return Tool( + name="calculator", + func=_safe_eval, + description="Basic calculator (math.* available) when PythonREPLTool is unavailable.", + ) + + +def wikipedia_tool() -> Tool: + """Wikipedia lookup (free).""" + wikipedia = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper()) + return Tool( + name="wikipedia", + func=wikipedia.run, + description="Search Wikipedia for factual information (e.g., 'Tell me about Python programming').", + ) + + +def arxiv_tool() -> Tool: + """ArXiv search (free).""" + arxiv = ArxivQueryRun() + return Tool( + name="arxiv", + func=arxiv.run, + description="Search ArXiv for research papers (e.g., 'papers about large language models').", + ) + + +def duckduckgo_tool() -> Tool: + """DuckDuckGo web search (free).""" + try: + search = DuckDuckGoSearchRun() + return Tool( + name="web_search", + func=search.run, + description="General web search for news/current info (e.g., 'latest AI news').", + ) + except ImportError: + return None + + +def yahoo_finance_tool() -> Tool: + """Yahoo Finance stock data (free).""" + + def get_stock_price(ticker: str) -> str: + symbol = (ticker or "").strip().split()[0].upper() + if not symbol: + return "Error: missing ticker symbol" + + try: + stock = yf.Ticker(ticker) + price = None + market_cap = None + + # Prefer fast_info when available + fast_info = getattr(stock, "fast_info", None) + if fast_info: + price = getattr(fast_info, "last_price", None) + market_cap = getattr(fast_info, "market_cap", None) + + if price is None: + info = stock.info + price = info.get("currentPrice") + market_cap = info.get("marketCap") + + # Format price if present + if isinstance(price, (int, float)): + price_str = f"${price:,.2f}" + else: + price_str = "N/A" + + return f"{symbol}: {price_str}" + except Exception as exc: # pragma: no cover + return f"Error fetching {ticker}: {exc}" + + return Tool( + name="stock_data", + func=get_stock_price, + description="Get stock price/market cap via Yahoo Finance (e.g., 'AAPL').", + ) + + +def tavily_tool(api_key: str) -> Tool: + """Tavily search (paid, requires key + flag).""" + if TavilySearchResults is None: + raise RuntimeError("Tavily not installed; ensure langchain_community is available.") + + search = TavilySearchResults( + api_key=api_key, + max_results=3, + search_depth="advanced", + include_answer=True, + ) + return Tool( + name="tavily_search", + func=search.invoke, + description="Advanced web search (paid) for richer current-event answers.", + ) + + +def get_available_tools() -> List[Tool]: + """Return enabled tools based on environment flags.""" + tools: List[Tool] = [ + calculator_tool(), + wikipedia_tool(), + arxiv_tool(), + duckduckgo_tool(), + yahoo_finance_tool(), + ] + + # Drop any None entries (e.g., missing ddgs dependency) + tools = [t for t in tools if t is not None] + + enable_tavily = os.getenv("ENABLE_TAVILY", "false").lower() == "true" + api_key = os.getenv("TAVILY_API_KEY") + + if enable_tavily and api_key: + try: + tools.append(tavily_tool(api_key)) + except Exception: + # Fail silently here; downstream can log if desired + pass + + return tools diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 195415598..6bc911350 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -57,6 +57,44 @@ function App() { setCurrentConversationId(id); }; + const handleDeleteConversation = async (id) => { + try { + await api.deleteConversation(id); + // Remove from conversations list + setConversations(conversations.filter((conv) => conv.id !== id)); + // If deleting current conversation, clear it + if (id === currentConversationId) { + setCurrentConversationId(null); + setCurrentConversation(null); + } + } catch (error) { + console.error('Failed to delete conversation:', error); + alert('Failed to delete conversation'); + } + }; + + const handleUpdateTitle = async (id, newTitle) => { + try { + await api.updateConversationTitle(id, newTitle); + // Update conversations list + setConversations( + conversations.map((conv) => + conv.id === id ? { ...conv, title: newTitle } : conv + ) + ); + // Update current conversation if it's the one being edited + if (id === currentConversationId && currentConversation) { + setCurrentConversation({ + ...currentConversation, + title: newTitle, + }); + } + } catch (error) { + console.error('Failed to update conversation title:', error); + alert('Failed to update conversation title'); + } + }; + const handleSendMessage = async (content) => { if (!currentConversationId) return; @@ -106,6 +144,10 @@ function App() { const messages = [...prev.messages]; const lastMsg = messages[messages.length - 1]; lastMsg.stage1 = event.data; + lastMsg.metadata = { + ...(lastMsg.metadata || {}), + ...(event.metadata || {}), + }; lastMsg.loading.stage1 = false; return { ...prev, messages }; }); @@ -125,7 +167,10 @@ function App() { const messages = [...prev.messages]; const lastMsg = messages[messages.length - 1]; lastMsg.stage2 = event.data; - lastMsg.metadata = event.metadata; + lastMsg.metadata = { + ...(lastMsg.metadata || {}), + ...(event.metadata || {}), + }; lastMsg.loading.stage2 = false; return { ...prev, messages }; }); @@ -145,6 +190,10 @@ function App() { const messages = [...prev.messages]; const lastMsg = messages[messages.length - 1]; lastMsg.stage3 = event.data; + lastMsg.metadata = { + ...(lastMsg.metadata || {}), + ...(event.metadata || {}), + }; lastMsg.loading.stage3 = false; return { ...prev, messages }; }); @@ -188,6 +237,8 @@ function App() { currentConversationId={currentConversationId} onSelectConversation={handleSelectConversation} onNewConversation={handleNewConversation} + onDeleteConversation={handleDeleteConversation} + onUpdateTitle={handleUpdateTitle} /> void + * @param {boolean} temporary - If true, don't save conversation to storage * @returns {Promise} */ - async sendMessageStream(conversationId, content, onEvent) { + async sendMessageStream(conversationId, content, onEvent, temporary = false) { + let context = []; + + // Get conversation context (skip for temporary chats) + if (!temporary) { + const conversation = await this.getConversation(conversationId); + // Extract last 6 messages as context (3 exchanges) + context = conversation.messages.slice(-6); + } + const response = await fetch( `${API_BASE}/api/conversations/${conversationId}/message/stream`, { @@ -81,7 +91,11 @@ export const api = { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ content }), + body: JSON.stringify({ + content, + context, + temporary // Send temporary flag + }), } ); @@ -112,4 +126,43 @@ export const api = { } } }, + + /** + * Update conversation title. + * @param {string} conversationId - The conversation ID + * @param {string} title - New title + */ + async updateConversationTitle(conversationId, title) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}/title`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title }), + } + ); + if (!response.ok) { + throw new Error('Failed to update conversation title'); + } + return response.json(); + }, + + /** + * Delete a conversation. + * @param {string} conversationId - The conversation ID + */ + async deleteConversation(conversationId) { + const response = await fetch( + `${API_BASE}/api/conversations/${conversationId}`, + { + method: 'DELETE', + } + ); + if (!response.ok) { + throw new Error('Failed to delete conversation'); + } + return response.json(); + }, }; diff --git a/frontend/src/components/ChatInterface.jsx b/frontend/src/components/ChatInterface.jsx index 3ae796caa..e80f5ba2d 100644 --- a/frontend/src/components/ChatInterface.jsx +++ b/frontend/src/components/ChatInterface.jsx @@ -79,7 +79,12 @@ export default function ChatInterface({ Running Stage 1: Collecting individual responses... )} - {msg.stage1 && } + {msg.stage1 && ( + + )} {/* Stage 2 */} {msg.loading?.stage2 && ( @@ -103,7 +108,12 @@ export default function ChatInterface({ Running Stage 3: Final synthesis... )} - {msg.stage3 && } + {msg.stage3 && ( + + )} )} @@ -120,26 +130,28 @@ export default function ChatInterface({
- {conversation.messages.length === 0 && ( -
-