├── backend ├── runtime.txt ├── src │ └── chatbot │ │ ├── __init__.py │ │ ├── utils │ │ ├── __init__.py │ │ ├── prompts.py │ │ └── chat_helpers.py │ │ ├── crews │ │ └── research_crew │ │ │ ├── __init__.py │ │ │ ├── config │ │ │ ├── agents.yaml │ │ │ └── tasks.yaml │ │ │ └── research_crew.py │ │ ├── tools │ │ ├── __init__.py │ │ ├── exa_answer_tool.py │ │ └── exa_search_tool.py │ │ ├── listeners │ │ ├── __init__.py │ │ └── real_time_listener.py │ │ ├── main.py │ │ ├── auth.py │ │ └── ag_ui_server.py ├── requirements.txt ├── .gitignore ├── Pipfile ├── example.env ├── generate_secret.py ├── pyproject.toml ├── Dockerfile ├── run_server.py ├── generate_example_credentials.py ├── debug_railway.py └── ARCHITECTURE.md ├── frontend ├── example.env.local ├── src │ ├── app │ │ ├── favicon.ico │ │ ├── page.tsx │ │ ├── api │ │ │ └── chat │ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── globals.css │ ├── contexts │ │ └── TokenContext.tsx │ └── components │ │ ├── ExecutionTracker.tsx │ │ ├── FlowStateDisplay.tsx │ │ ├── ResearchResults.tsx │ │ └── ChatInterface.tsx ├── postcss.config.mjs ├── public │ ├── vercel.svg │ ├── window.svg │ ├── file.svg │ ├── globe.svg │ └── next.svg ├── next.config.ts ├── eslint.config.mjs ├── .gitignore ├── tsconfig.json ├── package.json └── tailwind.config.ts ├── package.json ├── LICENSE └── README.md /backend/runtime.txt: -------------------------------------------------------------------------------- 1 | 3.10 -------------------------------------------------------------------------------- /backend/src/chatbot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/chatbot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/chatbot/crews/research_crew/__init__.py: -------------------------------------------------------------------------------- 1 | """Research crew.""" 2 | -------------------------------------------------------------------------------- /frontend/example.env.local: -------------------------------------------------------------------------------- 1 | # Backend URL for API calls 2 | NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Folken2/ag-ui-crewai-research/HEAD/backend/requirements.txt -------------------------------------------------------------------------------- /frontend/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Folken2/ag-ui-crewai-research/HEAD/frontend/src/app/favicon.ico -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | } 4 | 5 | export default config 6 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | __pycache__/ 3 | lib/ 4 | .DS_Store 5 | .venv/ 6 | venv/ 7 | generate_production_credentials.py 8 | debug_railway.py -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/chatbot/tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .exa_search_tool import ExaSearchTool 2 | from .exa_answer_tool import ExaAnswerTool 3 | 4 | __all__ = ["ExaSearchTool", "ExaAnswerTool"] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@copilotkit/react-core": "^1.9.1", 4 | "@copilotkit/react-ui": "^1.9.1", 5 | "@copilotkit/runtime": "^1.9.1" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/chatbot/listeners/__init__.py: -------------------------------------------------------------------------------- 1 | # Import all listeners to ensure they are registered 2 | from .real_time_listener import real_time_listener 3 | 4 | # Export listeners for external access if needed 5 | __all__ = ['real_time_listener'] -------------------------------------------------------------------------------- /frontend/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | async rewrites() { 5 | return [ 6 | { 7 | source: '/api/:path*', 8 | destination: process.env.BACKEND_URL 9 | ? `${process.env.BACKEND_URL}/api/:path*` 10 | : 'http://localhost:8000/api/:path*', 11 | }, 12 | ]; 13 | }, 14 | }; 15 | 16 | export default nextConfig; 17 | -------------------------------------------------------------------------------- /backend/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | crewai = {extras = ["tools"], version = ">=0.134.0,<1.0.0"} 8 | crewai-tools = ">=0.8.0" 9 | litellm = ">=1.0.0" 10 | fastapi = ">=0.104.0" 11 | uvicorn = ">=0.24.0" 12 | pydantic = ">=2.0.0" 13 | python-multipart = ">=0.0.6" 14 | ag-ui = ">=0.1.0" 15 | 16 | [dev-packages] 17 | 18 | [requires] 19 | python_version = "3.10" -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /backend/example.env: -------------------------------------------------------------------------------- 1 | # Authentication Configuration 2 | SECRET_KEY=your-jwt-key 3 | ALGORITHM=HS256 4 | ACCESS_TOKEN_EXPIRE_MINUTES= # Leave empty for permanent tokens (recommended for Railway) 5 | 6 | 7 | # OpenRouter Configuration (for LiteLLM) 8 | OPENROUTER_API_KEY=your-openrouter-api-key-here 9 | OPENROUTER_MODEL=openai/gpt-4o-mini # Available models at: www.openrouter.ai/models 10 | OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 11 | 12 | # EXA Search Configuration 13 | EXA_API_KEY=your-exa-api-key-here 14 | 15 | # Serper Configuration (for Google Search) 16 | SERPER_API_KEY=your-serper-api-key-here -------------------------------------------------------------------------------- /backend/generate_secret.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Generate a secure secret key for JWT authentication 4 | Run this script to generate a new secret key for your .env file 5 | """ 6 | 7 | import os 8 | import binascii 9 | 10 | def generate_secret_key(): 11 | """Generate a secure random secret key""" 12 | return binascii.hexlify(os.urandom(32)).decode() 13 | 14 | if __name__ == "__main__": 15 | secret_key = generate_secret_key() 16 | print("Generated Secret Key:") 17 | print(f"SECRET_KEY={secret_key}") 18 | print("\nAdd this to your .env file!") 19 | print("Make sure to keep this secret and never commit it to version control.") 20 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /frontend/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ChatInterface } from "@/components/ChatInterface"; 4 | import { useToken } from "@/contexts/TokenContext"; 5 | 6 | export default function Home() { 7 | const { isLoading } = useToken(); 8 | 9 | if (isLoading) { 10 | return ( 11 |
12 |
13 |
14 | Initializing... 15 |
16 |
17 | ); 18 | } 19 | 20 | return ; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "chat" 3 | version = "0.1.0" 4 | description = "Perplexity-like search chatbot using CrewAI" 5 | authors = [{ name = "Your Name", email = "you@example.com" }] 6 | requires-python = ">=3.10,<3.12" 7 | dependencies = [ 8 | "crewai[tools]>=0.134.0,<1.0.0", 9 | "crewai-tools>=0.8.0", 10 | "litellm>=1.0.0", 11 | "asyncio", 12 | "pydantic>=2.0.0", 13 | ] 14 | 15 | [project.scripts] 16 | kickoff = "chatbot.main:kickoff" 17 | run_crew = "chatbot.main:kickoff" 18 | plot = "chatbot.main:plot" 19 | 20 | [build-system] 21 | requires = ["hatchling"] 22 | build-backend = "hatchling.build" 23 | 24 | [tool.hatch.build.targets.wheel] 25 | packages = ["src/chatbot"] 26 | 27 | [tool.crewai] 28 | type = "flow" 29 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM python:3.11-slim 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Install system dependencies 8 | RUN apt-get update && apt-get install -y --no-install-recommends \ 9 | gcc \ 10 | curl \ 11 | && rm -rf /var/lib/apt/lists/* 12 | 13 | # Copy requirements first for better caching 14 | COPY requirements.txt . 15 | 16 | # Install Python dependencies 17 | RUN pip install --no-cache-dir -r requirements.txt 18 | 19 | # Copy the rest of the backend code 20 | COPY . . 21 | 22 | # Expose port (Railway will use $PORT env var by default) 23 | EXPOSE 8000 24 | 25 | # Health check 26 | HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ 27 | CMD curl -f http://localhost:8000/health || exit 1 28 | 29 | # Run the server 30 | CMD ["python", "run_server.py"] 31 | -------------------------------------------------------------------------------- /backend/src/chatbot/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Main module for the CrewAI chatbot flow 4 | Contains the ChatState class and core flow logic 5 | """ 6 | 7 | from pydantic import BaseModel 8 | from typing import List, Dict, Any, Optional 9 | 10 | 11 | class ChatState(BaseModel): 12 | """State management for the chatbot session.""" 13 | 14 | # Current session state 15 | current_input: str = "" 16 | session_ended: bool = False 17 | 18 | # Conversation history 19 | conversation_history: List[Dict[str, Any]] = [] 20 | 21 | # Research results 22 | research_results: Optional[Dict[str, Any]] = None 23 | has_new_research: bool = False 24 | 25 | # Processing state 26 | is_processing: bool = False 27 | 28 | # Event tracking 29 | last_event_update: Optional[str] = None 30 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@tailwindcss/typography": "^0.5.16", 13 | "@vercel/analytics": "^1.5.0", 14 | "autoprefixer": "^10.4.21", 15 | "next": "15.3.8", 16 | "react": "^19.0.0", 17 | "react-dom": "^19.0.0", 18 | "react-markdown": "^10.1.0", 19 | "rehype-raw": "^7.0.0", 20 | "remark-gfm": "^4.0.1" 21 | }, 22 | "devDependencies": { 23 | "@eslint/eslintrc": "^3", 24 | "@tailwindcss/postcss": "^4", 25 | "@types/node": "^20", 26 | "@types/react": "^19", 27 | "@types/react-dom": "^19", 28 | "eslint": "^9", 29 | "eslint-config-next": "15.3.6", 30 | "tailwindcss": "^4", 31 | "typescript": "^5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/run_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Launcher script for the CrewAI chatbot server 4 | This script properly sets up the Python path and runs the server 5 | """ 6 | 7 | import sys 8 | import os 9 | from pathlib import Path 10 | 11 | # Add the src directory to Python path 12 | backend_dir = Path(__file__).parent 13 | src_dir = backend_dir / "src" 14 | sys.path.insert(0, str(src_dir)) 15 | 16 | # Now we can import the server 17 | from chatbot.ag_ui_server import app 18 | 19 | if __name__ == "__main__": 20 | import uvicorn 21 | print("Starting CrewAI Chatbot Server...") 22 | print("Server will be available at: http://localhost:8000") 23 | print("Health check: http://localhost:8000/health") 24 | print("Press Ctrl+C to stop the server") 25 | 26 | uvicorn.run( 27 | "chatbot.ag_ui_server:app", # Use import string for reload 28 | host="0.0.0.0", 29 | port=8000, 30 | reload=True, 31 | log_level="info" 32 | ) -------------------------------------------------------------------------------- /frontend/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ResearchCrew 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /backend/src/chatbot/crews/research_crew/config/agents.yaml: -------------------------------------------------------------------------------- 1 | researcher_agent: 2 | role: > 3 | Expert Research Data Extractor 4 | goal: > 5 | Use search tools to find accurate, up-to-date information and return it in a clean, 6 | structured format with summary, sources (including image URLs), and citations. 7 | Return ONLY structured data - no explanations, thoughts, or meta-commentary. 8 | 9 | backstory: > 10 | You are a highly efficient research specialist who excels at extracting and organizing 11 | information from search results. Your superpower is turning raw search data into clean, 12 | structured outputs without any unnecessary commentary. You understand that your job is 13 | to collect facts and sources, not to write essays or explain your process. You're meticulous 14 | about data quality - every source object you create has all required fields (url, title, 15 | image_url, snippet), using null or empty strings when data is unavailable. You never mix 16 | your reasoning into the output structure. You're fast, precise, and focused on delivering 17 | clean data that downstream systems can process reliably. 18 | -------------------------------------------------------------------------------- /frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | export async function POST(request: NextRequest) { 4 | try { 5 | const body = await request.json(); 6 | 7 | // Get backend URL from environment or use localhost for development 8 | let backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; 9 | // Remove trailing slash to prevent double slashes 10 | backendUrl = backendUrl.replace(/\/$/, ''); 11 | 12 | // Get authorization header from the request 13 | const authHeader = request.headers.get('authorization'); 14 | 15 | // Prepare headers for backend request 16 | const headers: Record = { 17 | 'Content-Type': 'application/json', 18 | }; 19 | 20 | // Add authorization header if present 21 | if (authHeader) { 22 | headers['Authorization'] = authHeader; 23 | } 24 | 25 | // Forward the request to the backend 26 | const response = await fetch(`${backendUrl}/agent`, { 27 | method: 'POST', 28 | headers, 29 | body: JSON.stringify(body), 30 | }); 31 | 32 | if (!response.ok) { 33 | throw new Error(`Backend responded with status: ${response.status}`); 34 | } 35 | 36 | // Return the response as a stream 37 | const { readable, writable } = new TransformStream(); 38 | 39 | response.body?.pipeTo(writable); 40 | 41 | return new NextResponse(readable, { 42 | headers: { 43 | 'Content-Type': 'text/plain; charset=utf-8', 44 | 'Cache-Control': 'no-cache', 45 | 'Connection': 'keep-alive', 46 | }, 47 | }); 48 | } catch (error) { 49 | console.error('Error proxying to backend:', error); 50 | return NextResponse.json( 51 | { error: 'Failed to connect to backend' }, 52 | { status: 500 } 53 | ); 54 | } 55 | } -------------------------------------------------------------------------------- /frontend/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter, Geist, Geist_Mono } from "next/font/google"; 3 | import { Analytics } from "@vercel/analytics/next"; 4 | import { TokenProvider } from "../contexts/TokenContext"; 5 | import "./globals.css"; 6 | 7 | const inter = Inter({ 8 | subsets: ["latin"], 9 | variable: "--font-inter", 10 | display: "swap", 11 | }); 12 | 13 | const geistSans = Geist({ 14 | variable: "--font-geist-sans", 15 | subsets: ["latin"], 16 | display: "swap", 17 | }); 18 | 19 | const geistMono = Geist_Mono({ 20 | variable: "--font-geist-mono", 21 | subsets: ["latin"], 22 | display: "swap", 23 | }); 24 | 25 | export const metadata: Metadata = { 26 | title: "AI Research Assistant | CrewAI Powered", 27 | description: "Advanced AI-powered research assistant with real-time web search, in-depth analysis, and intelligent insights. Built with CrewAI and the AG-UI Protocol.", 28 | keywords: ["AI", "research", "assistant", "CrewAI", "artificial intelligence", "web search", "analysis"], 29 | authors: [{ name: "AI Research Team" }], 30 | viewport: "width=device-width, initial-scale=1", 31 | themeColor: [ 32 | { media: "(prefers-color-scheme: light)", color: "#3b82f6" }, 33 | { media: "(prefers-color-scheme: dark)", color: "#8b5cf6" }, 34 | ], 35 | }; 36 | 37 | export default function RootLayout({ 38 | children, 39 | }: Readonly<{ 40 | children: React.ReactNode; 41 | }>) { 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | {children} 52 |
53 |
54 | 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /backend/generate_example_credentials.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Generate example production credentials for Railway deployment 4 | This creates secure credentials that you can customize 5 | """ 6 | 7 | import os 8 | import binascii 9 | 10 | def main(): 11 | print("🔐 Production Credentials Generator") 12 | print("=" * 50) 13 | 14 | # Generate secret key 15 | secret_key = binascii.hexlify(os.urandom(32)).decode() 16 | 17 | # Use the existing hash from auth.py (password: secret) 18 | # You can generate new hashes using the auth system once deployed 19 | existing_hash = "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW" 20 | 21 | print("\n📋 RAILWAY ENVIRONMENT VARIABLES:") 22 | print("=" * 50) 23 | print(f"SECRET_KEY={secret_key}") 24 | print(f"ALGORITHM=HS256") 25 | print(f"ADMIN_USERNAME=admin") 26 | print(f"ADMIN_PASSWORD_HASH={existing_hash}") 27 | print(f"ADMIN_EMAIL=admin@yourdomain.com") 28 | print(f"ADMIN_FULL_NAME=Administrator") 29 | print(f"USER_USERNAME=user") 30 | print(f"USER_PASSWORD_HASH={existing_hash}") 31 | print(f"USER_EMAIL=user@yourdomain.com") 32 | print(f"USER_FULL_NAME=Regular User") 33 | print("=" * 50) 34 | 35 | print("\n🔑 LOGIN CREDENTIALS:") 36 | print("Admin: username=admin, password=secret") 37 | print("User: username=user, password=secret") 38 | 39 | print("\n✅ Copy these variables to your Railway environment!") 40 | print("🔒 Your secret key is now secure and not visible in code!") 41 | print("\n💡 TIP: After deployment, you can generate new password hashes using the API!") 42 | print("\n🔄 TO GENERATE NEW PASSWORDS:") 43 | print("1. Deploy to Railway with these credentials") 44 | print("2. Use the /token endpoint to login") 45 | print("3. Generate new password hashes using Python:") 46 | print(" from passlib.context import CryptContext") 47 | print(" pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')") 48 | print(" new_hash = pwd_context.hash('your-new-password')") 49 | 50 | if __name__ == "__main__": 51 | main() -------------------------------------------------------------------------------- /backend/src/chatbot/tools/exa_answer_tool.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Type, Optional 3 | from crewai.tools import BaseTool 4 | from pydantic import BaseModel, Field 5 | from exa_py import Exa 6 | 7 | class ExaAnswerInput(BaseModel): 8 | """Input schema for ExaAnswerTool.""" 9 | query: str = Field(..., description="The question to get a direct answer for. Best for specific, factual questions.") 10 | include_text: Optional[bool] = Field(default=True, description="Whether to include full text content in the response.") 11 | 12 | class ExaAnswerTool(BaseTool): 13 | name: str = "exa_answer" 14 | description: str = "Get a direct, LLM-generated answer to a question informed by Exa search results. Perfect for specific, factual questions that need quick answers." 15 | args_schema: Type[BaseModel] = ExaAnswerInput 16 | 17 | def __init__(self): 18 | super().__init__() 19 | # Initialize EXA client with API key from environment 20 | api_key = os.getenv("EXA_API_KEY") 21 | if not api_key: 22 | raise ValueError("EXA_API_KEY environment variable is required for ExaAnswerTool") 23 | # Use object.__setattr__ to set attributes on Pydantic models 24 | object.__setattr__(self, 'exa_client', Exa(api_key=api_key)) 25 | 26 | def _run(self, query: str, include_text: bool = True) -> str: 27 | """ 28 | Get a direct answer to a question using EXA's answer endpoint. 29 | 30 | Args: 31 | query: The question to answer 32 | include_text: Whether to include full text content 33 | 34 | Returns: 35 | Formatted answer with citations 36 | """ 37 | try: 38 | # Use EXA's answer endpoint for direct answers 39 | answer_response = self.exa_client.answer( 40 | query=query, 41 | text=include_text 42 | ) 43 | 44 | # Format the response 45 | if not answer_response: 46 | return f"No answer found for query: '{query}'" 47 | 48 | # Build the formatted response 49 | formatted_response = f"Answer: {answer_response.answer}\n\n" 50 | 51 | # Add citations if available 52 | if hasattr(answer_response, 'citations') and answer_response.citations: 53 | formatted_response += "Sources:\n" 54 | for i, citation in enumerate(answer_response.citations, 1): 55 | formatted_response += f"{i}. {citation.title}\n" 56 | formatted_response += f" URL: {citation.url}\n" 57 | if hasattr(citation, 'author') and citation.author: 58 | formatted_response += f" Author: {citation.author}\n" 59 | if hasattr(citation, 'publishedDate') and citation.publishedDate: 60 | formatted_response += f" Published: {citation.publishedDate}\n" 61 | if hasattr(citation, 'text') and citation.text and include_text: 62 | # Truncate text if too long 63 | text_preview = citation.text[:200] + "..." if len(citation.text) > 200 else citation.text 64 | formatted_response += f" Preview: {text_preview}\n" 65 | formatted_response += "\n" 66 | 67 | return formatted_response 68 | 69 | except Exception as e: 70 | return f"Error getting answer: {str(e)}" 71 | -------------------------------------------------------------------------------- /frontend/src/contexts/TokenContext.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { createContext, useContext, useState, useEffect, ReactNode } from 'react' 4 | 5 | interface TokenContextType { 6 | token: string | null 7 | isLoading: boolean 8 | refreshToken: () => Promise 9 | } 10 | 11 | const TokenContext = createContext(undefined) 12 | 13 | export function TokenProvider({ children }: { children: ReactNode }) { 14 | const [token, setToken] = useState(null) 15 | const [isLoading, setIsLoading] = useState(true) 16 | 17 | const refreshToken = async () => { 18 | try { 19 | // Use environment variable for backend URL 20 | let backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000' 21 | // Remove trailing slash to prevent double slashes 22 | backendUrl = backendUrl.replace(/\/$/, '') 23 | 24 | const response = await fetch(`${backendUrl}/token`, { 25 | method: 'POST', 26 | headers: { 27 | 'Content-Type': 'application/x-www-form-urlencoded', 28 | }, 29 | body: new URLSearchParams({ 30 | username: 'admin', // Use admin credentials automatically 31 | password: 'secret', 32 | }), 33 | }) 34 | 35 | if (response.ok) { 36 | const data = await response.json() 37 | const { access_token } = data 38 | 39 | // Store token in localStorage for persistence 40 | localStorage.setItem('backend_token', access_token) 41 | setToken(access_token) 42 | } else { 43 | console.error('Failed to get token from backend') 44 | setToken(null) 45 | } 46 | } catch (error) { 47 | console.error('Error getting token:', error) 48 | setToken(null) 49 | } 50 | } 51 | 52 | // Get token on mount 53 | useEffect(() => { 54 | const getToken = async () => { 55 | // First try to get existing token from localStorage 56 | const storedToken = localStorage.getItem('backend_token') 57 | 58 | if (storedToken) { 59 | // Verify token is still valid 60 | try { 61 | let backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000' 62 | // Remove trailing slash to prevent double slashes 63 | backendUrl = backendUrl.replace(/\/$/, '') 64 | 65 | const response = await fetch(`${backendUrl}/users/me`, { 66 | headers: { 67 | 'Authorization': `Bearer ${storedToken}`, 68 | }, 69 | }) 70 | 71 | if (response.ok) { 72 | setToken(storedToken) 73 | setIsLoading(false) 74 | return 75 | } 76 | } catch (error) { 77 | console.error('Token verification failed:', error) 78 | } 79 | } 80 | 81 | // If no valid token, get a new one 82 | await refreshToken() 83 | setIsLoading(false) 84 | } 85 | 86 | getToken() 87 | }, []) 88 | 89 | const value: TokenContextType = { 90 | token, 91 | isLoading, 92 | refreshToken, 93 | } 94 | 95 | return ( 96 | 97 | {children} 98 | 99 | ) 100 | } 101 | 102 | export function useToken() { 103 | const context = useContext(TokenContext) 104 | if (context === undefined) { 105 | throw new Error('useToken must be used within a TokenProvider') 106 | } 107 | return context 108 | } 109 | -------------------------------------------------------------------------------- /backend/src/chatbot/crews/research_crew/research_crew.py: -------------------------------------------------------------------------------- 1 | from crewai import Agent, Crew, Process, Task 2 | from crewai.project import CrewBase, agent, crew, task 3 | from crewai.agents.agent_builder.base_agent import BaseAgent 4 | from pydantic import BaseModel, Field 5 | from typing import List, Optional, Union 6 | from crewai_tools import SerperDevTool 7 | from ...tools import ExaSearchTool, ExaAnswerTool 8 | from langchain_openai import ChatOpenAI 9 | from decouple import config 10 | import os 11 | 12 | # Import listeners to register event handlers 13 | from ...listeners import real_time_listener 14 | 15 | 16 | ## TOOLS 17 | # Configure SerperDevTool with both search and news modes for comprehensive results 18 | serper_search_tool = SerperDevTool(search_type="search") 19 | serper_news_tool = SerperDevTool(search_type="news") 20 | # Configure EXA tools for enhanced web search and direct answers 21 | exa_search_tool = ExaSearchTool() 22 | exa_answer_tool = ExaAnswerTool() 23 | 24 | ## LLM CONFIGURATION 25 | # Configure OpenRouter LLM 26 | def get_openrouter_llm(): 27 | """Get OpenRouter LLM configuration""" 28 | api_key = config("OPENROUTER_API_KEY", default="") 29 | model = config("OPENROUTER_MODEL", default="openai/gpt-4o-mini") 30 | base_url = config("OPENROUTER_BASE_URL", default="https://openrouter.ai/api/v1") 31 | 32 | if not api_key: 33 | raise ValueError("OPENROUTER_API_KEY environment variable is required") 34 | 35 | # Add openrouter/ prefix for LiteLLM compatibility 36 | litemllm_model = f"openrouter/{model}" 37 | 38 | return ChatOpenAI( 39 | model=litemllm_model, 40 | api_key=api_key, 41 | base_url=base_url, 42 | temperature=0.1, 43 | ) 44 | 45 | # Initialize the LLM 46 | llm = get_openrouter_llm() 47 | 48 | ## PYDANTIC MODELS 49 | class SourceInfo(BaseModel): 50 | url: str = Field(description="The source URL") 51 | title: Optional[str] = Field(default=None, description="The title of the source") 52 | image_url: Optional[str] = Field(default=None, description="Image URL associated with the source") 53 | snippet: Optional[str] = Field(default=None, description="Brief snippet or description") 54 | 55 | class ResearchResult(BaseModel): 56 | summary: Optional[str] = Field(default=None, description="A concise summary of the research result") 57 | sources: Optional[List[Union[SourceInfo, str]]] = Field(default=None, description="A list of sources with metadata including images") 58 | citations: Optional[List[str]] = Field(default=None, description="A list of citations for the sources used") 59 | 60 | 61 | 62 | 63 | @CrewBase 64 | class ResearchCrew: 65 | """Research Crew""" 66 | 67 | agents: List[BaseAgent] 68 | tasks: List[Task] 69 | 70 | agents_config = "config/agents.yaml" 71 | tasks_config = "config/tasks.yaml" 72 | 73 | 74 | @agent 75 | def researcher_agent(self) -> Agent: 76 | return Agent( 77 | config=self.agents_config["researcher_agent"], 78 | tools=[exa_search_tool, exa_answer_tool], 79 | llm=llm, # Use OpenRouter LLM 80 | verbose=True, 81 | inject_date=True, 82 | ) 83 | 84 | @task 85 | def research_task(self) -> Task: 86 | return Task( 87 | config=self.tasks_config["research_task"], 88 | output_pydantic=ResearchResult, 89 | ) 90 | 91 | @crew 92 | def crew(self) -> Crew: 93 | """Creates the Research Crew""" 94 | return Crew( 95 | agents=self.agents, 96 | tasks=self.tasks, 97 | process=Process.sequential, 98 | verbose=True, 99 | usage_metrics={}, 100 | llm=llm # Use OpenRouter LLM for the crew 101 | ) 102 | -------------------------------------------------------------------------------- /backend/src/chatbot/crews/research_crew/config/tasks.yaml: -------------------------------------------------------------------------------- 1 | research_task: 2 | description: > 3 | Research the user's query: {query} 4 | 5 | ## Step 1: Choose the Right Tool 6 | 7 | **ExaAnswerTool (exa_answer)**: For specific, factual questions 8 | - Example: "What is the capital of France?", "Who won the last World Cup?" 9 | - Returns: Direct answer with citations (formatted text) 10 | 11 | **ExaSearchTool (exa_web_search)**: For complex research or open-ended questions 12 | - Example: "Latest AI developments", "How to cook eggs in air fryer?" 13 | - Returns: JSON string with search results already formatted as SourceInfo objects 14 | 15 | When in doubt, use ExaSearchTool for comprehensive coverage. 16 | 17 | ## Step 2: Parse the Tool Results 18 | 19 | **ExaSearchTool returns JSON** in this format: 20 | ```json 21 | { 22 | "query": "...", 23 | "num_results": 5, 24 | "results": [ 25 | { 26 | "url": "https://...", 27 | "title": "...", 28 | "image_url": null, 29 | "snippet": "..." 30 | } 31 | ] 32 | } 33 | ``` 34 | 35 | The "results" array is already in SourceInfo format - you can use it directly! 36 | Extract the key information from snippets to write your summary. 37 | 38 | ## Step 3: Create Your Output 39 | 40 | Write a 2-3 paragraph summary based on the search result snippets. 41 | Use the "results" array from the tool output as your "sources" field. 42 | DO NOT write explanatory text, thoughts, or meta-commentary. 43 | expected_output: > 44 | A ResearchResult object with three fields: summary, sources, and citations. 45 | 46 | CRITICAL OUTPUT FORMAT RULES: 47 | 48 | 1. DO NOT include your thinking process, reasoning, or explanations 49 | 2. DO NOT write "Thought:" or "I will..." statements 50 | 3. DO NOT write narrative text before the JSON structure 51 | 4. Return ONLY the structured data in the correct format 52 | 53 | ## Field Requirements: 54 | 55 | **summary** (string): 56 | A concise 2-3 paragraph summary of the key findings from search results. 57 | Write clear, factual sentences. No meta-commentary. 58 | 59 | **sources** (array of objects): 60 | Extract 5-8 most relevant sources from search results. 61 | Each source MUST be an object with ALL four fields: 62 | 63 | ``` 64 | { 65 | "url": "https://example.com", # required: source URL 66 | "title": "Article Title", # required: use "Unknown" if missing 67 | "image_url": null, # required: use null if no image 68 | "snippet": "Brief description..." # required: use "" if no snippet 69 | } 70 | ``` 71 | 72 | CRITICAL: Every source object MUST have all 4 fields. Use null or "" for missing data. 73 | 74 | **citations** (array of strings): 75 | List of source URLs that were directly cited in the summary. 76 | Example: ["https://example.com", "https://another.com"] 77 | 78 | ## What Good Output Looks Like: 79 | 80 | summary: "Microsoft announced Copilot Mode for Edge browser on October 24, 2025. The feature includes autonomous actions..." 81 | 82 | sources: [ 83 | { 84 | "url": "https://daily-ai.info/", 85 | "title": "Daily AI Insight", 86 | "image_url": null, 87 | "snippet": "Microsoft launches Copilot Mode in Edge..." 88 | } 89 | ] 90 | 91 | citations: ["https://daily-ai.info/"] 92 | 93 | ## What BAD Output Looks Like (DO NOT DO THIS): 94 | 95 | ❌ "Thought: I will now synthesize..." 96 | ❌ "Based on the search results, I found..." 97 | ❌ Writing explanation before the data structure 98 | ❌ Missing fields in source objects 99 | ❌ Empty image_url fields without null value 100 | agent: researcher_agent 101 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | border: 'hsl(var(--border))', 13 | input: 'hsl(var(--input))', 14 | ring: 'hsl(var(--ring))', 15 | background: 'hsl(var(--background))', 16 | foreground: 'hsl(var(--foreground))', 17 | primary: { 18 | DEFAULT: 'hsl(var(--primary))', 19 | foreground: 'hsl(var(--primary-foreground))', 20 | 50: '#fffdf7', 21 | 100: '#fef9e7', 22 | 200: '#fef0c7', 23 | 300: '#fde68a', 24 | 400: '#fcd34d', 25 | 500: '#f59e0b', 26 | 600: '#d97706', 27 | 700: '#b45309', 28 | 800: '#92400e', 29 | 900: '#78350f', 30 | }, 31 | secondary: { 32 | DEFAULT: 'hsl(var(--secondary))', 33 | foreground: 'hsl(var(--secondary-foreground))', 34 | 50: '#f9fafb', 35 | 100: '#f3f4f6', 36 | 200: '#e5e7eb', 37 | 300: '#d1d5db', 38 | 400: '#9ca3af', 39 | 500: '#6b7280', 40 | 600: '#4b5563', 41 | 700: '#374151', 42 | 800: '#1f2937', 43 | 900: '#111827', 44 | }, 45 | muted: { 46 | DEFAULT: 'hsl(var(--muted))', 47 | foreground: 'hsl(var(--muted-foreground))', 48 | }, 49 | accent: { 50 | DEFAULT: 'hsl(var(--accent))', 51 | foreground: 'hsl(var(--accent-foreground))', 52 | }, 53 | success: { 54 | DEFAULT: 'hsl(var(--success))', 55 | foreground: 'hsl(var(--success-foreground))', 56 | 50: '#f0fdf4', 57 | 100: '#dcfce7', 58 | 200: '#bbf7d0', 59 | 300: '#86efac', 60 | 400: '#4ade80', 61 | 500: '#22c55e', 62 | 600: '#16a34a', 63 | 700: '#15803d', 64 | 800: '#166534', 65 | 900: '#14532d', 66 | }, 67 | destructive: { 68 | DEFAULT: 'hsl(var(--destructive))', 69 | foreground: 'hsl(var(--destructive-foreground))', 70 | 50: '#fef2f2', 71 | 100: '#fee2e2', 72 | 200: '#fecaca', 73 | 300: '#fca5a5', 74 | 400: '#f87171', 75 | 500: '#ef4444', 76 | 600: '#dc2626', 77 | 700: '#b91c1c', 78 | 800: '#991b1b', 79 | 900: '#7f1d1d', 80 | }, 81 | amber: { 82 | 50: '#fffdf7', 83 | 100: '#fef9e7', 84 | 200: '#fef0c7', 85 | 300: '#fde68a', 86 | 400: '#fcd34d', 87 | 500: '#f59e0b', 88 | 600: '#d97706', 89 | 700: '#b45309', 90 | 800: '#92400e', 91 | 900: '#78350f', 92 | }, 93 | gray: { 94 | 50: '#f9fafb', 95 | 100: '#f3f4f6', 96 | 200: '#e5e7eb', 97 | 300: '#d1d5db', 98 | 400: '#9ca3af', 99 | 500: '#6b7280', 100 | 600: '#4b5563', 101 | 700: '#374151', 102 | 800: '#1f2937', 103 | 900: '#111827', 104 | } 105 | }, 106 | borderRadius: { 107 | lg: `var(--radius)`, 108 | md: `calc(var(--radius) - 2px)`, 109 | sm: 'calc(var(--radius) - 4px)', 110 | }, 111 | animation: { 112 | 'fade-in': 'fadeIn 0.5s ease-in-out', 113 | 'slide-up': 'slideUp 0.3s ease-out', 114 | }, 115 | keyframes: { 116 | fadeIn: { 117 | '0%': { opacity: '0', transform: 'translateY(10px)' }, 118 | '100%': { opacity: '1', transform: 'translateY(0)' }, 119 | }, 120 | slideUp: { 121 | '0%': { transform: 'translateY(100%)' }, 122 | '100%': { transform: 'translateY(0)' }, 123 | } 124 | } 125 | }, 126 | }, 127 | plugins: [ 128 | require('@tailwindcss/typography'), 129 | ], 130 | } 131 | 132 | export default config -------------------------------------------------------------------------------- /backend/debug_railway.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Railway Backend Debug Script 4 | Run this to test if your Railway backend is working correctly 5 | Usage: python debug_railway.py 6 | """ 7 | 8 | import requests 9 | import json 10 | import os 11 | import sys 12 | 13 | def test_backend(backend_url): 14 | if not backend_url: 15 | print("❌ No backend URL provided") 16 | return 17 | 18 | print(f"🔍 Testing backend at: {backend_url}") 19 | 20 | # Test 1: Health check 21 | print("\n1️⃣ Testing health endpoint...") 22 | try: 23 | response = requests.get(f"{backend_url}/health", timeout=10) 24 | print(f" Status: {response.status_code}") 25 | if response.status_code == 200: 26 | print(" ✅ Health check passed") 27 | else: 28 | print(f" ❌ Health check failed: {response.text}") 29 | except Exception as e: 30 | print(f" ❌ Health check error: {e}") 31 | 32 | # Test 2: Root endpoint 33 | print("\n2️⃣ Testing root endpoint...") 34 | try: 35 | response = requests.get(f"{backend_url}/", timeout=10) 36 | print(f" Status: {response.status_code}") 37 | print(f" Response: {response.text[:100]}...") 38 | except Exception as e: 39 | print(f" ❌ Root endpoint error: {e}") 40 | 41 | # Test 3: Token endpoint 42 | print("\n3️⃣ Testing token endpoint...") 43 | try: 44 | response = requests.post( 45 | f"{backend_url}/token", 46 | data={"username": "admin", "password": "secret"}, 47 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 48 | timeout=10 49 | ) 50 | print(f" Status: {response.status_code}") 51 | if response.status_code == 200: 52 | token_data = response.json() 53 | print(" ✅ Token generation successful") 54 | token = token_data.get("access_token") 55 | if token: 56 | print(f" Token: {token[:20]}...") 57 | return token 58 | else: 59 | print(f" ❌ Token generation failed: {response.text}") 60 | except Exception as e: 61 | print(f" ❌ Token endpoint error: {e}") 62 | 63 | return None 64 | 65 | def test_agent_endpoint(backend_url, token): 66 | if not token: 67 | print("\n❌ No token available, skipping agent test") 68 | return 69 | 70 | print("\n4️⃣ Testing agent endpoint...") 71 | try: 72 | response = requests.post( 73 | f"{backend_url}/agent", 74 | json={"messages": [{"content": "Hello, test message"}]}, 75 | headers={ 76 | "Content-Type": "application/json", 77 | "Authorization": f"Bearer {token}" 78 | }, 79 | timeout=30 80 | ) 81 | print(f" Status: {response.status_code}") 82 | if response.status_code == 200: 83 | print(" ✅ Agent endpoint working") 84 | else: 85 | print(f" ❌ Agent endpoint failed: {response.text}") 86 | except Exception as e: 87 | print(f" ❌ Agent endpoint error: {e}") 88 | 89 | if __name__ == "__main__": 90 | print("🚀 Railway Backend Debug Tool") 91 | print("=" * 50) 92 | 93 | if len(sys.argv) != 2: 94 | print("Usage: python debug_railway.py ") 95 | print("Example: python debug_railway.py https://ag-ui-crewai-research.railway.app") 96 | exit(1) 97 | 98 | backend_url = sys.argv[1].strip() 99 | if not backend_url: 100 | print("❌ No backend URL provided") 101 | exit(1) 102 | 103 | token = test_backend(backend_url) 104 | test_agent_endpoint(backend_url, token) 105 | 106 | print("\n" + "=" * 50) 107 | print("🔧 Troubleshooting Tips:") 108 | print("1. Check Railway logs for errors") 109 | print("2. Verify all environment variables are set") 110 | print("3. Ensure OPENROUTER_API_KEY is valid") 111 | print("4. Check if EXA_API_KEY and SERPER_API_KEY are set") 112 | print("5. Verify SECRET_KEY is set for JWT") 113 | -------------------------------------------------------------------------------- /backend/src/chatbot/utils/prompts.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | # ────────────────────── INJECTING CURRENT TIME ────────────────────── 4 | 5 | def inject_current_time(prompt: str) -> str: 6 | current_time = time.strftime("%Y-%m-%d %H:%M:%S") 7 | return prompt.format(current_time=current_time) 8 | 9 | # ────────────────────── UNIFIED SYSTEM PROMPT ────────────────────── 10 | 11 | UNIFIED_PROMPT = """ 12 | You are an intelligent and helpful AI assistant that can handle all types of user interactions. 13 | You have access to conversation history and can determine the best way to respond. 14 | You're professional, knowledgeable, and genuinely helpful. 15 | 16 | ## Your Personality: 17 | - Professional and knowledgeable 18 | - Helpful and clear in communication 19 | - Use natural, conversational language 20 | - Be encouraging and supportive when appropriate 21 | - Focus on providing accurate, useful information 22 | 23 | ## Your Capabilities: 24 | 1. **Chat**: Have helpful conversations and provide accurate information 25 | 2. **Search**: Help users find current information and research topics. Return "SEARCH" to execute the agent. 26 | 3. **Context Awareness**: Use conversation history for better, more relevant responses 27 | 4. **Multilingual**: You can understand and respond in multiple languages 28 | 5. **Follow-up Detection**: Understand when users are asking follow-up questions that need research 29 | 30 | ## Response Format: 31 | Your response must follow this exact format: 32 | 33 | INTENT: [SEARCH|CHAT|EXIT] 34 | EXPANDED_QUERY: [For SEARCH intent with follow-up questions, provide the complete expanded query. For other cases, repeat the user's message.] 35 | RESPONSE: [Your response to the user]. try to structure your response in a way that is easy to understand and follow. 36 | Usually using headers (H1, H2, H3) and bullet points and sub-points helps. 37 | 38 | ## Intent Guidelines: 39 | 40 | **SEARCH** - Use when user wants: 41 | - Current information or news (in any language) 42 | - Latest news, recent developments, or current events 43 | - Research on a topic 44 | - Factual data or statistics 45 | - Recent developments 46 | - Any request for current, real-time information 47 | - Questions about what's happening now 48 | - Requests for the latest updates 49 | - Follow-up questions that relate to previous research topics 50 | - Short questions that seem to continue a previous research conversation 51 | - Questions like "And in Europe?" after discussing tallest buildings 52 | - Questions like "What about..." or "How about..." that reference previous topics 53 | 54 | **CHAT** - Use when user wants: 55 | - General conversation 56 | - Anything that you can answer without the need to search or up-to-date information. 57 | 58 | **EXIT** - Use when user wants: 59 | - To end the session 60 | - Says goodbye, bye, exit, quit, stop 61 | - Wants to leave or finish 62 | 63 | ## Response Guidelines: 64 | 65 | **For SEARCH intent:** 66 | - return "SEARCH" to execute the agent. 67 | 68 | **For CHAT intent:** 69 | - Be helpful and informative 70 | - Use conversation history for context when relevant 71 | - If they need real-time data, suggest a search 72 | - Provide clear, accurate responses 73 | - Example: "That's an interesting question. Based on our conversation, I think... [thoughtful response]" 74 | 75 | **For EXIT intent:** 76 | - Provide a polite farewell 77 | - Thank them for the conversation 78 | - Example: "Thank you for the conversation. Have a great day!" 79 | 80 | ## CRITICAL: Follow-up Question Detection 81 | When analyzing the user's current message, pay special attention to: 82 | - **Questions that reference previous topics** from the conversation history 83 | - **Short questions** (like "at what temperature?", "And in Europe?", "What about...?") 84 | - **Questions that seem incomplete** without the conversation context 85 | 86 | **IMPORTANT**: When you detect a follow-up question that needs SEARCH intent, you should automatically expand the query in your mind based on the conversation history. For example: 87 | - If the user previously asked "how to cook an egg in airfryer" and now asks "at what temperature?", you should understand they want "what temperature to cook eggs in air fryer" 88 | - If the user previously asked about "tallest building" and now asks "And in Europe?", you should understand they want "tallest building in Europe" 89 | 90 | The search agent will receive the expanded, complete query automatically. 91 | 92 | 93 | ## Important Notes: 94 | - Use conversation history below for context 95 | - Keep responses clear and concise 96 | - If unsure, default to CHAT 97 | - Always be helpful and informative 98 | - Focus on providing accurate information 99 | - Understand intent naturally in any language - don't rely on specific keywords 100 | - Pay attention to conversation context - if the user asks a short follow-up question related to previous research, it's likely a SEARCH intent 101 | - The current time is {current_time} 102 | """ 103 | 104 | -------------------------------------------------------------------------------- /backend/src/chatbot/tools/exa_search_tool.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from typing import Type, List, Optional 4 | from crewai.tools import BaseTool 5 | from pydantic import BaseModel, Field 6 | from exa_py import Exa 7 | 8 | class ExaSearchInput(BaseModel): 9 | """Input schema for ExaSearchTool.""" 10 | query: str = Field(..., description="The search query to find relevant information on the web.") 11 | num_results: Optional[int] = Field(default=5, description="Number of search results to return (default: 5, max: 10).") 12 | 13 | class ExaSearchTool(BaseTool): 14 | name: str = "exa_web_search" 15 | description: str = "Search the web for current and relevant information using EXA search API. Useful for finding recent news, facts, and up-to-date information on any topic." 16 | args_schema: Type[BaseModel] = ExaSearchInput 17 | 18 | def __init__(self): 19 | super().__init__() 20 | # Initialize EXA client with API key from environment 21 | api_key = os.getenv("EXA_API_KEY") 22 | if not api_key: 23 | raise ValueError("EXA_API_KEY environment variable is required for ExaSearchTool") 24 | # Use object.__setattr__ to set attributes on Pydantic models 25 | object.__setattr__(self, 'exa_client', Exa(api_key=api_key)) 26 | 27 | def _run(self, query: str, num_results: int = 5) -> str: 28 | """ 29 | Execute a web search using EXA API with content extraction. 30 | 31 | Args: 32 | query: The search query 33 | num_results: Number of results to return (max 10) 34 | 35 | Returns: 36 | JSON string containing search results in SourceInfo format for easy parsing 37 | """ 38 | try: 39 | # Limit results to 10 for API efficiency 40 | num_results = min(num_results, 10) 41 | 42 | # Execute search with content extraction 43 | search_response = self.exa_client.search_and_contents( 44 | query=query, 45 | num_results=num_results, 46 | use_autoprompt=True, # Use EXA's autoprompt for better results 47 | type="auto", # Use auto search type - automatically chooses between keyword and neural 48 | # Extract content with highlights and summaries 49 | text=True, # Get full page text 50 | highlights={ 51 | "num_sentences": 3, # Get 3 sentences per highlight 52 | "highlights_per_url": 1, # Get 1 highlight per URL 53 | "query": "Key information and main points" # Custom query for highlights 54 | }, 55 | summary={ 56 | "query": "Main developments and key points" # Custom query for summary 57 | } 58 | ) 59 | 60 | # Format results to match SourceInfo model structure 61 | if not search_response.results: 62 | return json.dumps({ 63 | "query": query, 64 | "results": [], 65 | "message": "No search results found" 66 | }) 67 | 68 | # Build structured results array 69 | structured_results = [] 70 | for result in search_response.results: 71 | # Handle case where result might be a string or different object type 72 | if isinstance(result, str): 73 | # If result is a string, treat it as a URL 74 | source_info = { 75 | "url": result, 76 | "title": "Unknown", 77 | "image_url": None, 78 | "snippet": "" 79 | } 80 | else: 81 | # Get the best snippet from highlights, summary, or text 82 | snippet = "" 83 | if hasattr(result, 'summary') and result.summary: 84 | snippet = result.summary[:300] 85 | elif hasattr(result, 'highlights') and result.highlights: 86 | snippet = result.highlights[0] # Use first highlight 87 | elif hasattr(result, 'text') and result.text: 88 | snippet = result.text[:300] 89 | 90 | # Add ellipsis if truncated 91 | if snippet and len(snippet) >= 300: 92 | snippet = snippet + "..." 93 | 94 | # Try different possible attribute names for image 95 | image_url = None 96 | for attr in ['image', 'image_url', 'thumbnail', 'imageUrl']: 97 | if hasattr(result, attr): 98 | image_url = getattr(result, attr) 99 | if image_url: # Stop at first non-null value 100 | break 101 | 102 | # Format as SourceInfo structure with all required fields 103 | source_info = { 104 | "url": getattr(result, 'url', ''), 105 | "title": getattr(result, 'title', 'Unknown'), 106 | "image_url": image_url, # Will be None if no image found 107 | "snippet": snippet if snippet else "" 108 | } 109 | 110 | structured_results.append(source_info) 111 | 112 | # Return as JSON string for easy parsing by the agent 113 | return json.dumps({ 114 | "query": query, 115 | "num_results": len(structured_results), 116 | "results": structured_results 117 | }, indent=2) 118 | 119 | except Exception as e: 120 | return json.dumps({ 121 | "query": query, 122 | "error": str(e), 123 | "results": [] 124 | }) 125 | -------------------------------------------------------------------------------- /frontend/src/components/ExecutionTracker.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect } from "react" 4 | 5 | interface ExecutionEvent { 6 | type: string 7 | data: { 8 | message: string 9 | agent_role?: string 10 | tool_name?: string 11 | tool_query?: string 12 | model?: string 13 | query?: string 14 | status?: string 15 | crew_name?: string 16 | execution_time?: number 17 | token_usage?: { 18 | total_tokens?: number 19 | prompt_tokens?: number 20 | completion_tokens?: number 21 | } 22 | } 23 | timestamp: string 24 | agent_id?: string 25 | session_id?: string 26 | } 27 | 28 | interface ExecutionTrackerProps { 29 | events: ExecutionEvent[] 30 | isProcessing: boolean 31 | } 32 | 33 | export function ExecutionTracker({ events, isProcessing }: ExecutionTrackerProps) { 34 | const [visibleEvents, setVisibleEvents] = useState([]) 35 | 36 | useEffect(() => { 37 | // Add new events with a slight delay for animation 38 | events.forEach((event, index) => { 39 | setTimeout(() => { 40 | setVisibleEvents(prev => { 41 | // Avoid duplicates 42 | const exists = prev.some(e => 43 | e.timestamp === event.timestamp && 44 | e.type === event.type && 45 | e.data.message === event.data.message 46 | ) 47 | return exists ? prev : [...prev, event] 48 | }) 49 | }, index * 50) 50 | }) 51 | }, [events]) 52 | 53 | useEffect(() => { 54 | // Clear events when not processing 55 | if (!isProcessing) { 56 | setTimeout(() => { 57 | setVisibleEvents([]) 58 | }, 2000) // Keep events visible for 2 seconds after completion 59 | } 60 | }, [isProcessing]) 61 | 62 | if (!isProcessing && visibleEvents.length === 0) { 63 | return null 64 | } 65 | 66 | const formatEventMessage = (event: ExecutionEvent) => { 67 | const { data } = event 68 | let message = data.message 69 | 70 | // Handle tool usage events with the specific structure requested 71 | if (event.type === "TOOL_STARTED" && data.tool_query) { 72 | message = `🌐 Searching for: ${data.tool_query}` 73 | } else if (event.type === "TOOL_COMPLETED" && data.tool_query) { 74 | message = `Found results for: ${data.tool_query}` 75 | } else if (event.type === "TOOL_ERROR" && data.tool_query) { 76 | message = `Error searching for: ${data.tool_query}` 77 | } 78 | 79 | // Add additional context for certain events (but not for tool events) 80 | if (data.tool_name && data.query && !event.type.startsWith("TOOL_")) { 81 | message = `${message.split(' with query')[0]} with query: "${data.query}"` 82 | } 83 | 84 | if (data.execution_time) { 85 | message += ` (${data.execution_time.toFixed(2)}s)` 86 | } 87 | 88 | if (data.token_usage?.total_tokens) { 89 | message += ` • ${data.token_usage.total_tokens} tokens` 90 | } 91 | 92 | return message 93 | } 94 | 95 | return ( 96 |
97 |
98 | 🔄 Execution Status 99 |
100 | 101 |
102 |
103 | {visibleEvents.map((event, index) => ( 104 |
109 | {/* Loading spinner for STARTED events */} 110 | {event.type.includes("STARTED") && ( 111 |
112 |
113 |
114 | )} 115 | 116 |
117 |
118 | {formatEventMessage(event)} 119 |
120 | 121 | {/* Additional details for certain events */} 122 | {event.data.agent_role && ( 123 |
124 | Agent: {event.data.agent_role} 125 |
126 | )} 127 | 128 | {event.data.tool_name && !event.data.query && ( 129 |
130 | Tool: {event.data.tool_name} 131 |
132 | )} 133 | 134 | {event.data.model && ( 135 |
136 | Model: {event.data.model} 137 |
138 | )} 139 |
140 | 141 |
142 | {new Date(event.timestamp).toLocaleTimeString([], { 143 | hour: '2-digit', 144 | minute: '2-digit', 145 | second: '2-digit' 146 | })} 147 |
148 |
149 | ))} 150 | 151 | {isProcessing && ( 152 |
153 |
154 |
155 |
156 |
157 |
158 | Processing... 159 |
160 | )} 161 |
162 |
163 |
164 | ) 165 | } -------------------------------------------------------------------------------- /backend/src/chatbot/auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Token-based authentication for FastAPI backend 4 | Secure authentication using JWT tokens and environment variables 5 | """ 6 | 7 | import os 8 | from datetime import datetime, timedelta 9 | from typing import Optional, Dict, Any 10 | from fastapi import HTTPException, status, Depends 11 | from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm 12 | from jose import JWTError, jwt 13 | from passlib.context import CryptContext 14 | from pydantic import BaseModel 15 | from decouple import config 16 | 17 | # Configuration from environment variables 18 | SECRET_KEY = config("SECRET_KEY", default="your-secret-key-change-this-in-production") 19 | ALGORITHM = config("ALGORITHM", default="HS256") 20 | ACCESS_TOKEN_EXPIRE_MINUTES = config("ACCESS_TOKEN_EXPIRE_MINUTES", default="", cast=str) 21 | ACCESS_TOKEN_EXPIRE_MINUTES = int(ACCESS_TOKEN_EXPIRE_MINUTES) if ACCESS_TOKEN_EXPIRE_MINUTES else None 22 | 23 | # Password hashing 24 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 25 | 26 | # OAuth2 scheme 27 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 28 | 29 | # Pydantic models 30 | class Token(BaseModel): 31 | access_token: str 32 | token_type: str 33 | 34 | class TokenData(BaseModel): 35 | username: Optional[str] = None 36 | 37 | class User(BaseModel): 38 | username: str 39 | email: Optional[str] = None 40 | full_name: Optional[str] = None 41 | disabled: Optional[bool] = None 42 | 43 | class UserInDB(User): 44 | hashed_password: str 45 | 46 | # In-memory user database (replace with real database in production) 47 | # WARNING: Change these passwords in production! 48 | # For production, use environment variables for user credentials 49 | def get_users_from_env(): 50 | """Get users from environment variables for production security""" 51 | users = {} 52 | 53 | # Admin user from environment 54 | admin_username = config("ADMIN_USERNAME", default="admin") 55 | admin_password_hash = config("ADMIN_PASSWORD_HASH", default=None) 56 | admin_email = config("ADMIN_EMAIL", default="admin@example.com") 57 | admin_full_name = config("ADMIN_FULL_NAME", default="Administrator") 58 | 59 | if admin_password_hash: 60 | users[admin_username] = { 61 | "username": admin_username, 62 | "email": admin_email, 63 | "full_name": admin_full_name, 64 | "hashed_password": admin_password_hash, 65 | "disabled": False, 66 | } 67 | 68 | # Regular user from environment 69 | user_username = config("USER_USERNAME", default="user") 70 | user_password_hash = config("USER_PASSWORD_HASH", default=None) 71 | user_email = config("USER_EMAIL", default="user@example.com") 72 | user_full_name = config("USER_FULL_NAME", default="Regular User") 73 | 74 | if user_password_hash: 75 | users[user_username] = { 76 | "username": user_username, 77 | "email": user_email, 78 | "full_name": user_full_name, 79 | "hashed_password": user_password_hash, 80 | "disabled": False, 81 | } 82 | 83 | # Fallback to default users if no environment variables set 84 | if not users: 85 | users = { 86 | "admin": { 87 | "username": "admin", 88 | "email": "admin@example.com", 89 | "full_name": "Administrator", 90 | "hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW", # password: secret 91 | "disabled": False, 92 | }, 93 | "user": { 94 | "username": "user", 95 | "email": "user@example.com", 96 | "full_name": "Regular User", 97 | "hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW", # password: secret 98 | "disabled": False, 99 | } 100 | } 101 | 102 | return users 103 | 104 | # Get users from environment or fallback to defaults 105 | fake_users_db = get_users_from_env() 106 | 107 | def verify_password(plain_password: str, hashed_password: str) -> bool: 108 | """Verify a password against its hash""" 109 | # bcrypt passwords cannot be longer than 72 bytes 110 | return pwd_context.verify(plain_password[:72], hashed_password) 111 | 112 | def get_password_hash(password: str) -> str: 113 | """Hash a password""" 114 | # bcrypt passwords cannot be longer than 72 bytes 115 | return pwd_context.hash(password[:72]) 116 | 117 | def get_user(db: dict, username: str) -> Optional[UserInDB]: 118 | """Get user from database""" 119 | if username in db: 120 | user_dict = db[username] 121 | return UserInDB(**user_dict) 122 | return None 123 | 124 | def authenticate_user(fake_db: dict, username: str, password: str) -> Optional[UserInDB]: 125 | """Authenticate user with username and password""" 126 | user = get_user(fake_db, username) 127 | if not user: 128 | return None 129 | if not verify_password(password, user.hashed_password): 130 | return None 131 | return user 132 | 133 | def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: 134 | """Create JWT access token - no expiration if expires_delta is None""" 135 | to_encode = data.copy() 136 | if expires_delta is not None: 137 | expire = datetime.utcnow() + expires_delta 138 | to_encode.update({"exp": expire}) 139 | # If expires_delta is None, don't add exp claim = token never expires 140 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 141 | return encoded_jwt 142 | 143 | async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: 144 | """Get current authenticated user from token""" 145 | credentials_exception = HTTPException( 146 | status_code=status.HTTP_401_UNAUTHORIZED, 147 | detail="Could not validate credentials", 148 | headers={"WWW-Authenticate": "Bearer"}, 149 | ) 150 | try: 151 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 152 | username: str = payload.get("sub") 153 | if username is None: 154 | raise credentials_exception 155 | token_data = TokenData(username=username) 156 | except JWTError: 157 | raise credentials_exception 158 | 159 | user = get_user(fake_users_db, username=token_data.username) 160 | if user is None: 161 | raise credentials_exception 162 | return user 163 | 164 | async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: 165 | """Get current active user (not disabled)""" 166 | if current_user.disabled: 167 | raise HTTPException(status_code=400, detail="Inactive user") 168 | return current_user 169 | 170 | def generate_password_hash(password: str) -> str: 171 | """Generate password hash for new users""" 172 | return get_password_hash(password) 173 | 174 | # Utility function to create a new user (for admin purposes) 175 | def create_user(username: str, password: str, email: str = None, full_name: str = None) -> Dict[str, Any]: 176 | """Create a new user in the fake database""" 177 | hashed_password = get_password_hash(password) 178 | user_data = { 179 | "username": username, 180 | "email": email, 181 | "full_name": full_name, 182 | "hashed_password": hashed_password, 183 | "disabled": False, 184 | } 185 | fake_users_db[username] = user_data 186 | return user_data 187 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AG-UI CrewAI Real-Time Research Assistant 2 | 3 | A Perplexity-like AI research assistant built with **CrewAI**, **AG-UI Protocol**, and **Next.js**. Features real-time event streaming, web search capabilities, and a modern chat interface with source citations and images. 4 | 5 | ## Key Features 6 | 7 | - **Real-time Research**: Web search with EXA AI and SerperDev integration 8 | - **Live Event Streaming**: AG-UI protocol for real-time agent updates 9 | - **Source Citations**: Perplexity-style source cards with images 10 | - **Intent Detection**: Smart classification between search/chat/exit 11 | - **Modern UI**: Clean Next.js frontend with Tailwind CSS 12 | - **JWT Authentication**: Secure token-based authentication system 13 | - **Open Frontend**: No user login required - automatic token management 14 | 15 | ## Tech Stack 16 | 17 | - **Agentic framework**: CrewAI 18 | - **Protocol**: AG-UI for intelligent backend <-> frontend connection 19 | - **Real-time web search**: EXA AI + SerperDev 20 | - **LLM**: OpenRouter (via LiteLLM) - supports multiple models 21 | - **Authentication**: JWT tokens with bcrypt password hashing 22 | - **Frontend**: Next.js with automatic token management 23 | - **Backend**: FastAPI with protected endpoints 24 | 25 | ## Required API Keys 26 | 27 | You'll need to obtain these API keys: 28 | 29 | 1. **OpenRouter API Key** - For LLM responses (supports multiple models) 30 | - Sign up at [OpenRouter](https://openrouter.ai/) 31 | - Get your API key from the dashboard 32 | 33 | 2. **EXA AI API Key** - For advanced web search 34 | - Go to [EXA AI](https://exa.ai/) 35 | - Create a new API key 36 | 37 | 3. **SerperDev API Key** - For Google search functionality 38 | - Sign up at [serper.dev](https://serper.dev) 39 | - Get your API key from the dashboard 40 | 41 | 42 | ## Local Development Setup 43 | 44 | ### 1. Clone the Repository 45 | ```bash 46 | git clone https://github.com/Folken2/ag-ui-crewai-research.git 47 | cd ag-ui-crewai-research 48 | ``` 49 | 50 | ### 2. Backend Setup 51 | 52 | ```bash 53 | # Navigate to backend directory 54 | cd backend 55 | 56 | # Create and activate virtual environment 57 | python -m venv venv 58 | 59 | # On Windows: 60 | venv\Scripts\activate 61 | # On macOS/Linux: 62 | source venv/bin/activate 63 | 64 | # Install dependencies 65 | pip install -r requirements.txt 66 | ``` 67 | 68 | ### 3. Environment Configuration 69 | 70 | Create environment files for both backend and frontend: 71 | 72 | **Backend Environment** (`backend/.env`): 73 | ```bash 74 | # Copy the example file 75 | cp example.env .env 76 | 77 | # Edit .env with your API keys 78 | OPENROUTER_API_KEY=your-openrouter-api-key-here 79 | OPENROUTER_MODEL=google/gemini-2.5-flash 80 | EXA_API_KEY=your-exa-api-key-here 81 | SERPER_API_KEY=your-serper-api-key-here 82 | 83 | # Authentication (optional - uses defaults if not set) 84 | SECRET_KEY=your-secret-key-for-jwt 85 | ADMIN_USERNAME=admin 86 | ADMIN_PASSWORD_HASH=your-admin-password 87 | ``` 88 | 89 | **Frontend Environment** (`frontend/.env.local`): 90 | ```bash 91 | # Copy the example file 92 | cp example.env.local .env.local 93 | 94 | # Edit .env.local with your backend URL 95 | NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 96 | ``` 97 | 98 | ### 4. Frontend Setup 99 | 100 | ```bash 101 | # Navigate to frontend directory (from project root) 102 | cd frontend 103 | 104 | # Install dependencies 105 | npm install 106 | ``` 107 | 108 | ## Running the Application 109 | 110 | ### Start the Backend Server 111 | ```bash 112 | # From backend directory (with venv activated) 113 | cd backend 114 | source venv/bin/activate # On Windows: venv\Scripts\activate 115 | python run_server.py 116 | ``` 117 | 118 | The backend will start on `http://localhost:8000` 119 | 120 | **Note**: The backend includes JWT authentication. The frontend will automatically get tokens using default credentials (`admin`/`secret`). 121 | 122 | ### Start the Frontend (in a new terminal) 123 | ```bash 124 | # From frontend directory 125 | cd frontend 126 | npm run dev 127 | ``` 128 | 129 | The frontend will start on `http://localhost:3000` 130 | 131 | ### Access the Application 132 | Open your browser and go to `http://localhost:3000` 133 | 134 | ## How It Works 135 | 136 | ### Authentication Flow 137 | 1. **Frontend starts** → Automatically requests JWT token from backend 138 | 2. **Backend validates** → Returns permanent token (no expiration) 139 | 3. **All requests** → Include `Authorization: Bearer ` header 140 | 4. **Backend protects** → All endpoints require valid JWT token 141 | 142 | ### Research Flow 143 | 1. **User Input** → Intent detection via OpenRouter LLM 144 | 2. **Search Intent** → Research crew activation with EXA/SerperDev 145 | 3. **Real-time Events** → Live agent updates via AG-UI protocol 146 | 4. **Results** → Formatted response with sources and images 147 | 148 | ### UI Features 149 | - **Real-time streaming** with source cards and images 150 | - **Perplexity-style layout** with domain extraction 151 | - **Live agent status** and execution tracking 152 | - **Automatic authentication** - no user interaction needed 153 | 154 | 155 | ## Development 156 | 157 | ### Available Models 158 | You can change the LLM model by updating `OPENROUTER_MODEL` in your `.env` file: 159 | 160 | See all available models at [OpenRouter Models](https://openrouter.ai/models) 161 | 162 | ### Development Tips 163 | - **Adding Tools**: Create in `backend/src/chatbot/tools/` and register in research crew 164 | - **Customizing Events**: Modify `real_time_listener.py` and update frontend handlers 165 | - **Authentication**: Default credentials are `admin`/`secret` - change in `.env` for production 166 | - **API Keys**: All sensitive keys are managed via environment variables 167 | - **Generate Secrets**: Use `python generate_secret.py` to create secure JWT secret keys 168 | - **Production Setup**: Use `python generate_example_credentials.py` for deployment credentials 169 | 170 | 171 | ## Adding More Crews 172 | 173 | ### Create a New Crew 174 | ```bash 175 | # Create crew directory structure 176 | mkdir -p backend/src/chatbot/crews/analysis_crew/config 177 | ``` 178 | 179 | ### Example Crew Structure 180 | ``` 181 | analysis_crew/ 182 | ├── config/ 183 | │ ├── agents.yaml # Agent configurations 184 | │ └── tasks.yaml # Task definitions 185 | └── analysis_crew.py # Main crew class 186 | ``` 187 | 188 | ### Register in Main Flow 189 | ```python 190 | # In ag_ui_server.py 191 | from .crews.analysis_crew.analysis_crew import AnalysisCrew 192 | 193 | # Add to intent detection 194 | if intent == "ANALYSIS": 195 | result = AnalysisCrew().crew().kickoff(inputs={"data": user_message}) 196 | ``` 197 | 198 | ### Crew Examples you can add 199 | - **Research Crew**: Web search and information gathering (already included) 200 | - **Analysis Crew**: Data analysis and insights 201 | - **Writing Crew**: Content creation and summarization 202 | - **Code Crew**: Programming and technical tasks 203 | - **Translation Crew**: Multi-language translation 204 | - **Summarization Crew**: Document and text summarization 205 | 206 | ## Troubleshooting 207 | 208 | ### Common Issues 209 | 210 | **Backend won't start:** 211 | - Check if all API keys are set in `.env` 212 | - Ensure virtual environment is activated 213 | - Verify Python version compatibility (3.10-3.12 recommended) 214 | 215 | **Frontend stuck on "Initializing...":** 216 | - Verify `NEXT_PUBLIC_BACKEND_URL` points to running backend 217 | - Check browser console for errors 218 | - Ensure backend is running on the correct port 219 | 220 | **Authentication errors:** 221 | - Default credentials are `admin`/`secret` 222 | - Check if `SECRET_KEY` is set in backend `.env` 223 | - Verify JWT token is being sent in requests 224 | 225 | **API key errors:** 226 | - Verify all required API keys are set 227 | - Check OpenRouter account has sufficient credits 228 | - Ensure EXA and SerperDev API keys are valid 229 | 230 | ## 📄 License 231 | 232 | MIT License 233 | 234 | 235 | 236 | -------------------------------------------------------------------------------- /frontend/src/components/FlowStateDisplay.tsx: -------------------------------------------------------------------------------- 1 | interface FlowStateDisplayProps { 2 | state?: { 3 | conversation_history?: Array<{ 4 | input: string 5 | response: string 6 | type: "chat" | "research_enhanced" 7 | sources?: string[] 8 | }> 9 | research_results?: { 10 | summary: string 11 | sources: string[] 12 | citations: string[] 13 | } 14 | processing?: boolean 15 | has_new_research?: boolean 16 | } 17 | } 18 | 19 | export function FlowStateDisplay({ state }: FlowStateDisplayProps) { 20 | const getStatusInfo = () => { 21 | if (state?.processing) return { 22 | status: "Processing", 23 | bgClass: "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800/30", 24 | dotClass: "bg-amber-500", 25 | textClass: "text-amber-700 dark:text-amber-400", 26 | icon: ( 27 | 28 | 29 | 30 | ) 31 | } 32 | if (state?.has_new_research) return { 33 | status: "Results Ready", 34 | bgClass: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30", 35 | dotClass: "bg-green-500", 36 | textClass: "text-green-700 dark:text-green-400", 37 | icon: ( 38 | 39 | 40 | 41 | ) 42 | } 43 | return { 44 | status: "Ready", 45 | bgClass: "bg-gray-50 border-gray-200 dark:bg-gray-900/20 dark:border-gray-800/30", 46 | dotClass: "bg-gray-500", 47 | textClass: "text-gray-700 dark:text-gray-400", 48 | icon: ( 49 | 50 | 51 | 52 | ) 53 | } 54 | } 55 | 56 | const { status, bgClass, dotClass, textClass, icon } = getStatusInfo() 57 | 58 | return ( 59 |
60 | {/* Header */} 61 |
62 |
63 | 64 | 65 | 66 |
67 |

68 | Flow State 69 |

70 |
71 | 72 | {/* Status Indicator */} 73 |
74 |
75 |
76 |
77 | {icon} 78 |
79 |
80 | {status} 81 |

82 | {state?.processing ? "AI is analyzing your request..." : 83 | state?.has_new_research ? "New insights are available" : 84 | "Ready for your next question"} 85 |

86 |
87 |
88 | 89 | {/* Pulse indicator for processing */} 90 | {state?.processing && ( 91 |
92 |
93 |
94 |
95 |
96 | )} 97 |
98 |
99 | 100 | {/* Statistics Cards */} 101 |
102 | {/* Conversation Stats */} 103 |
104 |
105 |
106 | 107 | 108 | 109 |
110 |

111 | Conversations 112 |

113 |
114 |
115 |
116 | Total Messages: 117 | 118 | {state?.conversation_history?.length || 0} 119 | 120 |
121 |
122 |
123 | 124 | {/* Research Stats */} 125 |
126 |
127 |
128 | 129 | 130 | 131 |
132 |

133 | Research 134 |

135 |
136 |
137 |
138 | Status: 139 | 144 | {state?.has_new_research ? "Available" : "None"} 145 | 146 |
147 | {state?.research_results && ( 148 |
149 | Sources: 150 | 151 | {state.research_results.sources?.length || 0} 152 | 153 |
154 | )} 155 |
156 |
157 |
158 | 159 | {/* Processing Animation */} 160 | {state?.processing && ( 161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |

169 | AI is working... 170 |

171 |

172 | Analyzing your request and gathering insights 173 |

174 |
175 |
176 |
177 | )} 178 | 179 | {/* Research Progress Indicator */} 180 | {state?.has_new_research && ( 181 |
182 |
183 |
184 | 185 | 186 | 187 |
188 |
189 |

190 | Research Complete! 191 |

192 |

193 | New research results are ready for you to explore 194 |

195 |
196 |
197 |
198 |
199 | )} 200 |
201 | ) 202 | } -------------------------------------------------------------------------------- /backend/src/chatbot/utils/chat_helpers.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Any, Tuple 2 | from litellm import completion 3 | from decouple import config 4 | import os 5 | 6 | from .prompts import UNIFIED_PROMPT, inject_current_time 7 | 8 | # OpenRouter configuration 9 | OPENROUTER_API_KEY = config("OPENROUTER_API_KEY", default="") 10 | OPENROUTER_MODEL = config("OPENROUTER_MODEL", default="openai/gpt-4o-mini") 11 | OPENROUTER_BASE_URL = config("OPENROUTER_BASE_URL", default="https://openrouter.ai/api/v1") 12 | 13 | # Set OpenRouter API key for LiteLLM 14 | if OPENROUTER_API_KEY: 15 | os.environ["OPENROUTER_API_KEY"] = OPENROUTER_API_KEY 16 | 17 | 18 | def detect_intent(text: str, history: List[Dict[str, str]] = None) -> Tuple[str, str]: 19 | """Return (intent, expanded_query) using the unified prompt with conversation context.""" 20 | try: 21 | # Build conversation context 22 | context_messages = [] 23 | 24 | # Add conversation history for context 25 | if history: 26 | context_messages.append("Recent conversation history:") 27 | for turn in history: 28 | context_messages.append(f"User: {turn['input']}") 29 | context_messages.append(f"Assistant: {turn['response']}") 30 | else: 31 | context_messages.append("This is the start of the conversation.") 32 | 33 | context = "\n".join(context_messages) 34 | 35 | # Debug logging 36 | print(f"🔍 Intent Detection Debug:") 37 | print(f"Current text: '{text}'") 38 | print(f"History length: {len(history) if history else 0}") 39 | print(f"Context: {context[:200]}...") 40 | 41 | resp = completion( 42 | model=f"openrouter/{OPENROUTER_MODEL}", 43 | messages=[ 44 | {"role": "system", "content": inject_current_time(UNIFIED_PROMPT)}, 45 | {"role": "user", "content": f"{context}\n\nUser's current message: {text}"}, 46 | ], 47 | temperature=0.5, 48 | ) 49 | content = resp.choices[0].message.content.strip() 50 | 51 | print(f"LLM Response: '{content}'") 52 | 53 | # Extract intent and expanded query from the response format 54 | intent = "CHAT" # default 55 | expanded_query = text # default to original text 56 | 57 | if "INTENT:" in content: 58 | intent_line = [line for line in content.split('\n') if line.strip().startswith('INTENT:')] 59 | if intent_line: 60 | intent = intent_line[0].replace('INTENT:', '').strip() 61 | 62 | if "EXPANDED_QUERY:" in content: 63 | query_line = [line for line in content.split('\n') if line.strip().startswith('EXPANDED_QUERY:')] 64 | if query_line: 65 | expanded_query = query_line[0].replace('EXPANDED_QUERY:', '').strip() 66 | 67 | if intent in ['SEARCH', 'CHAT', 'EXIT']: 68 | print(f"✅ Detected Intent: {intent}") 69 | print(f"✅ Expanded Query: '{expanded_query}'") 70 | return intent, expanded_query 71 | 72 | # Simple fallback: let the LLM's response guide us 73 | # If the LLM didn't follow the format, default to CHAT 74 | print(f"❌ No valid intent found, defaulting to CHAT") 75 | return "CHAT", text 76 | except Exception as e: 77 | print(f"❌ Error in detect_intent: {e}") 78 | return "CHAT", text 79 | 80 | 81 | def synthesise_research( 82 | current_input: str, research: Dict[str, Any], temperature: float = 0.7 83 | ) -> str: 84 | """Synthesize research results with LLM knowledge using the unified prompt.""" 85 | # Build research context with better formatting 86 | summary = research.get('summary', '') 87 | sources = research.get('sources', []) 88 | citations = research.get('citations', []) 89 | 90 | # Format sources nicely 91 | sources_text = "" 92 | if sources: 93 | sources_text = "Sources found:\n" 94 | for i, source in enumerate(sources, 1): 95 | # Handle both dict and SourceInfo objects 96 | if hasattr(source, 'title'): 97 | title = source.title or 'No title' 98 | url = source.url or 'No URL' 99 | snippet = source.snippet or '' 100 | else: 101 | # Fallback for dict-like objects 102 | title = source.get('title', 'No title') 103 | url = source.get('url', 'No URL') 104 | snippet = source.get('snippet', '') 105 | 106 | sources_text += f"{i}. {title}\n" 107 | sources_text += f" URL: {url}\n" 108 | if snippet: 109 | sources_text += f" Summary: {snippet[:200]}...\n" 110 | sources_text += "\n" 111 | 112 | research_context = f""" 113 | Research Results for: "{current_input}" 114 | 115 | Summary: 116 | {summary} 117 | 118 | {sources_text} 119 | 120 | Citations: {citations if citations else 'None provided'} 121 | 122 | Please provide a comprehensive, warm, and helpful answer based on this research information. 123 | """ 124 | 125 | # Use the unified prompt with research context 126 | system_prompt = inject_current_time(UNIFIED_PROMPT) 127 | 128 | try: 129 | resp = completion( 130 | model=f"openrouter/{OPENROUTER_MODEL}", 131 | messages=[ 132 | {"role": "system", "content": system_prompt}, 133 | {"role": "user", "content": f"{research_context}\n\nBased on the research above, provide a comprehensive answer to the user's question. Be warm, helpful, and format the response clearly."}, 134 | ], 135 | temperature=temperature, 136 | ) 137 | 138 | content = resp.choices[0].message.content.strip() 139 | 140 | # Extract response from the unified format 141 | if "RESPONSE:" in content: 142 | response_lines = content.split('RESPONSE:') 143 | if len(response_lines) > 1: 144 | return response_lines[1].strip() 145 | 146 | # Fallback: return the full content if format parsing fails 147 | return content 148 | 149 | except Exception as e: 150 | return f"I'm sorry, I encountered an error: {str(e)}" 151 | 152 | 153 | def generate_chat_reply(history: List[Dict[str, str]], user_input: str) -> str: 154 | """Friendly chat response using the unified prompt and full conversation history.""" 155 | # Build conversation context 156 | context_messages = [] 157 | 158 | # Add conversation history for context 159 | if history: 160 | context_messages.append("Recent conversation history:") 161 | for turn in history: 162 | context_messages.append(f"User: {turn['input']}") 163 | context_messages.append(f"Assistant: {turn['response']}") 164 | else: 165 | context_messages.append("This is the start of the conversation.") 166 | 167 | context = "\n".join(context_messages) 168 | 169 | # Use the unified prompt 170 | system_prompt = inject_current_time(UNIFIED_PROMPT) 171 | 172 | try: 173 | resp = completion( 174 | model=f"openrouter/{OPENROUTER_MODEL}", 175 | messages=[ 176 | {"role": "system", "content": system_prompt}, 177 | {"role": "user", "content": f"{context}\n\nUser's current message: {user_input}"}, 178 | ], 179 | temperature=0.5, 180 | ) 181 | 182 | content = resp.choices[0].message.content.strip() 183 | 184 | # Extract response from the unified format 185 | if "RESPONSE:" in content: 186 | response_lines = content.split('RESPONSE:') 187 | if len(response_lines) > 1: 188 | return response_lines[1].strip() 189 | 190 | # Fallback: return the full content if format parsing fails 191 | return content 192 | 193 | except Exception as e: 194 | return f"I'm sorry, I encountered an error: {str(e)}" 195 | 196 | 197 | def unified_llm_call(conversation_history: List[Dict[str, str]], user_input: str) -> Tuple[str, str]: 198 | """ 199 | Single LLM call that determines intent and generates response using the unified prompt. 200 | Returns (intent, response) tuple. 201 | """ 202 | # Build conversation context 203 | context_messages = [] 204 | 205 | # Add conversation history for context 206 | if conversation_history: 207 | context_messages.append("Recent conversation history:") 208 | for turn in conversation_history: 209 | context_messages.append(f"User: {turn['input']}") 210 | context_messages.append(f"Assistant: {turn['response']}") 211 | else: 212 | context_messages.append("This is the start of the conversation.") 213 | 214 | context = "\n".join(context_messages) 215 | 216 | # Use the unified prompt 217 | system_prompt = inject_current_time(UNIFIED_PROMPT) 218 | 219 | try: 220 | resp = completion( 221 | model=f"openrouter/{OPENROUTER_MODEL}", 222 | messages=[ 223 | {"role": "system", "content": system_prompt}, 224 | {"role": "user", "content": f"{context}\n\nUser's current message: {user_input}"}, 225 | ], 226 | temperature=0.5, 227 | ) 228 | 229 | content = resp.choices[0].message.content.strip() 230 | 231 | # Parse the unified response format 232 | intent = "CHAT" # Default 233 | response = content 234 | 235 | if "INTENT:" in content and "RESPONSE:" in content: 236 | lines = content.split('\n') 237 | for line in lines: 238 | line = line.strip() 239 | if line.startswith('INTENT:'): 240 | intent_part = line.replace('INTENT:', '').strip() 241 | if intent_part in ['SEARCH', 'CHAT', 'EXIT']: 242 | intent = intent_part 243 | elif line.startswith('RESPONSE:'): 244 | response_part = line.replace('RESPONSE:', '').strip() 245 | if response_part: 246 | response = response_part 247 | 248 | return intent, response 249 | 250 | except Exception as e: 251 | return 'CHAT', f"I'm sorry, I encountered an error: {str(e)}" 252 | -------------------------------------------------------------------------------- /backend/ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # CrewAI Flow Architecture 2 | 3 | Welcome to the architectural overview of the CrewAI Flow project! This document explains how the different parts of the system work together to create a smart, real-time chatbot experience. 4 | 5 | ## High-Level Overview 6 | 7 | At its core, this project is a web application with a **Next.js frontend** and a **Python FastAPI backend**. The magic happens in the backend, where we intelligently decide how to answer a user's query. 8 | 9 | The system can handle two types of requests: 10 | 1. **Simple Chat**: For conversational questions, it gets a quick response from a Large Language Model (LLM). 11 | 2. **In-depth Research**: For complex queries, it launches a team of AI agents (a "Crew") to research the topic, gather sources, and formulate a comprehensive answer. 12 | 13 | This entire process is streamed to the user in real-time, so they can see the AI "thinking." 14 | 15 | ### Visual Flow 16 | 17 | Here's a simple diagram of the process: 18 | 19 | ``` 20 | +----------------------+ 21 | [User's Browser] | Next.js Frontend | 22 | +----------------------+ 23 | | (User sends message) 24 | v 25 | +----------------------+ 26 | [Python Server] | FastAPI Backend | 27 | +----------------------+ 28 | | 29 | v 30 | +---------------------------+ 31 | | Intent Detection | 32 | | (Is it a CHAT or SEARCH?) | 33 | +---------------------------+ 34 | | | 35 | (CHAT Intent) | | (SEARCH Intent) 36 | +---------+ +-----------+ 37 | | | 38 | v v 39 | +-------------------+ +----------------------+ 40 | | Simple LLM Call | | CrewAI Research Crew | 41 | | (Quick Response) | | (In-depth Research) | 42 | +-------------------+ +----------------------+ 43 | | | 44 | +----------------+--------------------+ 45 | | 46 | v 47 | +----------------------+ 48 | | Formatted Response | 49 | +----------------------+ 50 | | (Streamed back to user) 51 | v 52 | +----------------------+ 53 | [User's Browser] | Next.js Frontend | 54 | +----------------------+ 55 | ``` 56 | 57 | --- 58 | 59 | ## Component Breakdown 60 | 61 | Let's look at the key pieces of the backend (`backend/src/chatbot/`) that make this work. 62 | 63 | ### 1. The Server: `ag_ui_server.py` 64 | 65 | This file is the main entry point for the backend. 66 | 67 | * **What it does**: It creates a FastAPI web server that listens for messages from the frontend. It's responsible for receiving requests and sending back responses. 68 | * **Key Endpoint**: The `/agent` endpoint is the primary endpoint that the frontend talks to. It's set up to stream events, meaning it can send multiple small updates over a single connection instead of making the user wait for the final answer. 69 | * **For Advanced Users**: It uses `StreamingResponse` with the `text/event-stream` media type to achieve Server-Sent Events (SSE). This is an efficient way to push real-time data from the server to the client. 70 | 71 | ### 2. The Orchestrator: `AGUIFlowAdapter` 72 | 73 | This class, found inside `ag_ui_server.py`, manages the entire process of handling a user's message. 74 | 75 | * **What it does**: When a message comes in, the adapter starts the process. It's responsible for running the core logic, listening for real-time updates from the AI, and sending those updates to the frontend. It also formats the final answer before sending it. 76 | * **For Advanced Users**: It uses `asyncio` to run the main message processing (`_run_flow_message`) in a non-blocking way. This allows it to simultaneously listen for events from the `real_time_listener` and stream them to the UI while the main logic is still executing. 77 | 78 | ### 3. The Brains: `ChatbotSession` 79 | 80 | This class, also in `ag_ui_server.py`, decides *how* to handle the user's message. 81 | 82 | * **What it does**: This is where the core decision-making happens. 83 | 1. It first performs **Intent Detection** (`detect_intent`) to classify the message as "CHAT" or "SEARCH". 84 | 2. If it's a "CHAT" message, it gets a direct, conversational reply from an LLM (`generate_chat_reply`). 85 | 3. If it's a "SEARCH" message, it activates the powerful `ResearchCrew`. 86 | * **For Advanced Users**: It maintains the conversation state, including history and any research results, within its `self.state` object. This state is not persisted across server restarts but is maintained for a single user "session." 87 | 88 | ### 4. The Research Team: `ResearchCrew` 89 | 90 | This is your CrewAI implementation, located in `backend/src/chatbot/crews/research_crew/`. 91 | 92 | * **What it does**: This is a team of specialized AI agents designed for research. When given a query, the crew works together to browse the web, read articles, extract key information, and compile a detailed report with sources. 93 | * **Key Files**: 94 | * `research_crew.py`: Defines the crew, its agents, and the tasks they perform. 95 | * `config/agents.yaml`: Defines the properties of each agent (e.g., their LLM, their role, their backstory). 96 | * `config/tasks.yaml`: Defines the sequence of tasks the agents need to complete. 97 | * **For Advanced Users**: The crew is "kicked off" via `crew().kickoff()`. The `pydantic` output of the final task is used to ensure the data is returned in a structured, predictable format. 98 | 99 | ### 5. The Real-Time Announcer: `real_time_listener.py` 100 | 101 | This is the secret sauce for the "Perplexity-like" real-time updates. 102 | 103 | * **What it does**: This module "listens" to everything the `ResearchCrew` does. It captures events like "Agent Started," "Tool Used," or "LLM Stream Chunk" (a piece of a sentence). The `AGUIFlowAdapter` then forwards these events to the UI. 104 | * **For Advanced Users**: This is implemented as a custom `AsyncEventHandler` for CrewAI. It hooks into the crew's execution lifecycle and stores events in a session-specific queue, which the main server process polls and streams to the client. 105 | 106 | ### 6. Helper Utilities: `utils/` 107 | 108 | The utilities folder contains helper functions that support the main logic: 109 | 110 | * **`chat_helpers.py`**: Contains functions like `detect_intent()`, `generate_chat_reply()`, and `synthesise_research()`. These handle the core LLM interactions and decision-making logic. 111 | * **`prompts.py`**: Houses the prompts and instructions sent to the LLMs to guide their behavior. 112 | 113 | --- 114 | 115 | ## Data Flow Walkthrough 116 | 117 | Let's trace what happens when a user sends a message: 118 | 119 | ### Step 1: Message Reception 120 | 1. User types a message in the frontend chat interface 121 | 2. Frontend sends POST request to `/agent` endpoint 122 | 3. `agent_endpoint()` function extracts the message and calls `adapter.process_message()` 123 | 124 | ### Step 2: Processing Setup 125 | 1. `AGUIFlowAdapter.process_message()` sends a "RUN_STARTED" event to the UI 126 | 2. Creates an async task to run the actual message processing 127 | 3. Starts monitoring for real-time events from the `real_time_listener` 128 | 129 | ### Step 3: Intent Classification 130 | 1. `ChatbotSession.process_message()` calls `detect_intent(user_message)` 131 | 2. An LLM analyzes the message and returns either "CHAT" or "SEARCH" 132 | 133 | ### Step 4a: Simple Chat Path (CHAT Intent) 134 | 1. Calls `generate_chat_reply()` with conversation history 135 | 2. LLM generates a conversational response 136 | 3. Response is added to conversation history 137 | 4. Returns formatted response 138 | 139 | ### Step 4b: Research Path (SEARCH Intent) 140 | 1. Creates and kicks off `ResearchCrew` with the user query 141 | 2. Crew agents execute their tasks (web search, analysis, compilation) 142 | 3. Real-time events are captured by `real_time_listener` during execution 143 | 4. Crew returns structured research results with sources 144 | 5. `synthesise_research()` creates a final polished answer 145 | 6. Research results are stored in session state 146 | 147 | ### Step 5: Response Streaming 148 | 1. `AGUIFlowAdapter` formats the response based on intent type 149 | 2. For research responses: formats content and extracts sources 150 | 3. Streams the final response as "TEXT_MESSAGE_DELTA" events 151 | 4. Sends sources as "SOURCES_UPDATE" events (if applicable) 152 | 5. Sends "RUN_FINISHED" event to signal completion 153 | 154 | ### Step 6: Frontend Display 155 | 1. Frontend receives and displays streamed events in real-time 156 | 2. Shows typing indicators, progress updates, and partial responses 157 | 3. Displays final formatted answer with sources and citations 158 | 159 | --- 160 | 161 | ## Key Technologies & Patterns 162 | 163 | ### Backend Technologies 164 | - **FastAPI**: Modern, fast web framework for building APIs 165 | - **CrewAI**: Multi-agent framework for coordinating AI agents 166 | - **Pydantic**: Data validation and serialization 167 | - **AsyncIO**: Asynchronous programming for concurrent operations 168 | 169 | ### Frontend Technologies 170 | - **Next.js**: React framework for the web interface 171 | - **Server-Sent Events (SSE)**: Real-time communication from server to client 172 | - **TypeScript**: Type-safe JavaScript for better developer experience 173 | 174 | ### Architectural Patterns 175 | - **Event-Driven Architecture**: Real-time events drive UI updates 176 | - **Strategy Pattern**: Different handling strategies based on intent classification 177 | - **Observer Pattern**: Real-time listener observes crew execution 178 | - **Streaming Response**: Progressive loading of responses for better UX 179 | 180 | --- 181 | 182 | ## Benefits of This Architecture 183 | 184 | ### For Users 185 | - **Real-time feedback**: See the AI working in real-time 186 | - **Intelligent routing**: Get quick answers for simple questions, thorough research for complex ones 187 | - **Source transparency**: See exactly where information comes from 188 | - **Modern interface**: Clean, responsive design similar to popular AI assistants 189 | 190 | ### For Developers 191 | - **Modular design**: Easy to extend with new agents, tools, or capabilities 192 | - **Clear separation of concerns**: Frontend, orchestration, and AI logic are cleanly separated 193 | - **Event-driven**: Easy to add new types of real-time updates 194 | - **Type safety**: Pydantic models ensure data consistency between components 195 | 196 | --- 197 | 198 | This architecture creates a system that is both powerful and efficient. It uses the full force of an AI agent crew only when necessary, while still providing quick, conversational responses for simpler interactions, all with a modern, real-time user experience. -------------------------------------------------------------------------------- /frontend/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: 0 0% 100%; 5 | --foreground: 222 47% 11%; 6 | 7 | --muted: 210 40% 96%; 8 | --muted-foreground: 215 16% 47%; 9 | 10 | --popover: 0 0% 100%; 11 | --popover-foreground: 222 47% 11%; 12 | 13 | --card: 0 0% 100%; 14 | --card-foreground: 222 47% 11%; 15 | 16 | --border: 214 32% 91%; 17 | --input: 214 32% 91%; 18 | 19 | --primary: 43 79% 63%; 20 | --primary-foreground: 26 83% 14%; 21 | 22 | --secondary: 210 40% 96%; 23 | --secondary-foreground: 222 47% 11%; 24 | 25 | --accent: 210 40% 94%; 26 | --accent-foreground: 222 47% 11%; 27 | 28 | --destructive: 0 84% 60%; 29 | --destructive-foreground: 0 0% 100%; 30 | 31 | --success: 142.1 76.2% 36.3%; 32 | --success-foreground: 0 0% 100%; 33 | 34 | --ring: 43 96% 56%; 35 | 36 | --radius: 0.75rem; 37 | } 38 | 39 | @theme inline { 40 | --color-background: hsl(var(--background)); 41 | --color-foreground: hsl(var(--foreground)); 42 | --font-sans: var(--font-geist-sans); 43 | --font-mono: var(--font-geist-mono); 44 | } 45 | 46 | @media (prefers-color-scheme: dark) { 47 | :root { 48 | --background: 224 71% 4%; 49 | --foreground: 213 31% 91%; 50 | 51 | --muted: 223 47% 11%; 52 | --muted-foreground: 215 20% 65%; 53 | 54 | --popover: 224 71% 4%; 55 | --popover-foreground: 213 31% 91%; 56 | 57 | --card: 224 71% 4%; 58 | --card-foreground: 213 31% 91%; 59 | 60 | --border: 216 34% 17%; 61 | --input: 216 34% 17%; 62 | 63 | --primary: 43 96% 56%; 64 | --primary-foreground: 26 83% 14%; 65 | 66 | --secondary: 223 47% 11%; 67 | --secondary-foreground: 213 31% 91%; 68 | 69 | --accent: 216 34% 17%; 70 | --accent-foreground: 213 31% 91%; 71 | 72 | --destructive: 0 63% 31%; 73 | --destructive-foreground: 0 85% 96%; 74 | 75 | --success: 142 69% 58%; 76 | --success-foreground: 144 61% 20%; 77 | 78 | --ring: 43 96% 56%; 79 | } 80 | } 81 | 82 | /* Enhanced body styling with subtle background */ 83 | body { 84 | background: linear-gradient(135deg, 85 | rgba(251, 191, 36, 0.02) 0%, 86 | rgba(156, 163, 175, 0.03) 100% 87 | ), 88 | hsl(var(--background)); 89 | 90 | color: hsl(var(--foreground)); 91 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 92 | min-height: 100vh; 93 | display: flex; 94 | flex-direction: column; 95 | font-weight: 400; 96 | letter-spacing: -0.01em; 97 | } 98 | 99 | @media (prefers-color-scheme: dark) { 100 | body { 101 | background: linear-gradient(135deg, 102 | rgba(251, 191, 36, 0.05) 0%, 103 | rgba(75, 85, 99, 0.08) 100% 104 | ), 105 | hsl(var(--background)); 106 | } 107 | } 108 | 109 | /* Custom scrollbar styling */ 110 | ::-webkit-scrollbar { 111 | width: 8px; 112 | } 113 | 114 | ::-webkit-scrollbar-track { 115 | background: hsl(var(--muted)); 116 | border-radius: 4px; 117 | } 118 | 119 | ::-webkit-scrollbar-thumb { 120 | background: linear-gradient(135deg, #fcd34d, #f59e0b); 121 | border-radius: 4px; 122 | border: 1px solid hsl(var(--background)); 123 | } 124 | 125 | ::-webkit-scrollbar-thumb:hover { 126 | background: linear-gradient(135deg, #f59e0b, #d97706); 127 | } 128 | 129 | /* Enhanced markdown styles for chat messages */ 130 | .markdown-content { 131 | line-height: 1.7; 132 | letter-spacing: -0.01em; 133 | color: hsl(var(--foreground)); 134 | } 135 | 136 | /* Better spacing for content */ 137 | .markdown-content > *:first-child { 138 | margin-top: 0; 139 | } 140 | 141 | .markdown-content > *:last-child { 142 | margin-bottom: 0; 143 | } 144 | 145 | .markdown-content h1, 146 | .markdown-content h2, 147 | .markdown-content h3 { 148 | color: hsl(var(--foreground)); 149 | font-weight: 700; 150 | margin: 1.5rem 0 1rem 0; 151 | padding-bottom: 0.5rem; 152 | } 153 | 154 | .markdown-content h1 { 155 | font-size: 1.5rem; 156 | border-bottom: 2px solid hsl(var(--border)); 157 | } 158 | 159 | .markdown-content h2 { 160 | font-size: 1.25rem; 161 | border-bottom: 1px solid hsl(var(--border)); 162 | } 163 | 164 | .markdown-content h3 { 165 | font-size: 1.125rem; 166 | } 167 | 168 | .markdown-content p { 169 | margin: 1rem 0; 170 | color: hsl(var(--foreground)); 171 | opacity: 0.9; 172 | } 173 | 174 | /* Enhanced list styling */ 175 | .markdown-content ul, .markdown-content ol { 176 | margin: 1rem 0; 177 | padding-left: 1.5rem; 178 | } 179 | 180 | .markdown-content ul { 181 | list-style: none; 182 | } 183 | 184 | .markdown-content ol { 185 | padding-left: 1.5rem; 186 | } 187 | 188 | .markdown-content li { 189 | margin: 0.75rem 0; 190 | color: hsl(var(--foreground)); 191 | line-height: 1.6; 192 | } 193 | 194 | /* Custom bullet styling for better visual hierarchy */ 195 | .markdown-content ul li { 196 | position: relative; 197 | padding-left: 0; 198 | } 199 | 200 | .markdown-content ul li::before { 201 | content: ''; 202 | position: absolute; 203 | left: -1.5rem; 204 | top: 0.75rem; 205 | width: 6px; 206 | height: 6px; 207 | background: linear-gradient(135deg, #fcd34d, #f59e0b); 208 | border-radius: 50%; 209 | box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.2); 210 | } 211 | 212 | /* Enhanced prose styles for ReactMarkdown */ 213 | .prose { 214 | color: hsl(var(--foreground)); 215 | max-width: none; 216 | } 217 | 218 | .prose h1 { 219 | color: hsl(var(--foreground)); 220 | font-weight: 800; 221 | font-size: 1.875rem; 222 | margin-top: 2rem; 223 | margin-bottom: 1.5rem; 224 | line-height: 1.2; 225 | } 226 | 227 | .prose h2 { 228 | color: #d97706; 229 | font-weight: 700; 230 | font-size: 1.5rem; 231 | margin-top: 1.5rem; 232 | margin-bottom: 1rem; 233 | line-height: 1.3; 234 | } 235 | 236 | .prose h3 { 237 | color: hsl(var(--foreground)); 238 | font-weight: 600; 239 | font-size: 1.25rem; 240 | margin-top: 1.25rem; 241 | margin-bottom: 0.75rem; 242 | line-height: 1.4; 243 | } 244 | 245 | .prose p { 246 | margin-top: 1rem; 247 | margin-bottom: 1rem; 248 | line-height: 1.7; 249 | color: hsl(var(--foreground)); 250 | opacity: 0.95; 251 | } 252 | 253 | .prose ul { 254 | margin-top: 1rem; 255 | margin-bottom: 1rem; 256 | list-style: none; 257 | padding-left: 0; 258 | } 259 | 260 | .prose ol { 261 | margin-top: 1rem; 262 | margin-bottom: 1rem; 263 | padding-left: 1.5rem; 264 | } 265 | 266 | .prose li { 267 | margin-top: 0.5rem; 268 | margin-bottom: 0.5rem; 269 | line-height: 1.6; 270 | color: hsl(var(--foreground)); 271 | } 272 | 273 | .prose strong { 274 | font-weight: 700; 275 | color: hsl(var(--foreground)); 276 | } 277 | 278 | .prose em { 279 | font-style: italic; 280 | color: hsl(var(--muted-foreground)); 281 | } 282 | 283 | .prose code { 284 | background-color: hsl(var(--muted)); 285 | color: hsl(var(--foreground)); 286 | padding: 0.125rem 0.25rem; 287 | border-radius: 0.375rem; 288 | font-size: 0.875rem; 289 | font-weight: 500; 290 | border: 1px solid hsl(var(--border)); 291 | } 292 | 293 | .prose pre { 294 | background-color: hsl(var(--muted)); 295 | color: hsl(var(--foreground)); 296 | padding: 1rem; 297 | border-radius: 0.5rem; 298 | overflow-x: auto; 299 | border: 1px solid hsl(var(--border)); 300 | margin: 1rem 0; 301 | } 302 | 303 | .prose blockquote { 304 | border-left: 4px solid #f59e0b; 305 | padding-left: 1rem; 306 | margin: 1rem 0; 307 | font-style: italic; 308 | color: hsl(var(--muted-foreground)); 309 | background-color: rgba(251, 191, 36, 0.05); 310 | padding: 0.75rem 1rem; 311 | border-radius: 0 0.5rem 0.5rem 0; 312 | } 313 | 314 | .prose a { 315 | color: #d97706; 316 | text-decoration: underline; 317 | font-weight: 500; 318 | transition: all 0.2s ease; 319 | } 320 | 321 | .prose a:hover { 322 | color: #b45309; 323 | background-color: rgba(251, 191, 36, 0.1); 324 | text-decoration: none; 325 | border-radius: 0.25rem; 326 | padding: 0.125rem 0.25rem; 327 | } 328 | 329 | .markdown-content ul li::before { 330 | content: "•"; 331 | color: hsl(var(--foreground)); 332 | font-size: 1em; 333 | font-weight: normal; 334 | position: absolute; 335 | left: -1.25rem; 336 | top: 0; 337 | } 338 | 339 | .markdown-content strong { 340 | font-weight: 600; 341 | color: hsl(var(--foreground)); 342 | } 343 | 344 | .markdown-content em { 345 | font-style: italic; 346 | color: hsl(var(--muted-foreground)); 347 | opacity: 0.8; 348 | } 349 | 350 | .markdown-content code { 351 | background: hsl(var(--muted)); 352 | color: hsl(var(--primary)); 353 | padding: 0.2rem 0.4rem; 354 | border-radius: 0.375rem; 355 | font-size: 0.875em; 356 | font-weight: 500; 357 | border: 1px solid hsl(var(--border)); 358 | } 359 | 360 | .markdown-content pre { 361 | background: hsl(var(--muted)); 362 | border: 1px solid hsl(var(--border)); 363 | border-radius: 0.75rem; 364 | padding: 1rem; 365 | overflow-x: auto; 366 | margin: 1rem 0; 367 | } 368 | 369 | .markdown-content pre code { 370 | background: none; 371 | border: none; 372 | padding: 0; 373 | color: hsl(var(--foreground)); 374 | } 375 | 376 | .markdown-content blockquote { 377 | border-left: 4px solid hsl(var(--primary)); 378 | background: hsl(var(--muted)); 379 | padding: 1rem 1.5rem; 380 | margin: 1rem 0; 381 | border-radius: 0 0.5rem 0.5rem 0; 382 | font-style: italic; 383 | } 384 | 385 | /* Glass morphism effect for cards */ 386 | .glass-card { 387 | background: rgba(255, 255, 255, 0.8); 388 | backdrop-filter: blur(8px); 389 | border: 1px solid rgba(0, 0, 0, 0.1); 390 | box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); 391 | } 392 | 393 | @media (prefers-color-scheme: dark) { 394 | .glass-card { 395 | background: rgba(0, 0, 0, 0.4); 396 | border: 1px solid rgba(255, 255, 255, 0.1); 397 | box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 398 | } 399 | } 400 | 401 | /* Button hover effects */ 402 | .btn-primary { 403 | background: linear-gradient(135deg, #fcd34d, #f59e0b); 404 | transition: all 0.3s ease; 405 | } 406 | 407 | .btn-primary:hover { 408 | transform: translateY(-1px); 409 | box-shadow: 0 4px 12px rgba(252, 211, 77, 0.4); 410 | } 411 | 412 | /* Status indicators */ 413 | .status-processing { 414 | background: rgba(252, 211, 77, 0.1); 415 | border-color: rgba(252, 211, 77, 0.3); 416 | color: #f59e0b; 417 | } 418 | 419 | .status-success { 420 | background: rgba(34, 197, 94, 0.1); 421 | border-color: rgba(34, 197, 94, 0.3); 422 | color: #16a34a; 423 | } 424 | 425 | .status-ready { 426 | background: rgba(107, 114, 128, 0.1); 427 | border-color: rgba(107, 114, 128, 0.3); 428 | color: #6b7280; 429 | } 430 | 431 | /* Enhanced input styling */ 432 | .enhanced-input { 433 | background: hsl(var(--background)); 434 | border: 1px solid hsl(var(--border)); 435 | transition: all 0.3s ease; 436 | } 437 | 438 | .enhanced-input:focus { 439 | border-color: hsl(var(--primary)); 440 | box-shadow: 0 0 0 3px rgba(252, 211, 77, 0.1); 441 | } 442 | 443 | /* Execution tracker animations */ 444 | @keyframes slide-in-from-left-2 { 445 | from { 446 | transform: translateX(-8px); 447 | opacity: 0; 448 | } 449 | to { 450 | transform: translateX(0); 451 | opacity: 1; 452 | } 453 | } 454 | 455 | @keyframes fade-in { 456 | from { 457 | opacity: 0; 458 | } 459 | to { 460 | opacity: 1; 461 | } 462 | } 463 | 464 | .animate-in { 465 | animation-duration: 0.3s; 466 | animation-timing-function: ease-out; 467 | animation-fill-mode: both; 468 | } 469 | 470 | .slide-in-from-left-2 { 471 | animation-name: slide-in-from-left-2; 472 | } 473 | 474 | .animate-fade-in { 475 | animation-name: fade-in; 476 | animation-duration: 0.5s; 477 | animation-timing-function: ease-out; 478 | } 479 | -------------------------------------------------------------------------------- /frontend/src/components/ResearchResults.tsx: -------------------------------------------------------------------------------- 1 | interface Source { 2 | url: string 3 | title?: string 4 | image_url?: string 5 | snippet?: string 6 | } 7 | 8 | interface ResearchResultsProps { 9 | results?: { 10 | summary: string 11 | sources: Source[] 12 | citations: string[] 13 | } 14 | conversations?: Array<{ 15 | input: string 16 | response: string 17 | type: "chat" | "research_enhanced" 18 | sources?: Source[] 19 | }> 20 | } 21 | 22 | export function ResearchResults({ results, conversations }: ResearchResultsProps) { 23 | return ( 24 |
25 | {/* Header */} 26 |
27 |
28 |
29 | 30 | 31 | 32 |
33 |

34 | Research Results 35 |

36 |
37 | {results && ( 38 |
39 |
40 | 41 | {results.sources?.length || 0} sources found 42 | 43 |
44 | )} 45 |
46 | 47 | {/* Research Results Section */} 48 | {results ? ( 49 |
50 | {/* Summary Card */} 51 |
52 |
53 |
54 | 55 | 56 | 57 |
58 |

59 | Research Summary 60 |

61 |
62 |
63 |

{results.summary}

64 |
65 |
66 | 67 | {/* Enhanced Sources Section */} 68 | {results.sources && results.sources.length > 0 && ( 69 |
70 |
71 |
72 |
73 | 74 | 75 | 76 |
77 |

78 | Sources 79 |

80 | 81 | {results.sources.length} found 82 | 83 |
84 |
85 |
86 | {results.sources.map((source, index) => ( 87 | 94 | 95 | 96 | {/* Content */} 97 |
98 | {/* Image if available */} 99 | {source.image_url && ( 100 |
101 | {source.title { 106 | // Hide image on error 107 | e.currentTarget.style.display = 'none'; 108 | }} 109 | /> 110 | {/* Image overlay for better text readability */} 111 |
112 |
113 | )} 114 | 115 | {/* Source name/publisher on top (like Perplexity) */} 116 |
117 | {new URL(source.url).hostname.replace(/^www\./, '')} 118 |
119 | 120 | {/* Title below source name */} 121 |
122 | {source.title || new URL(source.url).hostname} 123 |
124 | 125 | {/* Snippet if available */} 126 | {source.snippet && ( 127 |
128 | {source.snippet} 129 |
130 | )} 131 | 132 |
133 |
134 |
135 | 136 | Verified source 137 | 138 |
139 |
140 |
141 | 142 | {/* Hover effect overlay */} 143 |
144 |
145 | ))} 146 |
147 |
148 | )} 149 | 150 | {/* Citations Section */} 151 | {results.citations && results.citations.length > 0 && ( 152 |
153 |
154 |
155 | 156 | 157 | 158 |
159 |

160 | Citations ({results.citations.length}) 161 |

162 |
163 |
164 | {results.citations.map((citation, index) => ( 165 |
166 |

{citation}

167 |
168 | ))} 169 |
170 |
171 | )} 172 |
173 | ) : ( 174 |
175 |
176 |
177 |
178 | 179 | 180 | 181 |
182 |
183 |

No research results yet

184 |

185 | Start a conversation to see research results and insights here 186 |

187 |
188 |
189 | )} 190 | 191 | {/* Conversation History */} 192 | {conversations && conversations.length > 0 && ( 193 |
194 |
195 |
196 | 197 | 198 | 199 |
200 |

201 | Recent Conversations 202 |

203 |
204 |
205 | {conversations.slice(-5).map((conv, index) => ( 206 |
207 |
208 |
209 |
214 | {conv.type === "research_enhanced" ? "R" : "C"} 215 |
216 | 217 | {conv.type === "research_enhanced" ? "Research Chat" : "Regular Chat"} 218 | 219 |
220 | {conv.sources && ( 221 |
222 |
223 | 224 | {conv.sources.length} sources 225 | 226 |
227 | )} 228 |
229 |

{conv.input}

230 |
231 | ))} 232 |
233 |
234 | )} 235 |
236 | ) 237 | } -------------------------------------------------------------------------------- /backend/src/chatbot/listeners/real_time_listener.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict, Any, List 2 | from dataclasses import dataclass, asdict 3 | from datetime import datetime 4 | import queue 5 | import threading 6 | import uuid 7 | import json 8 | 9 | from crewai.utilities.events import ( 10 | CrewKickoffCompletedEvent, 11 | AgentExecutionStartedEvent, 12 | AgentExecutionCompletedEvent, 13 | ToolUsageStartedEvent, 14 | ToolUsageFinishedEvent, 15 | ToolUsageErrorEvent, 16 | ) 17 | from crewai.utilities.events.base_event_listener import BaseEventListener 18 | 19 | 20 | @dataclass 21 | class StreamEvent: 22 | """Data structure for streaming events to frontend""" 23 | type: str 24 | data: Dict[str, Any] 25 | timestamp: str 26 | agent_id: Optional[str] = None 27 | session_id: Optional[str] = None 28 | 29 | 30 | class RealTimeListener(BaseEventListener): 31 | """ 32 | Real-time event listener that captures CrewAI events and streams them to the frontend. 33 | Provides Perplexity-like real-time visibility into agent execution. 34 | """ 35 | 36 | def __init__(self): 37 | super().__init__() 38 | 39 | self.event_queue = queue.Queue() 40 | self.session_id = str(uuid.uuid4()) 41 | 42 | def setup_listeners(self, crewai_event_bus): 43 | """Setup all event listeners for comprehensive monitoring""" 44 | 45 | # ==================== AGENT EVENTS ==================== 46 | 47 | @crewai_event_bus.on(AgentExecutionStartedEvent) 48 | def on_agent_execution_started(source, event: AgentExecutionStartedEvent): 49 | agent = getattr(event, 'agent', None) 50 | if agent: 51 | agent_id = str(agent.id) 52 | agent_role = getattr(agent, 'role', 'Research Agent') 53 | 54 | # Emit the thinking message immediately when agent starts 55 | self._emit_event(StreamEvent( 56 | type="AGENT_STARTED", 57 | data={ 58 | "agent_role": agent_role, 59 | "message": "Research agent thinking...", 60 | "status": "executing" 61 | }, 62 | timestamp=datetime.now().isoformat(), 63 | agent_id=agent_id, 64 | session_id=self.session_id 65 | )) 66 | 67 | @crewai_event_bus.on(AgentExecutionCompletedEvent) 68 | def on_agent_execution_finished(source, event: AgentExecutionCompletedEvent): 69 | agent = getattr(event, 'agent', None) 70 | if agent: 71 | agent_id = str(agent.id) 72 | agent_role = getattr(agent, 'role', 'Research Agent') 73 | 74 | # Emit the final thoughts message when agent finishes 75 | self._emit_event(StreamEvent( 76 | type="AGENT_FINISHED", 77 | data={ 78 | "agent_role": agent_role, 79 | "message": "Gathering final thoughts...", 80 | "status": "finished" 81 | }, 82 | timestamp=datetime.now().isoformat(), 83 | agent_id=agent_id, 84 | session_id=self.session_id 85 | )) 86 | 87 | # ==================== TOOL USAGE EVENTS ==================== 88 | 89 | @crewai_event_bus.on(ToolUsageStartedEvent) 90 | def on_tool_usage_started(source, event: ToolUsageStartedEvent): 91 | """Capture when a tool starts executing, including the input query""" 92 | agent = getattr(event, 'agent', None) 93 | agent_id = str(agent.id) if agent and hasattr(agent, 'id') else None 94 | agent_role = getattr(agent, 'role', 'Research Agent') if agent else 'Research Agent' 95 | 96 | # Extract tool input query from tool_args - handle various formats 97 | tool_query = "Unknown query" 98 | 99 | # Handle string representation of JSON 100 | if isinstance(event.tool_args, str): 101 | try: 102 | # Try to parse as JSON 103 | parsed_args = json.loads(event.tool_args) 104 | if isinstance(parsed_args, dict): 105 | if "query" in parsed_args and isinstance(parsed_args["query"], dict): 106 | tool_query = parsed_args["query"].get("search_query", "Unknown query") 107 | elif "query" in parsed_args: 108 | tool_query = parsed_args.get("query", "Unknown query") 109 | else: 110 | tool_query = event.tool_args 111 | except json.JSONDecodeError: 112 | # If not JSON, use as-is 113 | tool_query = event.tool_args 114 | elif isinstance(event.tool_args, dict): 115 | # Handle nested structure: {"query": {"search_query": "..."}, "num_results": 5} 116 | if "query" in event.tool_args and isinstance(event.tool_args["query"], dict): 117 | tool_query = event.tool_args["query"].get("search_query", "Unknown query") 118 | # Handle flat structure: {"query": "...", "num_results": 5} 119 | elif "query" in event.tool_args: 120 | tool_query = event.tool_args.get("query", "Unknown query") 121 | 122 | self._emit_event(StreamEvent( 123 | type="TOOL_STARTED", 124 | data={ 125 | "tool_name": event.tool_name, 126 | "tool_query": tool_query, 127 | "agent_role": agent_role, 128 | "message": f"Searching for {tool_query}", 129 | "status": "executing" 130 | }, 131 | timestamp=datetime.now().isoformat(), 132 | agent_id=agent_id, 133 | session_id=self.session_id 134 | )) 135 | 136 | @crewai_event_bus.on(ToolUsageFinishedEvent) 137 | def on_tool_usage_finished(source, event: ToolUsageFinishedEvent): 138 | """Capture when a tool finishes executing""" 139 | agent = getattr(event, 'agent', None) 140 | agent_id = str(agent.id) if agent and hasattr(agent, 'id') else None 141 | agent_role = getattr(agent, 'role', 'Research Agent') if agent else 'Research Agent' 142 | 143 | # Extract tool input query - handle various formats 144 | tool_query = "Unknown query" 145 | 146 | # Handle string representation of JSON 147 | if isinstance(event.tool_args, str): 148 | try: 149 | # Try to parse as JSON 150 | parsed_args = json.loads(event.tool_args) 151 | if isinstance(parsed_args, dict): 152 | if "query" in parsed_args and isinstance(parsed_args["query"], dict): 153 | tool_query = parsed_args["query"].get("search_query", "Unknown query") 154 | elif "query" in parsed_args: 155 | tool_query = parsed_args.get("query", "Unknown query") 156 | else: 157 | tool_query = event.tool_args 158 | except json.JSONDecodeError: 159 | # If not JSON, use as-is 160 | tool_query = event.tool_args 161 | elif isinstance(event.tool_args, dict): 162 | # Handle nested structure: {"query": {"search_query": "..."}, "num_results": 5} 163 | if "query" in event.tool_args and isinstance(event.tool_args["query"], dict): 164 | tool_query = event.tool_args["query"].get("search_query", "Unknown query") 165 | # Handle flat structure: {"query": "...", "num_results": 5} 166 | elif "query" in event.tool_args: 167 | tool_query = event.tool_args.get("query", "Unknown query") 168 | 169 | # Emit event when tool completes execution 170 | # Contains tool details, query info, agent info and completion status 171 | # Includes whether results came from cache 172 | #self._emit_event(StreamEvent( 173 | # type="TOOL_COMPLETED", 174 | # data={ 175 | # "tool_name": event.tool_name, 176 | # "tool_query": tool_query, 177 | # "agent_role": agent_role, 178 | # "message": f"Found results for: {tool_query}", 179 | # "status": "completed", 180 | # "from_cache": event.from_cache 181 | # }, 182 | # timestamp=datetime.now().isoformat(), 183 | # agent_id=agent_id, 184 | # session_id=self.session_id 185 | #)) 186 | 187 | @crewai_event_bus.on(ToolUsageErrorEvent) 188 | def on_tool_usage_error(source, event: ToolUsageErrorEvent): 189 | """Capture when a tool encounters an error""" 190 | agent = getattr(event, 'agent', None) 191 | agent_id = str(agent.id) if agent and hasattr(agent, 'id') else None 192 | agent_role = getattr(agent, 'role', 'Research Agent') if agent else 'Research Agent' 193 | 194 | # Extract tool input query - handle various formats 195 | tool_query = "Unknown query" 196 | 197 | # Handle string representation of JSON 198 | if isinstance(event.tool_args, str): 199 | try: 200 | # Try to parse as JSON 201 | parsed_args = json.loads(event.tool_args) 202 | if isinstance(parsed_args, dict): 203 | if "query" in parsed_args and isinstance(parsed_args["query"], dict): 204 | tool_query = parsed_args["query"].get("search_query", "Unknown query") 205 | elif "query" in parsed_args: 206 | tool_query = parsed_args.get("query", "Unknown query") 207 | else: 208 | tool_query = event.tool_args 209 | except json.JSONDecodeError: 210 | # If not JSON, use as-is 211 | tool_query = event.tool_args 212 | elif isinstance(event.tool_args, dict): 213 | # Handle nested structure: {"query": {"search_query": "..."}, "num_results": 5} 214 | if "query" in event.tool_args and isinstance(event.tool_args["query"], dict): 215 | tool_query = event.tool_args["query"].get("search_query", "Unknown query") 216 | # Handle flat structure: {"query": "...", "num_results": 5} 217 | elif "query" in event.tool_args: 218 | tool_query = event.tool_args.get("query", "Unknown query") 219 | 220 | self._emit_event(StreamEvent( 221 | type="TOOL_ERROR", 222 | data={ 223 | "tool_name": event.tool_name, 224 | "tool_query": tool_query, 225 | "agent_role": agent_role, 226 | "message": f"Error searching for {tool_query}", 227 | "status": "error", 228 | "error": str(event.error) 229 | }, 230 | timestamp=datetime.now().isoformat(), 231 | agent_id=agent_id, 232 | session_id=self.session_id 233 | )) 234 | 235 | 236 | def _emit_event(self, event: StreamEvent): 237 | """Emit event to the queue for frontend consumption""" 238 | # Clean the event data to ensure JSON serialization 239 | clean_event = self._clean_event_data(event) 240 | self.event_queue.put_nowait(clean_event) 241 | # Debug output for essential events only 242 | if event.type in ["AGENT_STARTED", "AGENT_FINISHED"]: 243 | print(f"🔔 Event: {event.data.get('message', '')}") 244 | 245 | def _clean_event_data(self, event: StreamEvent) -> StreamEvent: 246 | """Clean event data to ensure JSON serialization""" 247 | # Create a clean copy of the event data 248 | clean_data = {} 249 | for key, value in event.data.items(): 250 | if isinstance(value, (str, int, float, bool, type(None))): 251 | clean_data[key] = value 252 | else: 253 | # Convert other types to string 254 | clean_data[key] = str(value) 255 | 256 | # Create a new event with cleaned data 257 | return StreamEvent( 258 | type=event.type, 259 | data=clean_data, 260 | timestamp=event.timestamp, 261 | agent_id=event.agent_id, 262 | session_id=event.session_id 263 | ) 264 | 265 | def get_events_realtime(self) -> List[Dict[str, Any]]: 266 | """Get events and immediately return them for real-time streaming""" 267 | events = [] 268 | while True: 269 | try: 270 | event = self.event_queue.get_nowait() 271 | events.append(asdict(event)) 272 | except queue.Empty: 273 | break 274 | return events 275 | 276 | def get_session_status(self) -> Dict[str, Any]: 277 | """Get current session status and statistics""" 278 | return { 279 | "session_id": self.session_id, 280 | "events_pending": self.event_queue.qsize() 281 | } 282 | 283 | def reset_session(self): 284 | """Reset the session and clear all events""" 285 | self.session_id = str(uuid.uuid4()) 286 | 287 | while True: 288 | try: 289 | self.event_queue.get_nowait() 290 | except queue.Empty: 291 | break 292 | 293 | 294 | # Create a global instance of the listener 295 | real_time_listener = RealTimeListener() -------------------------------------------------------------------------------- /backend/src/chatbot/ag_ui_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | AG-UI compatible server for CrewAI flow 4 | Enhanced with real-time event streaming for Perplexity-like experience 5 | """ 6 | 7 | import sys 8 | import os 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | from fastapi import FastAPI, Depends, HTTPException, status 12 | from fastapi.responses import StreamingResponse 13 | from fastapi.middleware.cors import CORSMiddleware 14 | from fastapi.security import OAuth2PasswordRequestForm 15 | from pydantic import BaseModel 16 | from typing import Dict, Any, AsyncGenerator, List 17 | import json 18 | import asyncio 19 | from datetime import datetime, timedelta 20 | 21 | # Import authentication 22 | from .auth import ( 23 | authenticate_user, 24 | create_access_token, 25 | get_current_active_user, 26 | User, 27 | Token, 28 | fake_users_db, 29 | ACCESS_TOKEN_EXPIRE_MINUTES 30 | ) 31 | 32 | # Import listeners to register them with the event bus 33 | from .listeners.real_time_listener import real_time_listener 34 | 35 | 36 | from .main import ChatState 37 | 38 | from .utils.chat_helpers import ( 39 | detect_intent, 40 | generate_chat_reply, 41 | synthesise_research, 42 | ) 43 | from .crews.research_crew.research_crew import ResearchCrew 44 | 45 | 46 | # ------------------------------------------------------------- 47 | # Enhanced wrapper that utilises the existing helpers + crews and 48 | # provides real-time event streaming capabilities 49 | # ------------------------------------------------------------- 50 | 51 | 52 | class ChatbotSession: 53 | """Self-contained session replicating the behaviour of `SimpleChatFlow` with real-time events.""" 54 | 55 | def __init__(self): 56 | self.state = ChatState() 57 | self.session_id = None 58 | 59 | # ------------------------- Core API ---------------------- 60 | def process_message(self, user_message: str, conversation_history: List[Dict[str, str]] = None) -> Dict[str, Any]: 61 | """Process a single user message and return a response payload.""" 62 | 63 | # Only reset event listener session for new chat sessions, not for each message 64 | if not self.session_id: 65 | real_time_listener.reset_session() 66 | self.session_id = real_time_listener.session_id 67 | 68 | self.state.current_input = user_message 69 | 70 | # Initialize conversation history if provided (from frontend) 71 | if conversation_history is not None: 72 | self.state.conversation_history = conversation_history 73 | 74 | # 1. Detect intent (SEARCH, CHAT, EXIT) and get expanded query 75 | intent, expanded_query = detect_intent(user_message, self.state.conversation_history) 76 | 77 | # 2. Handle EXIT intent upfront 78 | if intent == "EXIT": 79 | self.state.session_ended = True 80 | return { 81 | "intent": "EXIT", 82 | "response": "👋 Session ended. Feel free to start a new one whenever you like!", 83 | } 84 | 85 | # 3. Handle SEARCH intent via ResearchCrew 86 | if intent == "SEARCH": 87 | try: 88 | search_result = ResearchCrew().crew().kickoff(inputs={"query": expanded_query}) 89 | 90 | pyd_res = search_result.pydantic 91 | research_results = { 92 | "summary": getattr(pyd_res, "summary", ""), 93 | "sources": getattr(pyd_res, "sources", []), 94 | "citations": getattr(pyd_res, "citations", []), 95 | } 96 | 97 | # Store research in state for potential future use 98 | self.state.research_results = research_results 99 | self.state.has_new_research = True 100 | 101 | # Synthesize final answer from the raw research 102 | answer = synthesise_research(user_message, research_results) 103 | 104 | # Persist turn in history 105 | self.state.conversation_history.append( 106 | { 107 | "input": user_message, 108 | "response": answer, 109 | "type": "research_enhanced", 110 | "sources": research_results.get("sources", []), 111 | } 112 | ) 113 | 114 | return { 115 | "intent": "SEARCH", 116 | "response": answer, 117 | "sources": research_results.get("sources", []), 118 | } 119 | except Exception as e: 120 | return {"error": str(e)} 121 | 122 | # 4. Default: regular chat with full conversation history 123 | reply = generate_chat_reply(self.state.conversation_history, user_message) 124 | self.state.conversation_history.append( 125 | {"input": user_message, "response": reply, "type": "chat"} 126 | ) 127 | 128 | return {"intent": "CHAT", "response": reply, "token_usage": 0} 129 | 130 | def start_new_chat(self): 131 | """Start a new chat session, clearing all history and state.""" 132 | self.state = ChatState() 133 | self.session_id = None 134 | real_time_listener.reset_session() 135 | 136 | # ---------------------- Utility helpers ------------------ 137 | def is_session_active(self) -> bool: 138 | return not self.state.session_ended 139 | 140 | def get_conversation_history(self) -> list: 141 | return self.state.conversation_history 142 | 143 | def get_session_id(self) -> str: 144 | return self.session_id or "no-session" 145 | 146 | 147 | # Factory expected by the rest of this module 148 | def create_chatbot() -> ChatbotSession: 149 | return ChatbotSession() 150 | 151 | app = FastAPI() 152 | 153 | # Add CORS for frontend 154 | app.add_middleware( 155 | CORSMiddleware, 156 | allow_origins=["*"], # Allow all origins for production 157 | allow_credentials=True, 158 | allow_methods=["*"], 159 | allow_headers=["*"], 160 | ) 161 | 162 | 163 | class AGUIFlowAdapter: 164 | def __init__(self): 165 | self.flow = create_chatbot() 166 | 167 | def _format_research_content(self, content: str) -> str: 168 | """Format research content with clean, professional formatting""" 169 | if not content: 170 | return content 171 | 172 | # Clean up the content first 173 | content = content.strip() 174 | 175 | # Split into paragraphs 176 | paragraphs = content.split('\n\n') 177 | formatted_paragraphs = [] 178 | 179 | for paragraph in paragraphs: 180 | paragraph = paragraph.strip() 181 | if not paragraph: 182 | continue 183 | 184 | # Handle bullet points that start with • 185 | if paragraph.startswith('•'): 186 | # Split multiple bullet points in the same paragraph 187 | bullets = [line.strip() for line in paragraph.split('•') if line.strip()] 188 | for bullet in bullets: 189 | bullet = bullet.strip() 190 | if bullet: 191 | formatted_paragraphs.append(f"• {bullet}") 192 | continue 193 | 194 | # Check for short paragraphs that might be headers 195 | if (len(paragraph) < 80 and 196 | not paragraph.endswith('.') and 197 | not paragraph.endswith('!') and 198 | not paragraph.endswith('?') and 199 | not paragraph.startswith('In ') and 200 | not paragraph.startswith('The ') and 201 | not paragraph.startswith('This ')): 202 | formatted_paragraphs.append(f"## {paragraph}") 203 | continue 204 | 205 | # Regular paragraphs - keep them clean 206 | formatted_paragraphs.append(paragraph) 207 | 208 | return '\n\n'.join(formatted_paragraphs) 209 | 210 | def _emphasize_key_terms(self, text: str) -> str: 211 | """Simple emphasis for key terms - kept for compatibility""" 212 | return text 213 | 214 | async def process_message(self, user_message: str, conversation_history: List[Dict[str, str]] = None): 215 | """Process message through our unified chatbot flow and emit real-time events""" 216 | 217 | # Start event 218 | yield self._create_event("RUN_STARTED", { 219 | "status": "processing", 220 | "message": f"Processing: {user_message}" 221 | }) 222 | 223 | # Reset the listener for new execution 224 | real_time_listener.reset_session() 225 | 226 | # Start the flow execution in a separate task 227 | flow_task = asyncio.create_task(self._run_flow_message(user_message, conversation_history)) 228 | 229 | # Track processed events to avoid duplicates 230 | processed_events = set() 231 | 232 | try: 233 | # Monitor for events while the flow is running 234 | while not flow_task.done(): 235 | # Check for new events 236 | events = real_time_listener.get_events_realtime() 237 | for event in events: 238 | # Create a unique identifier for this event 239 | event_id = f"{event['type']}-{event['timestamp']}" 240 | if event_id not in processed_events: 241 | processed_events.add(event_id) 242 | yield self._create_ag_ui_event(event) 243 | 244 | # Minimal delay to prevent CPU spinning but allow immediate streaming 245 | await asyncio.sleep(0.01) 246 | 247 | # Get the flow result 248 | result = await flow_task 249 | 250 | # Stream any remaining events 251 | events = real_time_listener.get_events_realtime() 252 | for event in events: 253 | event_id = f"{event['type']}-{event['timestamp']}" 254 | if event_id not in processed_events: 255 | processed_events.add(event_id) 256 | yield self._create_ag_ui_event(event) 257 | 258 | if "error" in result: 259 | yield self._create_event("TEXT_MESSAGE_DELTA", { 260 | "content": f"❌ Error: {result['error']}" 261 | }) 262 | else: 263 | # Determine how to format based on intent 264 | intent = result.get("intent", "CHAT") 265 | response = result.get("response", "") 266 | 267 | if intent == "SEARCH": 268 | # Format research response 269 | formatted_content = self._format_research_content(response) 270 | yield self._create_event("TEXT_MESSAGE_DELTA", { 271 | "content": formatted_content 272 | }) 273 | 274 | # Send sources if available 275 | sources = result.get("sources", []) 276 | if sources: 277 | # Handle both old string format and new SourceInfo format 278 | formatted_sources = [] 279 | for source in sources[:5]: 280 | if isinstance(source, str): 281 | # Old format - convert to new format 282 | formatted_sources.append({ 283 | "url": source, 284 | "title": self._extract_domain(source), 285 | "image_url": None, 286 | "snippet": None 287 | }) 288 | elif hasattr(source, 'url'): 289 | # New SourceInfo format 290 | formatted_sources.append({ 291 | "url": source.url, 292 | "title": source.title or self._extract_domain(source.url), 293 | "image_url": getattr(source, 'image_url', None), 294 | "snippet": getattr(source, 'snippet', None) 295 | }) 296 | 297 | yield self._create_event("SOURCES_UPDATE", { 298 | "sources": formatted_sources 299 | }) 300 | else: 301 | # Regular chat response 302 | yield self._create_event("TEXT_MESSAGE_DELTA", { 303 | "content": response 304 | }) 305 | 306 | except Exception as e: 307 | # Cancel the flow task if it's still running 308 | if not flow_task.done(): 309 | flow_task.cancel() 310 | yield self._create_event("TEXT_MESSAGE_DELTA", { 311 | "content": f"❌ Unexpected error: {str(e)}" 312 | }) 313 | 314 | # Finish event 315 | yield self._create_event("RUN_FINISHED", { 316 | "status": "complete" 317 | }) 318 | 319 | async def _run_flow_message(self, user_message: str, conversation_history: List[Dict[str, str]] = None): 320 | """Run the flow message processing in executor""" 321 | def process_flow(): 322 | try: 323 | # Use our simplified flow's process_message method 324 | return self.flow.process_message(user_message, conversation_history) 325 | except Exception as e: 326 | return {"error": str(e)} 327 | 328 | loop = asyncio.get_event_loop() 329 | return await loop.run_in_executor(None, process_flow) 330 | 331 | def _create_event(self, event_type: str, data: Dict[str, Any]): 332 | """Create AG-UI event""" 333 | return { 334 | "type": event_type, 335 | "data": data, 336 | "timestamp": datetime.now().isoformat() 337 | } 338 | 339 | def _create_ag_ui_event(self, stream_event: Dict[str, Any]): 340 | """Convert real-time listener event to AG-UI format""" 341 | event_type_mapping = { 342 | "AGENT_STARTED": "AGENT_STATUS", 343 | "AGENT_FINISHED": "AGENT_STATUS", 344 | "AGENT_ERROR": "AGENT_ERROR", 345 | "TASK_FAILED": "TASK_ERROR", 346 | "TOOL_STARTED": "TOOL_USAGE", 347 | "TOOL_COMPLETED": "TOOL_USAGE", 348 | "TOOL_ERROR": "TOOL_ERROR", 349 | } 350 | 351 | ag_ui_type = event_type_mapping.get(stream_event["type"], "EXECUTION_STATUS") 352 | 353 | # Special handling for streaming chunks 354 | if stream_event["type"] == "LLM_STREAM_CHUNK": 355 | return { 356 | "type": "TEXT_MESSAGE_DELTA", 357 | "data": { 358 | "content": stream_event["data"]["chunk"] 359 | }, 360 | "timestamp": stream_event["timestamp"] 361 | } 362 | 363 | return { 364 | "type": ag_ui_type, 365 | "data": stream_event["data"], 366 | "timestamp": stream_event["timestamp"] 367 | } 368 | 369 | def _extract_domain(self, url: str) -> str: 370 | """Extract a clean domain name from URL""" 371 | try: 372 | from urllib.parse import urlparse 373 | parsed = urlparse(url) 374 | domain = parsed.netloc 375 | # Remove www. if present 376 | if domain.startswith('www.'): 377 | domain = domain[4:] 378 | return domain.capitalize() 379 | except: 380 | return "Source" 381 | 382 | 383 | 384 | # Global adapter 385 | adapter = AGUIFlowAdapter() 386 | 387 | # Root endpoint 388 | @app.get("/") 389 | async def root(): 390 | """Root endpoint""" 391 | return { 392 | "message": "AG-UI CrewAI Research Assistant API", 393 | "version": "1.0.0", 394 | "status": "running", 395 | "endpoints": { 396 | "health": "/health", 397 | "token": "/token", 398 | "agent": "/agent", 399 | "docs": "/docs" 400 | } 401 | } 402 | 403 | @app.get("/health") 404 | async def health_check(): 405 | """Health check endpoint""" 406 | return {"status": "healthy", "message": "Server is running"} 407 | 408 | # Authentication endpoints 409 | @app.post("/token", response_model=Token) 410 | async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): 411 | """Login endpoint to get access token - creates permanent token for Railway deployment""" 412 | user = authenticate_user(fake_users_db, form_data.username, form_data.password) 413 | if not user: 414 | raise HTTPException( 415 | status_code=status.HTTP_401_UNAUTHORIZED, 416 | detail="Incorrect username or password", 417 | headers={"WWW-Authenticate": "Bearer"}, 418 | ) 419 | 420 | # Create permanent token (no expiration) 421 | access_token = create_access_token(data={"sub": user.username}, expires_delta=None) 422 | return {"access_token": access_token, "token_type": "bearer"} 423 | 424 | @app.get("/users/me", response_model=User) 425 | async def read_users_me(current_user: User = Depends(get_current_active_user)): 426 | """Get current user information""" 427 | return current_user 428 | 429 | @app.post("/agent") 430 | async def agent_endpoint(request: Dict[str, Any], current_user: User = Depends(get_current_active_user)): 431 | """Main AG-UI endpoint with real-time event streaming""" 432 | 433 | messages = request.get("messages", []) 434 | if not messages: 435 | return {"error": "No messages"} 436 | 437 | user_message = messages[-1].get("content", "") 438 | if not user_message: 439 | return {"error": "Empty message"} 440 | 441 | # Convert frontend conversation history to backend format 442 | conversation_history = [] 443 | for msg in messages[:-1]: # All messages except the current one 444 | if msg.get("role") == "user": 445 | conversation_history.append({ 446 | "input": msg.get("content", ""), 447 | "response": "", # Will be filled by next assistant message 448 | "type": "chat" 449 | }) 450 | elif msg.get("role") == "assistant": 451 | # Update the last conversation turn with the assistant's response 452 | if conversation_history: 453 | conversation_history[-1]["response"] = msg.get("content", "") 454 | 455 | async def event_stream(): 456 | async for event in adapter.process_message(user_message, conversation_history): 457 | yield f"data: {json.dumps(event)}\n\n" 458 | yield "data: [DONE]\n\n" 459 | 460 | return StreamingResponse( 461 | event_stream(), 462 | media_type="text/event-stream" 463 | ) 464 | 465 | @app.get("/health") 466 | async def health(): 467 | return {"status": "ok"} 468 | 469 | @app.get("/flow/status") 470 | async def flow_status(current_user: User = Depends(get_current_active_user)): 471 | """Get current flow status including real-time event statistics""" 472 | return { 473 | "session_active": adapter.flow.is_session_active(), 474 | "conversation_count": len(adapter.flow.get_conversation_history()), 475 | "real_time_status": real_time_listener.get_session_status() 476 | } 477 | 478 | @app.get("/flow/events") 479 | async def get_pending_events(current_user: User = Depends(get_current_active_user)): 480 | """Get any pending real-time events""" 481 | return { 482 | "events": real_time_listener.get_events_realtime(), 483 | "session_status": real_time_listener.get_session_status() 484 | } 485 | 486 | @app.post("/flow/reset") 487 | async def reset_flow(current_user: User = Depends(get_current_active_user)): 488 | """Reset the flow and start a new chat session""" 489 | adapter.flow.start_new_chat() 490 | return {"status": "reset", "message": "New chat session started"} 491 | 492 | @app.post("/flow/new-chat") 493 | async def start_new_chat(current_user: User = Depends(get_current_active_user)): 494 | """Start a new chat session, clearing all history""" 495 | adapter.flow.start_new_chat() 496 | return { 497 | "status": "new_chat", 498 | "message": "New chat session started", 499 | "session_id": adapter.flow.get_session_id() 500 | } 501 | 502 | if __name__ == "__main__": 503 | import uvicorn 504 | uvicorn.run(app, host="0.0.0.0", port=8000) -------------------------------------------------------------------------------- /frontend/src/components/ChatInterface.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useRef, useEffect, useCallback } from "react" 4 | import ReactMarkdown from 'react-markdown' 5 | import remarkGfm from 'remark-gfm' 6 | import rehypeRaw from 'rehype-raw' 7 | import { useToken } from '../contexts/TokenContext' 8 | 9 | interface Source { 10 | url: string 11 | title: string 12 | image_url?: string 13 | snippet?: string 14 | } 15 | 16 | interface Message { 17 | id: string 18 | content: string 19 | role: "user" | "assistant" 20 | timestamp: Date 21 | sources?: Source[] 22 | isSearching?: boolean 23 | } 24 | 25 | interface ChatState { 26 | processing: boolean 27 | currentAction: string 28 | conversationHistory: unknown[] 29 | sessionEnded?: boolean 30 | lastEventUpdate?: number 31 | } 32 | 33 | interface ExecutionEvent { 34 | type: string 35 | data: { 36 | message: string 37 | agent_role?: string 38 | tool_name?: string 39 | model?: string 40 | query?: string 41 | status?: string 42 | crew_name?: string 43 | execution_time?: number 44 | token_usage?: { 45 | total_tokens?: number 46 | prompt_tokens?: number 47 | completion_tokens?: number 48 | } 49 | } 50 | timestamp: string 51 | agent_id?: string 52 | session_id?: string 53 | } 54 | 55 | // Utility function to safely handle URLs 56 | const getSafeUrl = (url: string) => { 57 | try { 58 | new URL(url); 59 | return url; 60 | } catch { 61 | return '#'; 62 | } 63 | } 64 | 65 | 66 | 67 | const isValidUrl = (url: string) => { 68 | try { 69 | new URL(url); 70 | return true; 71 | } catch { 72 | return false; 73 | } 74 | } 75 | 76 | const extractDomain = (url: string) => { 77 | try { 78 | const domain = new URL(url).hostname; 79 | // Remove www. if present 80 | return domain.replace(/^www\./, ''); 81 | } catch { 82 | return 'Source'; 83 | } 84 | } 85 | 86 | export function ChatInterface() { 87 | const { token } = useToken() 88 | const [messages, setMessages] = useState([]) 89 | const [input, setInput] = useState("") 90 | const [isLoading, setIsLoading] = useState(false); 91 | const [eventMessages, setEventMessages] = useState([]); 92 | const [eventMessageId, setEventMessageId] = useState(0); 93 | const [chatState, setChatState] = useState({ 94 | processing: false, 95 | currentAction: "Ready to assist", 96 | conversationHistory: [], 97 | sessionEnded: false, 98 | lastEventUpdate: Date.now() 99 | }) 100 | const [showClearDialog, setShowClearDialog] = useState(false) 101 | const [executionEvents, setExecutionEvents] = useState([]) 102 | const messagesEndRef = useRef(null) 103 | 104 | const scrollToBottom = () => { 105 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) 106 | } 107 | 108 | useEffect(() => { 109 | scrollToBottom() 110 | }, [messages]) 111 | 112 | const handleNewChat = useCallback(() => { 113 | if (messages.length > 0) { 114 | setShowClearDialog(true) 115 | } 116 | }, [messages.length]) 117 | 118 | // Keyboard shortcut for new chat (Ctrl+N or Cmd+N) 119 | useEffect(() => { 120 | const handleKeyDown = (e: KeyboardEvent) => { 121 | if ((e.ctrlKey || e.metaKey) && e.key === 'n') { 122 | e.preventDefault() 123 | handleNewChat() 124 | } 125 | // ESC to close dialog 126 | if (e.key === 'Escape' && showClearDialog) { 127 | setShowClearDialog(false) 128 | } 129 | } 130 | 131 | window.addEventListener('keydown', handleKeyDown) 132 | return () => window.removeEventListener('keydown', handleKeyDown) 133 | }, [showClearDialog, messages.length, handleNewChat]) 134 | 135 | const clearChat = async () => { 136 | try { 137 | // Call backend to start new chat session 138 | const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; 139 | const headers: Record = { 140 | 'Content-Type': 'application/json', 141 | }; 142 | 143 | if (token) { 144 | headers['Authorization'] = `Bearer ${token}`; 145 | } 146 | 147 | await fetch(`${backendUrl}/flow/new-chat`, { 148 | method: 'POST', 149 | headers, 150 | }); 151 | } catch (error) { 152 | console.error('Error starting new chat session:', error); 153 | } 154 | 155 | // Clear frontend state 156 | setMessages([]) 157 | setIsLoading(false) 158 | setExecutionEvents([]) 159 | setChatState({ 160 | processing: false, 161 | currentAction: "Ready to assist", 162 | conversationHistory: [], 163 | sessionEnded: false, 164 | lastEventUpdate: Date.now() 165 | }) 166 | setShowClearDialog(false) 167 | } 168 | 169 | const handleSSEMessage = useCallback((event: MessageEvent) => { 170 | try { 171 | const data = JSON.parse(event.data); 172 | 173 | if (data.type === 'TEXT_MESSAGE_DELTA') { 174 | // Handle streaming text content 175 | const content = data.data?.content; 176 | if (content) { 177 | setMessages(prev => { 178 | const lastMessage = prev[prev.length - 1]; 179 | if (lastMessage && lastMessage.role === 'assistant') { 180 | // Update existing assistant message 181 | return prev.map((msg, index) => 182 | index === prev.length - 1 183 | ? { ...msg, content: msg.content + content } 184 | : msg 185 | ); 186 | } else { 187 | // Create new assistant message 188 | return [...prev, { 189 | id: Date.now().toString(), 190 | role: 'assistant', 191 | content: content, 192 | timestamp: new Date() 193 | }]; 194 | } 195 | }); 196 | } 197 | } else if (data.type === 'SOURCES_UPDATE') { 198 | // Handle sources update 199 | const sources = data.data?.sources; 200 | if (sources) { 201 | setMessages(prev => { 202 | const lastMessage = prev[prev.length - 1]; 203 | if (lastMessage && lastMessage.role === 'assistant') { 204 | return prev.map((msg, index) => 205 | index === prev.length - 1 206 | ? { ...msg, sources: sources } 207 | : msg 208 | ); 209 | } 210 | return prev; 211 | }); 212 | } 213 | } else if (data.type === 'EXECUTION_STATUS' || data.type === 'AGENT_STATUS' || data.type === 'TASK_STATUS' || 214 | data.type === 'TOOL_USAGE' || data.type === 'LLM_STATUS') { 215 | // Handle real-time execution events (agent, thoughts, tools) 216 | const message = data.data?.message; 217 | if (message) { 218 | // Add a small delay to ensure proper sequencing 219 | setTimeout(() => { 220 | setEventMessages(prev => { 221 | // Only add if it's not already in the list 222 | if (!prev.includes(message)) { 223 | console.log('Adding event:', message); // Debug log 224 | return [...prev, message]; 225 | } 226 | return prev; 227 | }); 228 | setEventMessageId(prev => prev + 1); 229 | }, 500); // 500ms delay for natural progression 230 | } 231 | } else if (data.type === 'RUN_FINISHED') { 232 | // Clear the event messages when execution completes 233 | setEventMessages([]); 234 | setIsLoading(false); 235 | } else if (data.type === 'RUN_ERROR' || data.type === 'AGENT_ERROR' || data.type === 'TASK_ERROR') { 236 | setEventMessages([]); 237 | setIsLoading(false); 238 | setMessages(prev => [...prev, { 239 | id: Date.now().toString(), 240 | role: 'assistant', 241 | content: 'Sorry, there was an error processing your request.', 242 | timestamp: new Date() 243 | }]); 244 | } 245 | } catch (error) { 246 | console.error('Error parsing SSE message:', error); 247 | } 248 | }, []); 249 | 250 | const handleSubmit = async (e: React.FormEvent) => { 251 | e.preventDefault(); 252 | if (!input.trim()) return; 253 | 254 | const userMessage = input.trim(); 255 | setInput(''); 256 | setMessages(prev => [...prev, { 257 | id: Date.now().toString(), 258 | role: 'user', 259 | content: userMessage, 260 | timestamp: new Date() 261 | }]); 262 | setIsLoading(true); 263 | setEventMessages([]); // Clear any previous event messages 264 | 265 | try { 266 | const headers: Record = { 'Content-Type': 'application/json' }; 267 | 268 | if (token) { 269 | headers['Authorization'] = `Bearer ${token}`; 270 | } 271 | 272 | const response = await fetch('/api/chat', { 273 | method: 'POST', 274 | headers, 275 | body: JSON.stringify({ messages: [ 276 | ...messages.map(m => ({ role: m.role, content: m.content })), 277 | { role: 'user', content: userMessage } 278 | ] }), 279 | }); 280 | 281 | if (!response.ok) { 282 | throw new Error('Failed to fetch'); 283 | } 284 | 285 | const reader = response.body?.getReader(); 286 | if (!reader) { 287 | throw new Error('No response body'); 288 | } 289 | 290 | const decoder = new TextDecoder(); 291 | let buffer = ''; 292 | 293 | while (true) { 294 | const { done, value } = await reader.read(); 295 | if (done) break; 296 | 297 | buffer += decoder.decode(value, { stream: true }); 298 | const lines = buffer.split('\n'); 299 | buffer = lines.pop() || ''; 300 | 301 | for (const line of lines) { 302 | if (line.startsWith('data: ')) { 303 | const data = line.slice(6); 304 | if (data === '[DONE]') { 305 | setIsLoading(false); 306 | setEventMessages([]); 307 | return; 308 | } 309 | try { 310 | const parsed = JSON.parse(data); 311 | handleSSEMessage({ data: JSON.stringify(parsed) } as MessageEvent); 312 | } catch (e) { 313 | console.error('Error parsing SSE data:', e); 314 | } 315 | } 316 | } 317 | } 318 | } catch (error) { 319 | console.error('Error:', error); 320 | setIsLoading(false); 321 | setEventMessages([]); 322 | setMessages(prev => [...prev, { 323 | id: Date.now().toString(), 324 | role: 'assistant', 325 | content: 'Sorry, there was an error processing your request.', 326 | timestamp: new Date() 327 | }]); 328 | } 329 | }; 330 | 331 | const sendMessage = (content: string) => { 332 | setInput(content); 333 | // Use setTimeout to ensure state is updated before submission 334 | setTimeout(() => { 335 | const formEvent = new Event('submit', { bubbles: true, cancelable: true }); 336 | const form = document.querySelector('form'); 337 | if (form) { 338 | form.dispatchEvent(formEvent); 339 | } 340 | }, 0); 341 | } 342 | 343 | const handleQuickAction = (text: string) => { 344 | sendMessage(text) 345 | } 346 | 347 | return ( 348 |
349 | {/* Header */} 350 |
351 |
352 |
353 |
354 | 355 | 356 | 357 | 358 |
359 |
360 |

361 | AI Research Assistant 362 |

363 |

CrewAI + AG-UI Protocol Implementation

364 |
365 |
366 | 367 | {/* Action Buttons */} 368 |
369 | {/* New Chat Button - Only show when there are messages */} 370 | {messages.length > 0 && ( 371 | 381 | )} 382 | 383 |
384 |
385 |
386 | 387 | {/* Chat Messages Area */} 388 |
389 | {messages.length === 0 ? ( 390 | // Welcome Screen 391 |
392 |
393 |
394 |

395 | AI Research Assistant 396 |

397 |

398 | A cutting-edge implementation showcasing CrewAI for 399 | orchestrated AI agents and AG-UI Protocol for 400 | real-time streaming interactions. 401 |

402 | 403 | {/* Technical Badges */} 404 |
405 |
406 |
407 | 408 | 409 | 410 | 411 | 412 | 413 | CrewAI 414 |
415 |
416 |
417 |
418 | 419 | 420 | 421 | AG-UI Protocol 422 |
423 |
424 |
425 |
426 | 427 | 428 | 429 | Real-time Streaming 430 |
431 |
432 |
433 |
434 | 435 | 436 | 437 | Source Attribution 438 |
439 |
440 |
441 |
442 | 443 | {/* Quick Actions */} 444 |
445 | 458 |
459 |
460 |
461 | ) : ( 462 | // Chat Messages 463 |
464 | {messages.map((message, index) => ( 465 |
466 |
471 |
472 | {/* Enhanced Sources Display */} 473 | {message.role === 'assistant' && !message.isSearching && message.content && message.sources && message.sources.length > 0 && ( 474 |
475 |
476 |
477 |
478 | 479 | 480 | 481 |
482 | Sources 483 | 484 | {message.sources.length} found 485 | 486 |
487 |
488 |
489 | {message.sources.map((source, index) => ( 490 | { 497 | if (!isValidUrl(source.url)) { 498 | e.preventDefault(); 499 | } 500 | }} 501 | > 502 | 503 | 504 | {/* Content */} 505 |
506 | {/* Image if available */} 507 | {source.image_url && ( 508 |
509 | {source.title} { 514 | // Hide image on error 515 | e.currentTarget.style.display = 'none'; 516 | }} 517 | /> 518 | {/* Image overlay for better text readability */} 519 |
520 |
521 | )} 522 | 523 | {/* Source name/publisher on top (like Perplexity) */} 524 |
525 | {extractDomain(source.url)} 526 |
527 | 528 | {/* Title below source name */} 529 |
530 | {source.title} 531 |
532 | 533 | {/* Snippet if available */} 534 | {source.snippet && ( 535 |
536 | {source.snippet} 537 |
538 | )} 539 |
540 | 541 | {/* Hover effect overlay */} 542 |
543 |
544 | ))} 545 |
546 |
547 | )} 548 | 549 | {/* Real-time execution status - shown instead of generic "Searching..." */} 550 | {message.role === 'assistant' && chatState.processing && index === messages.length - 1 && ( 551 |
552 | {executionEvents.length > 0 ? ( 553 | executionEvents.slice(-3).map((event, eventIndex) => ( 554 |
559 |
560 | 561 | {event.data.message} 562 | 563 |
564 | )) 565 | ) : ( 566 |
567 |
568 | 569 | {message.isSearching ? "Initializing research..." : ""} 570 | 571 |
572 | )} 573 |
574 | )} 575 | 576 | {/* Enhanced message content */} 577 | {message.content && ( 578 |
579 |

{children}

, 584 | h2: ({children}) =>

{children}

, 585 | h3: ({children}) =>

{children}

, 586 | p: ({children}) =>

{children}

, 587 | ul: ({children}) =>
    {children}
, 588 | ol: ({children}) =>
    {children}
, 589 | li: ({children}) => ( 590 |
  • 591 | 592 | {children} 593 |
  • 594 | ), 595 | strong: ({children}) => {children}, 596 | em: ({children}) => {children}, 597 | code: ({children}) => {children}, 598 | pre: ({children}) =>
    {children}
    , 599 | blockquote: ({children}) => ( 600 |
    601 | {children} 602 |
    603 | ), 604 | a: ({href, children}) => ( 605 | 611 | {children} 612 | 613 | ), 614 | // Handle tables 615 | table: ({children}) => ( 616 |
    617 | 618 | {children} 619 |
    620 |
    621 | ), 622 | th: ({children}) => ( 623 | 624 | {children} 625 | 626 | ), 627 | td: ({children}) => ( 628 | 629 | {children} 630 | 631 | ), 632 | // Handle horizontal rules 633 | hr: () => ( 634 |
    635 | ), 636 | // Handle h4 headers 637 | h4: ({children}) =>

    {children}

    , 638 | 639 | }} 640 | > 641 | {message.content.replace(/##\s*##/g, '##')} 642 |
    643 |
    644 | )} 645 |
    646 |
    647 |
    648 | ))} 649 | 650 | {/* Real-time Execution Status - Shows all internal process steps */} 651 | {isLoading && eventMessages.length > 0 && ( 652 |
    653 | {eventMessages.map((message, index) => ( 654 |
    665 |
    670 | {message} 671 | {index === eventMessages.length - 1 && ( 672 |
    673 | )} 674 |
    675 | ))} 676 |
    677 | )} 678 | 679 | {/* Loading Spinner - Shows when loading but no specific event messages */} 680 | {isLoading && eventMessages.length === 0 && ( 681 |
    682 |
    683 |
    684 | 685 |
    686 |
    687 | )} 688 | 689 |
    690 |
    691 | )} 692 |
    693 | 694 | {/* Chat Input */} 695 |
    696 |
    697 |
    698 | 699 | 700 |
    701 |
    702 | 703 | {/* Confirmation Dialog */} 704 | {showClearDialog && ( 705 |
    706 |
    707 |
    708 |
    709 | 710 | 711 | 712 |
    713 |

    Start New Chat?

    714 |
    715 |

    716 | This will clear your current conversation with {messages.length} messages. 717 |

    718 |

    719 | Are you sure you want to start fresh? 720 |

    721 |
    722 | 728 | 734 |
    735 |
    736 |
    737 | )} 738 |
    739 | ) 740 | } 741 | 742 | function ChatInput({ 743 | onSend, 744 | isLoading 745 | }: { 746 | onSend: (content: string) => void 747 | isLoading: boolean 748 | }) { 749 | const [content, setContent] = useState("") 750 | const inputRef = useRef(null) 751 | 752 | const handleSendMessage = () => { 753 | if (content.trim()) { 754 | onSend(content) 755 | setContent("") 756 | } 757 | } 758 | 759 | const handleKeyPress = (e: React.KeyboardEvent) => { 760 | if (e.key === 'Enter') { 761 | handleSendMessage() 762 | } 763 | } 764 | 765 | return ( 766 |
    767 |
    768 | setContent(e.target.value)} 773 | onKeyPress={handleKeyPress} 774 | placeholder="Ask me anything..." 775 | disabled={isLoading} 776 | className="w-full enhanced-input p-4 rounded-2xl text-foreground placeholder-muted-foreground disabled:opacity-50 transition-all duration-300" 777 | /> 778 |
    779 | 780 | 796 |
    797 | ) 798 | } --------------------------------------------------------------------------------