diff --git a/README.md b/README.md index 97526c0..363e055 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,88 @@ -# Outbound AI - -Outbound AI is a comprehensive platform designed to manage and monitor AI missions, agents, and reviews. It features a modern dashboard for real-time tracking and control. - -## Tech Stack - -### Frontend -- **Framework**: React (Vite) -- **Language**: TypeScript -- **Styling**: TailwindCSS, shadcn-ui -- **State/Data**: TanStack Query -- **Authentication**: Clerk -- **Visualization**: Recharts - -### Backend -- **Framework**: FastAPI -- **Database**: MongoDB (Beanie OD) -- **AI/LLM**: LangChain, LangGraph -- **Search**: Firecrawl -- **Utilities**: Pydantic - -## Key Features - -- **Launchpad**: Initiate and configure new missions. -- **Mission Chat**: Real-time interaction and monitoring of active missions. -- **Review Queue**: Interface for human-in-the-loop review of AI actions. -- **Active Agents**: Dashboard to view and manage currently running agents. -- **Detailed Analytics**: Visualizations of mission performance and metrics. - -## Setup Instructions - -### Frontend -1. Navigate to the frontend directory: - ```bash - cd frontend - ``` -2. Install dependencies: - ```bash - npm install - ``` -3. Start the development server: - ```bash - npm run dev - ``` - -### Backend -1. Navigate to the backend directory: - ```bash - cd backend - ``` -2. Create and activate a virtual environment (optional but recommended): - ```bash - python -m venv venv - # Windows - .\venv\Scripts\activate - # macOS/Linux - source venv/bin/activate - ``` -3. Install dependencies: - ```bash - pip install -r requirements.txt - ``` -4. Start the server: - ```bash - uvicorn main:app --reload - ``` +# πŸš€ Outbound AI +### The Operating System for Autonomous Sales Teams + +![Hero Image](https://placehold.co/1200x400/1e293b/ffffff?text=Outbound+AI+Mission+Control) + +> **Stop prospecting manually. Let AI build your pipeline.** +> Outbound AI is the first autonomous SDR platform that plans, researches, and executes complex outbound campaigns with human-like precision at 100x scale. + +--- + +## ⚑ Why Outbound AI? + +Sales teams are drowning in manual tasksβ€”finding emails, researching leads, and writing personalized notes. **Outbound AI** replaces the grunt work with intelligent agents that act as your virtual salesforce. + +- **10x Faster Prospecting**: Agents scan millions of profiles in minutes, not months. +- **Hyper-Personalization**: Every email is crafted using real-time insights from news, LinkedIn, and company reports. +- **Human-in-the-Loop**: You maintain control. AI drafts the outreach; you approve it in the Review Queue. + +--- + +## πŸ’Ž Core Features + +### 🧠 Spatial Mission Control +Forget complex filters. Just talk to your AI. +> _"Find me Series A founders in NYC who recently hired a VP of Sales."_ +Your AI understands natural language and launches a tailored mission immediately. + +### πŸ•΅οΈβ€β™‚οΈ Autonomous Agents +Watch as your army of agents goes to work. They: +- Scrape web data via **Firecrawl**. +- Verify contact details via **Search**. +- Reason and plan via **LangGraph**. + +### βœ… Smart Review Queue +Quality assurance is built-in. Review high-stakes communications before they send, or let the AI run on autopilot for lower-risk tiers. + +### 🌐 The "Live Brain" +Real-time knowledge graph that learns from every interaction, keeping your prospect data fresh and your context relevant. + +--- + +## πŸ› οΈ Technology Stack + +Built for speed, scalability, and developer joy. + +| Component | Tech | +|-----------|------| +| **Frontend** | React, Vite, TailwindCSS, shadcn-ui, TanStack Query | +| **Backend** | FastAPI, Python 3.12+, AsyncIO | +| **Database** | MongoDB (Beanie ODM) | +| **AI Engine** | LangChain, LangGraph, Groq/OpenAI | +| **Search** | Firecrawl | + +--- + +## πŸš€ Getting Started + +Launch your first mission involved setting up the "Brain" and the Interface. + +### Prerequisites +- Python 3.12+ +- Node.js 18+ +- MongoDB Instance + +### Quick Start +We found a magic script for you. + +```bash +# Clone the repo +git clone https://github.com/BEASTSHRIRAM/OutboundAI.git + +# Enter directory +cd outbound-ai + +# Make the start script executable +chmod +x start_dev.sh + +# Ignite πŸš€ +./start_dev.sh +``` + +The **Backend** will come alive at `http://localhost:8000` (API Docs). +The **Frontend** will be ready at `http://localhost:5173`. + +--- + +## πŸ›‘οΈ License +Private & Confidential. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8efb68e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y gcc curl && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for cache +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Run entrypoint +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/core/agent.py b/backend/app/core/agent.py index 5ed3047..1085af2 100644 --- a/backend/app/core/agent.py +++ b/backend/app/core/agent.py @@ -1,60 +1,533 @@ +""" +LangGraph Agentic Automation Engine +HARD CONTRACT - Fixed Graph Shape + +WORKFLOW: +ENTRY β†’ initial_triage β†’ resolve_person β†’ resolve_channel_identity β†’ route_by_intent + β”œβ”€ discovery_flow + β”œβ”€ outreach_flow + └─ publish_flow +β†’ review_queue (ONLY if draft_required == true) β†’ execute_action β†’ post_action_update β†’ END +""" import asyncio +import json +import re import httpx -from typing import TypedDict, Annotated, List, Dict +from typing import TypedDict, Annotated, List, Dict, Optional, Literal from langgraph.graph import StateGraph, END from langgraph.checkpoint.memory import MemorySaver -from app.models import Draft, Prospect, Mission, DraftStatus, MissionLog, User -from app.core.config import settings +from langchain_groq import ChatGroq +from langchain_core.messages import SystemMessage, HumanMessage from datetime import datetime -# Define Agent State +from app.models import Draft, Prospect, Mission, DraftStatus, MissionLog, User, Agent, AgentStats +from app.core.config import settings +from app.services.neo4j import neo4j_service + +# ================================================== +# AGENT STATE (Fixed Shape) +# ================================================== + class AgentState(TypedDict): + # Core identifiers mission_id: str - objective: str user_id: str - attachments: List[Dict] # Attachments from launchpad - prospects: List[Dict] # List of prospect data found - current_prospect: Dict # Valid prospect being processed - draft_id: str - feedback: str # Human feedback for revision + objective: str + + # Triage output (LLM decides) + intents: List[str] # ["discovery", "outreach", "publish", "read", "query"] + channels: List[str] # ["/linkedin", "/reddit", "/slack", "/gmail", "/twitter", "/github"] + required_tools: List[str] # ["composio_linkedin", "gmail"] + draft_required: bool # TRUE only for write/send/post/create actions + + # Resolution state + person_name: Optional[str] + person_id: Optional[str] # Neo4j person ID + channel_identities: Dict[str, str] # {"/linkedin": "member_id", "/slack": "user_id"} + + # Flow state + missing_info: List[str] # What's blocking progress + pause_reason: Optional[str] # Why graph is paused + + # Data + attachments: List[Dict] + prospects: List[Dict] + current_prospect: Dict + + # Draft state + draft_id: Optional[str] + draft_content: Dict # {channel, subject, body, metadata} + feedback: Optional[str] # Human feedback for revision + + # Execution state + execution_result: Dict # Result from execute_action + action_status: str # "pending", "sent", "failed" -# Real Tools -from langchain_groq import ChatGroq -from langchain_core.messages import SystemMessage, HumanMessage -async def log_event(mission_id: str, user_id: str, content: str, log_type: str = "action", role: str = "agent", metadata: Dict = {}): - """Log an event to DB and broadcast via WebSocket""" - # Save to database - log = MissionLog( - mission_id=mission_id, - role=role, - content=content, - log_type=log_type, - metadata=metadata - ) - await log.insert() +# ================================================== +# UTILITY FUNCTIONS +# ================================================== + +def extract_person_name(objective: str) -> Optional[str]: + """Extract person name from objective using heuristics""" + patterns = [ + r"(?:message|email|contact|send to|reach out to|dm|ping) ([A-Z][a-z]+(?: [A-Z][a-z]+)?)", + r"([A-Z][a-z]+(?: [A-Z][a-z]+)?)'s (?:LinkedIn|email|Twitter|Slack)", + r"(?:did|has|when did) ([A-Z][a-z]+(?: [A-Z][a-z]+)?) (?:reply|respond|message)", + ] + + for pattern in patterns: + match = re.search(pattern, objective, re.IGNORECASE) + if match: + return match.group(1) + return None + + +def extract_channel_from_objective(objective: str) -> List[str]: + """Detect channel mentions in objective""" + channels = [] + channel_keywords = { + "/linkedin": ["linkedin", "li message", "linkedin message"], + "/twitter": ["twitter", "tweet", "x.com"], + "/reddit": ["reddit", "subreddit", "r/"], + "/slack": ["slack", "slack message", "dm on slack"], + "/github": ["github", "gh issue", "pull request", "pr"], + "/gmail": ["email", "gmail", "send email", "mail"], + } + + obj_lower = objective.lower() + for channel, keywords in channel_keywords.items(): + if any(kw in obj_lower for kw in keywords): + channels.append(channel) + + return channels if channels else ["/gmail"] # Default to email + + +def extract_identifiers_from_objective(objective: str) -> Dict[str, str]: + """Extract platform identifiers from objective text""" + identifiers = {} + + # LinkedIn URL + linkedin_match = re.search(r"(https?://(?:www\.)?linkedin\.com/in/[^\s]+)", objective) + if linkedin_match: + identifiers["linkedin"] = linkedin_match.group(1) + + # Twitter handle + twitter_match = re.search(r"@([a-zA-Z0-9_]+)", objective) + if twitter_match: + identifiers["twitter"] = twitter_match.group(1) + + # Email + email_match = re.search(r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", objective) + if email_match: + identifiers["gmail"] = email_match.group(1) + + # GitHub username (from URL or mention) + github_match = re.search(r"github\.com/([a-zA-Z0-9_-]+)", objective) + if github_match: + identifiers["github"] = github_match.group(1) + + # Reddit username + reddit_match = re.search(r"u/([a-zA-Z0-9_-]+)", objective) + if reddit_match: + identifiers["reddit"] = reddit_match.group(1) + + return identifiers + +# ================================================== +# UTILITY FUNCTIONS (continued) +# ================================================== + +async def log_event( + mission_id: str, + user_id: str, + content: str, + log_type: str = "action", + role: str = "agent", + metadata: Dict = {}, + target: str = "chat" # "chat" = shows in mission chat, "brain" = shows only in LiveBrain +): + """Log an event to DB and broadcast via WebSocket + + Args: + target: "chat" shows in both chat and LiveBrain, "brain" shows only in LiveBrain sidebar + """ + # Only save to DB if it's a chat message (user-facing) + if target == "chat": + log = MissionLog( + mission_id=mission_id, + role=role, + content=content, + log_type=log_type, + metadata=metadata + ) + await log.insert() - # Broadcast via WebSocket try: - from main import get_connection_manager + from app.core.socket import get_connection_manager manager = get_connection_manager() await manager.send_to_user(user_id, { "type": log_type, "message": content, "agent": "OutboundAI", "mission_id": mission_id, - "metadata": metadata + "metadata": metadata, + "target": target # Frontend uses this to route to appropriate UI }) except Exception as e: print(f"[WS] Failed to broadcast: {e}") -async def scout_prospects(state: AgentState): - """Layer 1: Public Data Discovery (Role-Agnostic) with improved queries""" - await log_event(state["mission_id"], state["user_id"], f"Layer 1: Scouting public contexts for: {state['objective']}", "thinking") + +async def update_agent_stats(user_id: str, processed=0, queued=0, errors=0): + """Update stats for the user's active agents and broadcast to frontend""" + try: + agents = await Agent.find(Agent.user_id == user_id, Agent.status == "active").to_list() + if not agents: + agents = await Agent.find(Agent.user_id == user_id).limit(1).to_list() + + now = datetime.utcnow() + for agent in agents: + if not agent.stats: + agent.stats = AgentStats() + + agent.stats.processed += processed + agent.stats.queued += queued + agent.stats.errors += errors + agent.stats.last_run_at = now + await agent.save() + + all_agents = await Agent.find(Agent.user_id == user_id).to_list() + total_active = sum(1 for a in all_agents if a.status == "active") + total_processed = sum(a.stats.processed for a in all_agents if a.stats) + total_queued = sum(a.stats.queued for a in all_agents if a.stats) + + from app.core.socket import get_connection_manager + manager = get_connection_manager() + await manager.send_to_user(user_id, { + "type": "stats_update", + "stats": { + "active": total_active, + "processed": total_processed, + "queue": total_queued + } + }) + + except Exception as e: + print(f"Failed to update agent stats: {e}") + + +# ================================================== +# NODE 1: INITIAL_TRIAGE (LLM) +# ================================================== + +async def initial_triage(state: AgentState) -> Dict: + """ + PURPOSE: + - Understand intent semantically + - Classify action type + - Infer platforms & tools + - Decide if draft is required + + NO keyword matching + NO API calls + NO database calls + + Output JSON ONLY + """ + objective = state["objective"] + mission_id = state["mission_id"] + user_id = state["user_id"] + + await log_event(mission_id, user_id, "Analyzing your request...", "thinking") + + try: + system_prompt = """You are an Intent Classification Agent for an automation system. + +Analyze the user's request and determine: +1. What they are trying to do (intents) +2. Which channels/platforms are involved +3. What tools are required +4. Whether content needs to be drafted (for human approval) + +INTENT CATEGORIES: +- "discovery": Finding, searching, collecting people or data +- "outreach": Contacting, messaging, emailing someone directly +- "publish": Posting content publicly (tweets, LinkedIn posts, Reddit posts) +- "read": Reading/fetching existing data (get my emails, show messages) +- "query": Asking questions about data (did X reply?, what's the status?) + +CHANNEL MAPPING: +- LinkedIn messages/posts β†’ /linkedin +- Twitter/X tweets/replies β†’ /twitter +- Reddit posts/comments β†’ /reddit +- Slack messages β†’ /slack +- GitHub issues/PRs β†’ /github +- Email β†’ /gmail + +CRITICAL RULES FOR draft_required: +- draft_required = TRUE for: send, post, create, write, publish, message, email, tweet, comment +- draft_required = FALSE for: read, get, fetch, show, list, check, query, search, find + +Return ONLY valid JSON: +{ + "intents": ["discovery", "outreach"], + "channels": ["/linkedin", "/gmail"], + "required_tools": ["linkedin", "gmail"], + "draft_required": true, + "person_mentioned": "Name or null" +}""" + + llm = ChatGroq( + temperature=0.0, + groq_api_key=settings.GROQ_API_KEY, + model_name="llama-3.1-8b-instant" + ) + + messages = [ + SystemMessage(content=system_prompt), + HumanMessage(content=f"User request: {objective}") + ] + + response = await llm.ainvoke(messages) + content = response.content.strip() + + # Clean JSON + if "```json" in content: + content = content.split("```json")[1].split("```")[0].strip() + elif "```" in content: + content = content.replace("```", "").strip() + + data = json.loads(content) + + intents = data.get("intents", ["discovery"]) + channels = data.get("channels", extract_channel_from_objective(objective)) + required_tools = data.get("required_tools", []) + draft_required = data.get("draft_required", False) + person_name = data.get("person_mentioned") or extract_person_name(objective) + + # Technical info goes to LiveBrain only, not chat + await log_event( + mission_id, user_id, + f"Intent: {intents} | Channels: {channels} | Draft needed: {draft_required}", + "thinking", + target="brain" + ) + + return { + "intents": intents, + "channels": channels, + "required_tools": required_tools, + "draft_required": draft_required, + "person_name": person_name, + "missing_info": [] + } + + except Exception as e: + print(f"Intent classification failed: {e}") + # Fail safe - infer from objective + channels = extract_channel_from_objective(objective) + person_name = extract_person_name(objective) + + # Simple heuristic for draft_required + write_keywords = ["send", "post", "create", "write", "message", "email", "tweet", "dm"] + draft_required = any(kw in objective.lower() for kw in write_keywords) + + return { + "intents": ["outreach"] if draft_required else ["read"], + "channels": channels, + "required_tools": [c.replace("/", "") for c in channels], + "draft_required": draft_required, + "person_name": person_name, + "missing_info": [] + } + + +# ================================================== +# NODE 2: RESOLVE_PERSON (Deterministic) +# ================================================== + +async def resolve_person(state: AgentState) -> Dict: + """ + PURPOSE: + - Query Neo4j for person + - Create person if missing + - Handle ambiguity once, then cache + + NEVER call LLM + """ + person_name = state.get("person_name") + mission_id = state["mission_id"] + user_id = state["user_id"] + + if not person_name: + # No person mentioned, skip + return {"person_id": None} + + await log_event(mission_id, user_id, f"Looking up {person_name} in contact graph...", "thinking") + + try: + # Query Neo4j - deterministic, no LLM + person = neo4j_service.resolve_person(person_name) + + if person: + person_id = person.get("name", person_name) # Use name as ID for now + await log_event(mission_id, user_id, f"Found {person_name} in contact graph", "success") + return {"person_id": person_id} + else: + # Person created by resolve_person (MERGE) + await log_event(mission_id, user_id, f"Added {person_name} to contact graph", "success") + return {"person_id": person_name} + + except Exception as e: + print(f"Neo4j person resolution failed: {e}") + return {"person_id": None} + + +# ================================================== +# NODE 3: RESOLVE_CHANNEL_IDENTITY +# ================================================== + +async def resolve_channel_identity(state: AgentState) -> Dict: + """ + PURPOSE: + - Resolve platform-specific identifiers + + Examples: + - /linkedin β†’ member_id + - /twitter β†’ handle + - /slack β†’ user_id + + If identifier missing β†’ Pause and ask user ONCE + """ + channels = state.get("channels", []) + person_name = state.get("person_name") + mission_id = state["mission_id"] + user_id = state["user_id"] + objective = state["objective"] + + channel_identities = {} + missing_identities = [] + + # Get existing contact methods from Neo4j if person is known + existing_contacts = {} + if person_name: + existing_contacts = neo4j_service.get_contact_methods(person_name) + + # Also extract identifiers from objective + extracted = extract_identifiers_from_objective(objective) + + for channel in channels: + channel_key = channel.replace("/", "") + + # Check Neo4j first + if channel_key in existing_contacts: + channel_identities[channel] = existing_contacts[channel_key] + continue + + # Check extracted from objective + if channel_key in extracted: + identifier = extracted[channel_key] + channel_identities[channel] = identifier + + # Store in Neo4j for future + if person_name: + neo4j_service.add_contact_method(person_name, channel_key, identifier) + await log_event(mission_id, user_id, f"Saved {channel_key} for {person_name}", "success") + continue + + # Check intents to determine if we need a person identifier + intents = state.get("intents", []) + + # For READ/QUERY operations, we may not need person identifier + if "read" in intents or "query" in intents: + # For read operations on user's own accounts, no target identifier needed + continue + + # For PUBLISH (public posts), we don't need a person identifier + # Public posts go to platforms (subreddit, timeline), not to specific people + if "publish" in intents: + # For Reddit, we might have a subreddit in the objective + if channel_key == "reddit": + # Extract subreddit from objective (e.g., "r/SaaS" or "in r/startups") + import re + subreddit_match = re.search(r'r/(\w+)', objective.lower()) + if subreddit_match: + channel_identities[channel] = f"r/{subreddit_match.group(1)}" + # Twitter, LinkedIn posts don't need a target - they go to user's own feed + continue + + # Missing identifier for OUTREACH (DM/direct message) operation + if state.get("draft_required"): + missing_identities.append(channel) + + # If missing identifiers for write operations, pause and ask + if missing_identities: + missing_str = ", ".join(missing_identities) + target = person_name or "this contact" + + await log_event( + mission_id, user_id, + f"I need {target}'s identifier for {missing_str}. Please provide the profile URL or username.", + "action", + metadata={"action": "request_identifier", "channels": missing_identities, "person": person_name} + ) + + return { + "channel_identities": channel_identities, + "missing_info": [f"{ch}_identifier" for ch in missing_identities], + "pause_reason": f"Need identifier for {missing_str}" + } + + return { + "channel_identities": channel_identities, + "missing_info": [] + } + + +# ================================================== +# NODE 4: ROUTE_BY_INTENT (Conditional Router) +# ================================================== + +def route_by_intent(state: AgentState) -> str: + """ + Route to appropriate flow based on intent. + This is a routing function, not a node. + """ + # Check for blockers + if state.get("missing_info"): + return "end" # Paused, waiting for user input + + intents = state.get("intents", []) + + if "discovery" in intents: + return "discovery_flow" + elif "outreach" in intents: + return "outreach_flow" + elif "publish" in intents: + return "publish_flow" + elif "read" in intents or "query" in intents: + return "execute_action" # Skip draft for read operations + else: + return "end" + + +# ================================================== +# NODE 5a: DISCOVERY_FLOW +# ================================================== + +async def discovery_flow(state: AgentState) -> Dict: + """ + Discovery: Finding, searching, collecting people or data + Uses Firecrawl or platform search APIs + """ + objective = state["objective"] + mission_id = state["mission_id"] + user_id = state["user_id"] + + await log_event(mission_id, user_id, "Searching for relevant contacts...", "thinking") try: - # Use Firecrawl search API with more specific query for contacts async with httpx.AsyncClient() as client: response = await client.post( "https://api.firecrawl.dev/v1/search", @@ -63,8 +536,7 @@ async def scout_prospects(state: AgentState): "Content-Type": "application/json" }, json={ - # Enforce looking for contact info - "query": f"{state['objective']} email contact info site:linkedin.com/in/ OR site:twitter.com OR site:github.com OR site:company.com", + "query": f"{objective} contact email site:linkedin.com/in/ OR site:twitter.com", "limit": 5, "scrapeOptions": {"formats": ["markdown"]} }, @@ -75,369 +547,811 @@ async def scout_prospects(state: AgentState): data = response.json() results = data.get("data", []) - raw_prospects = [] - import re + prospects = [] email_regex = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" - + for result in results[:5]: - title = result.get("title", "Unknown Page") + title = result.get("title", "Unknown") url = result.get("url", "") snippet = result.get("description", "") - # Try to find email in snippet emails = re.findall(email_regex, snippet) - public_contact = emails[0] if emails else url - raw_prospects.append({ + prospects.append({ "name": title.split(" - ")[0] if " - " in title else title[:30], - "company": title.split(" at ")[-1] if " at " in title else "Unknown Company", - "context_source": "Web Search (Firecrawl)", - "public_contact": public_contact, - "original_data": result, - "snippet": snippet + "company": title.split(" at ")[-1] if " at " in title else "Unknown", + "context_source": "Web Search", + "public_contact": emails[0] if emails else url, + "snippet": snippet, + "original_data": result }) - if raw_prospects: - await log_event(state["mission_id"], state["user_id"], f"Found {len(raw_prospects)} potential contact points.", "success") - return {"prospects": raw_prospects} - - await log_event(state["mission_id"], state["user_id"], f"Firecrawl returned status {response.status_code}", "error") - + if prospects: + await log_event(mission_id, user_id, f"Found {len(prospects)} contacts", "success") + await update_agent_stats(user_id, processed=len(prospects)) + + # If outreach is also intended, set current prospect + current = prospects[0] if "outreach" in state.get("intents", []) else {} + + return { + "prospects": prospects, + "current_prospect": current + } + except Exception as e: - await log_event(state["mission_id"], state["user_id"], f"Error in Layer 1: {str(e)[:100]}", "error") - - # Fallback to Mock Data - await log_event(state["mission_id"], state["user_id"], "Using fallback data (API may have failed or no results).", "action") - return {"prospects": [ - {"name": "Satya Nadella", "company": "Microsoft", "context_source": "Public Knowledge", "public_contact": "satya.nadella@microsoft.com", "snippet": "CEO of Microsoft.", "original_data": {}}, - {"name": "General HR", "company": "Microsoft", "context_source": "Careers Page", "public_contact": "careers@microsoft.com", "snippet": "Microsoft Careers.", "original_data": {}} - ]} - -async def analyze_relevance(state: AgentState): - """Layer 2: User-Driven Matching (Filter & Contextualize)""" - prospects = state.get("prospects", []) - if not prospects: - return {"current_prospect": None} - - # We process the first one for the loop, or in a real agent, batch process. - # For this demo, we pick the first one to draft for. - cand = prospects[0] - - await log_event(state["mission_id"], state["user_id"], f"Layer 2: Analyzing relevance for {cand['name']}", "thinking") - - # Simple logic: If it matches keyword in objective, it's relevant. - # in production, use LLM to classify. - - relevance_reason = "Matches mission context based on keywords." - relevance_score = 0.8 - - enriched_cand = { - **cand, - "relevance_score": relevance_score, - "relevance_reason": relevance_reason, - "enriched": True # Mark as processed - } - - # Attachment Logic - # Use attachments passed from launchpad if available - attachments = state.get("attachments", []) or [] - - # Fallback: Simple heuristic if none passed/explicitly requested but keywords exist - if not attachments and any(k in state['objective'].lower() for k in ["attach", "file", "pdf", "deck", "case study"]): - from app.models import UserAsset - user_assets = await UserAsset.find(UserAsset.user_id == state['user_id']).sort("-created_at").to_list() - - if user_assets: - best_match = user_assets[0] - attachments.append({ - "filename": best_match.filename, - "asset_id": str(best_match.id), - "content_type": best_match.content_type - }) - await log_event(state["mission_id"], state["user_id"], f"Found relevant attachment: {best_match.filename}", "success") - - enriched_cand = { - **cand, - "relevance_score": relevance_score, - "relevance_reason": relevance_reason, - "attachments": attachments, # Pass to draft - "enriched": True - } + print(f"Discovery error: {e}") + await log_event(mission_id, user_id, f"Search encountered an issue: {str(e)[:50]}", "error") - await log_event(state["mission_id"], state["user_id"], f"Marked as relevant: {relevance_reason}", "success") - return {"current_prospect": enriched_cand} + return {"prospects": [], "current_prospect": {}} -async def write_draft(state: AgentState): - """Layer 3: Outreach (Role-Independent)""" - prospect = state.get("current_prospect") - if not prospect: - await log_event(state["mission_id"], state["user_id"], "No valid prospect to draft for.", "error") - return {} - feedback = state.get("feedback") +# ================================================== +# NODE 5b: OUTREACH_FLOW +# ================================================== + +async def outreach_flow(state: AgentState) -> Dict: + """ + Outreach: Prepare content for direct messaging/emailing + Generates draft if draft_required == True + """ + objective = state["objective"] + mission_id = state["mission_id"] + user_id = state["user_id"] + channels = state.get("channels", ["/gmail"]) + person_name = state.get("person_name", "Contact") + channel_identities = state.get("channel_identities", {}) + + # If we have prospects from discovery, use first one + prospect = state.get("current_prospect") or {} + if not prospect and state.get("prospects"): + prospect = state["prospects"][0] + + # Build target info + target_email = channel_identities.get("/gmail", prospect.get("public_contact", "")) + target_linkedin = channel_identities.get("/linkedin", "") - await log_event(state["mission_id"], state["user_id"], f"Layer 3: Drafting outreach for {prospect.get('name')}", "thinking") + if not state.get("draft_required"): + # This is a read operation disguised as outreach (e.g., "check if X replied") + return { + "draft_content": {}, + "draft_required": False + } + + await log_event(mission_id, user_id, f"Drafting message for {person_name}...", "thinking") try: - # Check for user credits or connection logic here if needed - # For now, just generate. - llm = ChatGroq( temperature=0.7, groq_api_key=settings.GROQ_API_KEY, model_name="llama-3.3-70b-versatile" ) - attachments_list = [a['filename'] for a in prospect.get('attachments', [])] - attachment_context = "" - if attachments_list: - attachment_context = f""" -IMPORTANT: You have the following files attached to this email: {', '.join(attachments_list)}. -- You cannot read the file content, but you should write the email as if the recipient will see it. -- Do NOT say "refer to the image" or "I cannot see the file". -- Instead say things like "I've attached [Filename] for your review" or "Please see the attached case study". -""" - + # Determine primary channel and constraints + primary_channel = channels[0] if channels else "/gmail" + + channel_constraints = { + "/linkedin": "Max 300 chars for connection request, 8000 for message", + "/twitter": "Max 280 characters", + "/slack": "Supports markdown formatting", + "/gmail": "Professional email format with subject line", + "/reddit": "Follows subreddit rules and etiquette" + } + + attachments = state.get("attachments", []) + attachment_names = [a.get("filename", "") for a in attachments] + system_prompt = f"""You are an expert outreach specialist. -Write a personalized email based on the publicly available context. -{attachment_context} -Format: -SUBJECT: [Subject] -EMAIL: [Body] -REASONING: [Reasoning]""" - - human_prompt = f"""Target Context: {prospect.get('name')} at {prospect.get('company')} -Source: {prospect.get('context_source')} -Snippet: {prospect.get('snippet')} -Relevance: {prospect.get('relevance_reason')} -Mission: {state.get('objective')} -Attachments: {', '.join(attachments_list) if attachments_list else 'None'} +Write a personalized message for {primary_channel}. + +Channel: {primary_channel} +Constraints: {channel_constraints.get(primary_channel, "Standard message format")} + +{"Attachments included: " + ", ".join(attachment_names) if attachment_names else ""} + +Return in this format: +SUBJECT: [Subject line - only for email] +BODY: [Message content] +REASONING: [Why this approach works]""" + + human_prompt = f""" +Target: {person_name} +Contact: {target_email or target_linkedin or "Unknown"} +Context: {prospect.get("snippet", objective)} +Mission: {objective} """ - messages = [ + + response = await llm.ainvoke([ SystemMessage(content=system_prompt), HumanMessage(content=human_prompt) - ] + ]) - response = await llm.ainvoke(messages) content = response.content - # Parse Logic (same as before) - subject, body, reasoning = "Hello", content, "AI Generated" + # Parse response + subject = "" + body = content + reasoning = "" + if "SUBJECT:" in content: - parts = content.split("EMAIL:") + parts = content.split("BODY:") subject = parts[0].replace("SUBJECT:", "").strip() if len(parts) > 1: - rem = parts[1] - if "REASONING:" in rem: - bps = rem.split("REASONING:") - body = bps[0].strip() - reasoning = bps[1].strip() if len(bps) > 1 else "" + remainder = parts[1] + if "REASONING:" in remainder: + body_parts = remainder.split("REASONING:") + body = body_parts[0].strip() + reasoning = body_parts[1].strip() if len(body_parts) > 1 else "" else: - body = rem.strip() + body = remainder.strip() + + draft_content = { + "channel": primary_channel, + "subject": subject.replace("`", "").strip(), + "body": body.replace("```", "").strip(), + "reasoning": reasoning, + "target_name": person_name, + "target_identifier": channel_identities.get(primary_channel, target_email), + "attachments": attachments + } + + await log_event(mission_id, user_id, "Draft ready for review", "success") - # Clean up any markdown code blocks if present - subject = subject.replace("`", "").strip() - body = body.replace("```", "").strip() + return { + "draft_content": draft_content, + "current_prospect": prospect + } + + except Exception as e: + print(f"Outreach draft error: {e}") + return { + "draft_content": { + "channel": channels[0] if channels else "/gmail", + "subject": "Hello", + "body": objective, + "error": str(e) + } + } + - await log_event(state["mission_id"], state["user_id"], f"Draft generated. Sending to Review Queue.", "success") +# ================================================== +# NODE 5c: PUBLISH_FLOW +# ================================================== +async def publish_flow(state: AgentState) -> Dict: + """ + Publish: Create public content (posts, tweets, etc.) + Always requires draft for public content + """ + objective = state["objective"] + mission_id = state["mission_id"] + user_id = state["user_id"] + channels = state.get("channels", []) + + primary_channel = channels[0] if channels else "/twitter" + + await log_event(mission_id, user_id, f"Drafting content for {primary_channel}...", "thinking") + + try: + llm = ChatGroq( + temperature=0.8, + groq_api_key=settings.GROQ_API_KEY, + model_name="llama-3.3-70b-versatile" + ) + + platform_guides = { + "/twitter": "Tweet: Max 280 chars, engaging, use hashtags sparingly", + "/linkedin": "Professional tone, thought leadership, can be longer", + "/reddit": "Match subreddit culture, provide value, avoid self-promotion", + } + + system_prompt = f"""You are a social media content expert. +Create content for {primary_channel}. + +Guidelines: {platform_guides.get(primary_channel, "Engaging, authentic content")} + +Return: +CONTENT: [The post content] +REASONING: [Why this works]""" + + response = await llm.ainvoke([ + SystemMessage(content=system_prompt), + HumanMessage(content=f"Create: {objective}") + ]) + + content = response.content + post_content = content + reasoning = "" + + if "CONTENT:" in content: + parts = content.split("REASONING:") + post_content = parts[0].replace("CONTENT:", "").strip() + reasoning = parts[1].strip() if len(parts) > 1 else "" + + draft_content = { + "channel": primary_channel, + "subject": "", # Posts don't have subjects + "body": post_content.replace("```", "").strip(), + "reasoning": reasoning, + "type": "publish" + } + + await log_event(mission_id, user_id, "Content draft ready for review", "success") + + return {"draft_content": draft_content} + except Exception as e: - print(f"LLM Error: {e}") - subject = "Hello" - body = "I saw your profile and wanted to reach out." - reasoning = f"Fallback due to error: {str(e)}" - - # Data Persistence - # 1. Save Prospect (Layer 1 & 2 data) - p_doc = Prospect( - mission_id=state["mission_id"], - name=prospect.get("name", "Unknown"), - company=prospect.get("company", "Unknown"), - context_source=prospect.get("context_source"), - public_contact=prospect.get("public_contact"), - relevance_score=prospect.get("relevance_score", 0.0), - relevance_reason=prospect.get("relevance_reason"), - original_data=prospect.get("original_data", {}) - ) - await p_doc.insert() + print(f"Publish draft error: {e}") + return { + "draft_content": { + "channel": primary_channel, + "body": objective, + "error": str(e) + } + } + - # 2. Save Draft (Layer 3) +# ================================================== +# NODE 6: REVIEW_QUEUE (Human-in-the-Loop) +# ================================================== + +async def review_queue(state: AgentState) -> Dict: + """ + PURPOSE: + - ONLY for write/send/post/create actions + - Populate channel-aware draft UI + + Each review item includes: + - channel + - intent + - preview content + - metadata (limits, warnings) + + This node SAVES the draft and pauses for human approval. + """ + draft_content = state.get("draft_content", {}) + mission_id = state["mission_id"] + user_id = state["user_id"] + prospect = state.get("current_prospect") or {} + + if not draft_content or not state.get("draft_required"): + # No draft needed, skip review + return {"draft_id": None} + + channel = draft_content.get("channel", "/gmail") + + # Save Prospect first (only if we have meaningful data) + p_doc = None + prospect_name = prospect.get("name") or draft_content.get("target_name") + if prospect_name: + p_doc = Prospect( + mission_id=mission_id, + name=prospect_name, + company=prospect.get("company", "Unknown"), + context_source=prospect.get("context_source", "User Input"), + public_contact=prospect.get("public_contact") or draft_content.get("target_identifier", ""), + relevance_score=prospect.get("relevance_score", 0.8), + relevance_reason=prospect.get("relevance_reason", "Mission context"), + original_data=prospect.get("original_data", {}) + ) + await p_doc.insert() + + # Save Draft (prospect_id is now optional) d_doc = Draft( - prospect_id=str(p_doc.id), - subject=subject, - body=body, - ai_reasoning=reasoning, + prospect_id=str(p_doc.id) if p_doc else None, + channel=channel.replace("/", ""), + subject=draft_content.get("subject", ""), + body=draft_content.get("body", ""), + ai_reasoning=draft_content.get("reasoning", ""), status=DraftStatus.PENDING, - attachments=prospect.get('attachments', []) + attachments=draft_content.get("attachments", []) ) await d_doc.insert() - return {"draft_id": str(d_doc.id)} + await update_agent_stats(user_id, queued=1) + + # Broadcast to review UI + await log_event( + mission_id, user_id, + "Draft added to review queue", + "success", + metadata={ + "action": "review_required", + "draft_id": str(d_doc.id), + "channel": channel, + "preview": draft_content.get("body", "")[:100] + } + ) + + return { + "draft_id": str(d_doc.id), + "action_status": "pending_review" + } + -# Build Graph -def route_mission(state: AgentState) -> str: - obj = state["objective"].lower() - # Simple keyword heuristic. Can be upgraded to LLM classifier. - if any(k in obj for k in ["find", "search", "scout", "look for", "scrape"]): - return "scout" - return "direct_intake" +# ================================================== +# NODE 7: EXECUTE_ACTION +# ================================================== -async def direct_intake(state: AgentState): - """Directly ingest contacts from user instruction without scraping""" - obj = state["objective"].lower() +async def execute_action(state: AgentState) -> Dict: + """ + PURPOSE: + - Use platform integration files ONLY + - Use Composio ONLY + - NO LLM calls + + Handle fallbacks gracefully: + - /linkedin message fails β†’ send connection request + - Missing permission β†’ ask user + """ mission_id = state["mission_id"] user_id = state["user_id"] + intents = state.get("intents", []) + channels = state.get("channels", ["/gmail"]) + draft_content = state.get("draft_content", {}) + channel_identities = state.get("channel_identities", {}) - # Check for integration needs + # Get user connections user = await User.find_one(User.clerk_id == user_id) - connections = user.other_connections if (user and user.other_connections) else {} - slack_connected = bool(user and user.slack_connection_id) - - missing_tools = [] - - # Keyword -> Tool Key mapping - # Note: Multi-word checks need care. - if "telegram" in obj and not connections.get("telegram"): missing_tools.append("telegram") - if "discord" in obj and not connections.get("discord"): missing_tools.append("discord") - if "slack" in obj and not slack_connected: missing_tools.append("slack") - if ("gmail" in obj or "email" in obj) and not user.gmail_connection_id: missing_tools.append("gmail") - if "github" in obj and not connections.get("github"): missing_tools.append("github") - if "reddit" in obj and not connections.get("reddit"): missing_tools.append("reddit") - if "perplexity" in obj and not connections.get("perplexity"): missing_tools.append("perplexity") - if ("sheets" in obj or "spreadsheet" in obj) and not connections.get("google_sheets"): missing_tools.append("google_sheets") - - if missing_tools: - for tool in missing_tools: - print(f"DEBUG: Requesting connection for {tool}") - label = tool.replace("_", " ").title() - await log_event(mission_id, user_id, f"To proceed, please connect {label}.", "action", metadata={"action": "connect_tool", "tool": tool}) - - # Stop here if we are just asking for connection - return {"prospects": []} - - await log_event(mission_id, user_id, "Skipping search (Direct Input Mode)", "thinking") - - import re - email_regex = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" - emails = re.findall(email_regex, state["objective"]) - - contacts = [] - if emails: - for email in emails: - contacts.append({ - "name": email.split("@")[0], - "company": "Unknown", - "context_source": "User Input", - "public_contact": email, - "snippet": f"User provided email in objective: {email}", - "original_data": {} - }) - else: - # Generic fallback - contacts.append({ - "name": "Target Contact", - "company": "Unknown", - "context_source": "User Instruction", - "public_contact": "", - "snippet": state["objective"], - "original_data": {} - }) + if not user: + return {"execution_result": {"success": False, "error": "User not found"}, "action_status": "failed"} + + primary_channel = channels[0] if channels else "/gmail" + + # READ/QUERY operations - execute directly + if "read" in intents or "query" in intents: + await log_event(mission_id, user_id, f"Fetching data from {primary_channel}...", "thinking") + + result = await execute_read_action(user, primary_channel, state) + + if result.get("success"): + await log_event(mission_id, user_id, "Here's what I found:", "success", metadata={"data": result.get("data")}) + else: + await log_event(mission_id, user_id, f"Couldn't fetch data: {result.get('error')}", "error") + + return {"execution_result": result, "action_status": "completed" if result.get("success") else "failed"} + + # WRITE operations - execute approved draft + if state.get("draft_required") and draft_content: + await log_event(mission_id, user_id, f"Sending via {primary_channel}...", "thinking") + + result = await execute_write_action(user, primary_channel, draft_content, channel_identities, state) + + if result.get("success"): + await log_event(mission_id, user_id, "Message sent successfully!", "success") + await update_agent_stats(user_id, processed=1) + else: + # Fallback logic + if primary_channel == "/linkedin" and "not connected" in str(result.get("error", "")).lower(): + await log_event(mission_id, user_id, "Sending connection request instead...", "action") + # Attempt connection request fallback + from app.integrations import linkedin + connection_id = user.other_connections.get("linkedin") if user.other_connections else None + if connection_id: + fallback_result = await linkedin.send_connection_request( + connection_id, + channel_identities.get("/linkedin", ""), + draft_content.get("body", "")[:300] + ) + if fallback_result.get("success"): + await log_event(mission_id, user_id, "Connection request sent!", "success") + return {"execution_result": fallback_result, "action_status": "sent"} + + await log_event(mission_id, user_id, f"Failed to send: {result.get('error')}", "error") + await update_agent_stats(user_id, errors=1) + + return {"execution_result": result, "action_status": "sent" if result.get("success") else "failed"} + + return {"execution_result": {}, "action_status": "no_action"} + + +async def execute_read_action(user: User, channel: str, state: AgentState) -> Dict: + """Execute read/query operations via platform integrations""" + objective = state.get("objective", "") + + if channel == "/gmail": + # For read operations, we'd need a separate read function + # Currently not implemented - sender only handles sending + return {"success": False, "error": "Gmail read operations not yet implemented"} + + elif channel == "/slack": + from app.integrations import slack + connection_id = user.slack_connection_id + if not connection_id: + return {"success": False, "error": "Slack not connected"} + + # Check if searching or listing + if "search" in objective.lower(): + query = objective.replace("search", "").strip() + return await slack.search_messages(connection_id, query) + else: + return await slack.list_channels(connection_id) + + elif channel == "/linkedin": + from app.integrations import linkedin + connection_id = user.other_connections.get("linkedin") if user.other_connections else None + if not connection_id: + return {"success": False, "error": "LinkedIn not connected"} + + return await linkedin.get_messages(connection_id) + + elif channel == "/twitter": + from app.integrations import twitter + connection_id = user.other_connections.get("twitter") if user.other_connections else None + if not connection_id: + return {"success": False, "error": "Twitter not connected"} + + if "mention" in objective.lower(): + return await twitter.get_mentions(connection_id) + return await twitter.get_timeline(connection_id) + + elif channel == "/github": + from app.integrations import github + connection_id = user.other_connections.get("github") if user.other_connections else None + if not connection_id: + return {"success": False, "error": "GitHub not connected"} + + # Try to extract repo info + match = re.search(r"([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)", objective) + if match: + owner, repo = match.groups() + if "pr" in objective.lower() or "pull" in objective.lower(): + return await github.list_pull_requests(connection_id, owner, repo) + return await github.list_issues(connection_id, owner, repo) + return {"success": False, "error": "Could not parse repository info"} + + elif channel == "/reddit": + from app.integrations import reddit + connection_id = user.other_connections.get("reddit") if user.other_connections else None + if not connection_id: + return {"success": False, "error": "Reddit not connected"} + + # Extract subreddit if mentioned + match = re.search(r"r/([a-zA-Z0-9_]+)", objective) + if match: + subreddit = match.group(1) + return await reddit.get_subreddit_posts(connection_id, subreddit) + + # General search + return await reddit.search_posts(connection_id, objective) + + return {"success": False, "error": f"Unknown channel: {channel}"} + + +async def execute_write_action(user: User, channel: str, draft_content: Dict, channel_identities: Dict, state: AgentState) -> Dict: + """Execute write/send operations via platform integrations""" + + body = draft_content.get("body", "") + subject = draft_content.get("subject", "") + target = draft_content.get("target_identifier") or channel_identities.get(channel, "") + + if channel == "/gmail": + from app.core.sender import send_email_via_composio + connection_id = user.gmail_connection_id + if not connection_id: + return {"success": False, "error": "Gmail not connected"} + + return await send_email_via_composio(user.clerk_id, target, subject, body) + + elif channel == "/slack": + from app.integrations import slack + connection_id = user.slack_connection_id + if not connection_id: + return {"success": False, "error": "Slack not connected"} + + return await slack.send_message(user.clerk_id, target, body) + + elif channel == "/linkedin": + from app.integrations import linkedin + connection_id = user.other_connections.get("linkedin") if user.other_connections else None + if not connection_id: + return {"success": False, "error": "LinkedIn not connected"} + + # Check if this is a post or message + if draft_content.get("type") == "publish": + return await linkedin.publish_post(user.clerk_id, body) + return await linkedin.send_message(user.clerk_id, target, body) + + elif channel == "/twitter": + from app.integrations import twitter + connection_id = user.other_connections.get("twitter") if user.other_connections else None + if not connection_id: + return {"success": False, "error": "Twitter not connected"} + + return await twitter.post_tweet(user.clerk_id, body) + + elif channel == "/reddit": + from app.integrations import reddit + connection_id = user.other_connections.get("reddit") if user.other_connections else None + if not connection_id: + return {"success": False, "error": "Reddit not connected"} + + # Extract subreddit from target or content + subreddit = target.replace("r/", "") if target else "test" + return await reddit.create_post(user.clerk_id, subreddit, subject or "Post", body) + + elif channel == "/github": + from app.integrations import github + connection_id = user.other_connections.get("github") if user.other_connections else None + if not connection_id: + return {"success": False, "error": "GitHub not connected"} - await log_event(state["mission_id"], state["user_id"], f"Processed {len(contacts)} targets from input.", "success") - return {"prospects": contacts} + # Parse owner/repo from target + match = re.search(r"([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)", target) + if match: + owner, repo = match.groups() + return await github.create_issue(user.clerk_id, owner, repo, subject or "Issue", body) + return {"success": False, "error": "Could not parse repository info"} + + return {"success": False, "error": f"Unknown channel: {channel}"} + + +# ================================================== +# NODE 8: POST_ACTION_UPDATE +# ================================================== + +async def post_action_update(state: AgentState) -> Dict: + """ + PURPOSE: + - Update Neo4j relationships + - Store execution result + - Mark sent / pending / failed + """ + mission_id = state["mission_id"] + user_id = state["user_id"] + execution_result = state.get("execution_result", {}) + draft_id = state.get("draft_id") + action_status = state.get("action_status", "completed") + person_name = state.get("person_name") + channels = state.get("channels", []) + + # Update Draft status if exists + if draft_id: + draft = await Draft.get(draft_id) + if draft: + if action_status == "sent": + draft.status = DraftStatus.SENT + elif action_status == "failed": + draft.status = DraftStatus.REJECTED # or add FAILED status + await draft.save() + + # Update Neo4j relationship + if person_name and execution_result.get("success"): + try: + primary_channel = channels[0].replace("/", "") if channels else "email" + # Could add a "CONTACTED_VIA" relationship + # neo4j_service.add_interaction(person_name, primary_channel, datetime.utcnow()) + pass + except Exception as e: + print(f"Failed to update Neo4j: {e}") + + # Update mission + try: + mission = await Mission.get(mission_id) + if mission: + mission.status = "completed" if action_status in ["sent", "completed"] else "failed" + await mission.save() + except Exception: + pass + + await log_event( + mission_id, user_id, + f"Mission {action_status}", + "success" if action_status in ["sent", "completed"] else "error" + ) + + return {"action_status": action_status} + + +# ================================================== +# CONDITIONAL ROUTING FUNCTIONS +# ================================================== + +def should_resolve_person(state: AgentState) -> str: + """Check if we need to resolve a person""" + if state.get("person_name"): + return "resolve_person" + return "resolve_channel_identity" + + +def route_after_flow(state: AgentState) -> str: + """Route after discovery/outreach/publish flows""" + if state.get("missing_info"): + return "end" + + # Check if we need human review + if state.get("draft_required") and state.get("draft_content"): + return "review_queue" + + return "execute_action" + + +# ================================================== +# BUILD GRAPH (Fixed Shape) +# ================================================== workflow = StateGraph(AgentState) -workflow.add_node("scout", scout_prospects) -workflow.add_node("direct_intake", direct_intake) # New node -workflow.add_node("analyze", analyze_relevance) -workflow.add_node("draft_node", write_draft) +# Add all nodes +workflow.add_node("initial_triage", initial_triage) +workflow.add_node("resolve_person", resolve_person) +workflow.add_node("resolve_channel_identity", resolve_channel_identity) +workflow.add_node("discovery_flow", discovery_flow) +workflow.add_node("outreach_flow", outreach_flow) +workflow.add_node("publish_flow", publish_flow) +workflow.add_node("review_queue", review_queue) +workflow.add_node("execute_action", execute_action) +workflow.add_node("post_action_update", post_action_update) -# Conditional Entry -workflow.set_conditional_entry_point( - route_mission, +# Set entry point +workflow.set_entry_point("initial_triage") + +# initial_triage β†’ resolve_person (if person mentioned) OR resolve_channel_identity +workflow.add_conditional_edges( + "initial_triage", + should_resolve_person, { - "scout": "scout", - "direct_intake": "direct_intake" + "resolve_person": "resolve_person", + "resolve_channel_identity": "resolve_channel_identity" } ) -# Edges -# Conditional Edges -def should_draft(state: AgentState) -> str: - if state.get("current_prospect"): - return "draft" - return "end" +# resolve_person β†’ resolve_channel_identity +workflow.add_edge("resolve_person", "resolve_channel_identity") -workflow.add_edge("scout", "analyze") -workflow.add_edge("direct_intake", "analyze") +# resolve_channel_identity β†’ route_by_intent workflow.add_conditional_edges( - "analyze", - should_draft, + "resolve_channel_identity", + route_by_intent, { - "draft": "draft_node", + "discovery_flow": "discovery_flow", + "outreach_flow": "outreach_flow", + "publish_flow": "publish_flow", + "execute_action": "execute_action", # For read/query operations "end": END } ) +# Flow nodes β†’ review_queue OR execute_action +workflow.add_conditional_edges( + "discovery_flow", + route_after_flow, + { + "review_queue": "review_queue", + "execute_action": "execute_action", + "end": END + } +) -# Checkpointer -memory = MemorySaver() +workflow.add_conditional_edges( + "outreach_flow", + route_after_flow, + { + "review_queue": "review_queue", + "execute_action": "execute_action", + "end": END + } +) + +workflow.add_conditional_edges( + "publish_flow", + route_after_flow, + { + "review_queue": "review_queue", + "execute_action": "execute_action", + "end": END + } +) + +# review_queue β†’ execute_action (after human approval) +workflow.add_edge("review_queue", "execute_action") -# Add human approval logic -def check_approval(state: AgentState): - print("Waiting for approval...") - pass +# execute_action β†’ post_action_update +workflow.add_edge("execute_action", "post_action_update") -workflow.add_node("human_approval", check_approval) -workflow.add_edge("draft_node", "human_approval") -workflow.add_edge("human_approval", END) +# post_action_update β†’ END +workflow.add_edge("post_action_update", END) + +# Compile with checkpointer and interrupt before review_queue for human approval +memory = MemorySaver() +app = workflow.compile( + checkpointer=memory, + interrupt_before=["execute_action"] # Pause before executing approved actions +) -# Compile with interrupt -app = workflow.compile(checkpointer=memory, interrupt_before=["human_approval"]) -# Helper runners +# ================================================== +# HELPER RUNNERS +# ================================================== + async def run_mission_agent(mission_id: str, objective: str, user_id: str, attachments: List[Dict] = []): - print(f"DEBUG: Starting mission agent for {mission_id} with {len(attachments)} attachment(s)") + """Start a new mission agent run""" + print(f"DEBUG: Starting mission {mission_id} with objective: {objective[:50]}...") + config = {"configurable": {"thread_id": mission_id}} inputs = { - "mission_id": mission_id, - "objective": objective, + "mission_id": mission_id, + "objective": objective, "user_id": user_id, - "attachments": attachments # Pass attachments to agent state + "attachments": attachments, + "intents": [], + "channels": [], + "required_tools": [], + "draft_required": False, + "person_name": None, + "person_id": None, + "channel_identities": {}, + "missing_info": [], + "pause_reason": None, + "prospects": [], + "current_prospect": {}, + "draft_id": None, + "draft_content": {}, + "feedback": None, + "execution_result": {}, + "action_status": "pending" } try: async for event in app.astream(inputs, config=config): - # print(f"DEBUG: Agent event: {event}") # Optional verbose logging - pass + print(f"DEBUG: Event: {list(event.keys())}") except Exception as e: import traceback - err = traceback.format_exc() - print(f"ERROR: Agent failed: {err}") - # Log to DB so user sees it + print(f"ERROR: Agent failed: {traceback.format_exc()}") try: - await log_event(mission_id, user_id, f"Agent crashed: {str(e)}", "error") + await log_event(mission_id, user_id, f"Agent encountered an error: {str(e)}", "error") except: pass -async def resume_mission_agent(draft_prospect_id_or_thread: str, feedback: str = None): - # Depending on how we mapped thread_id (mission_id vs draft_id) - # If thread_id is mission_id, we need to know it. - # For now assuming thread_id passed is correct. - - # We need to find the mission_id from the draft/prospect to use as thread_id - # This complexity suggests storing thread_id on the Draft or Mission. + +async def resume_mission_agent(mission_id: str, feedback: str = None, approved: bool = True): + """Resume a paused mission after human review""" + config = {"configurable": {"thread_id": mission_id}} - # Placeholder logic - config = {"configurable": {"thread_id": draft_prospect_id_or_thread}} + try: + if feedback: + # Update state with feedback + current_state = app.get_state(config) + if current_state and current_state.values: + # Trigger re-draft with feedback + current_state.values["feedback"] = feedback + app.update_state(config, current_state.values) + + if approved: + # Continue execution + async for event in app.astream(None, config=config): + print(f"DEBUG: Resume event: {list(event.keys())}") + except Exception as e: + print(f"ERROR: Resume failed: {e}") + + +async def provide_missing_info(mission_id: str, user_id: str, info_type: str, value: str): + """Provide missing information to continue paused mission""" + config = {"configurable": {"thread_id": mission_id}} - if feedback: - # Update state with feedback and route back to draft - # app.update_state(config, {"feedback": feedback}) # pseudo code - # app.invoke(Command(resume=feedback)) # if using interrupt inputs - pass - else: - # Just resume - async for event in app.astream(None, config=config): - pass + try: + current_state = app.get_state(config) + if current_state and current_state.values: + state_values = current_state.values + + # Update based on info_type + if "_identifier" in info_type: + channel = info_type.replace("_identifier", "") + state_values["channel_identities"][f"/{channel}"] = value + + # Store in Neo4j if person is known + if state_values.get("person_name"): + neo4j_service.add_contact_method(state_values["person_name"], channel, value) + + # Clear missing_info + state_values["missing_info"] = [m for m in state_values.get("missing_info", []) if m != info_type] + state_values["pause_reason"] = None + + app.update_state(config, state_values) + + # Resume + async for event in app.astream(None, config=config): + print(f"DEBUG: Continue event: {list(event.keys())}") + + except Exception as e: + print(f"ERROR: Provide info failed: {e}") + await log_event(mission_id, user_id, f"Failed to process input: {str(e)}", "error") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 53c14a1..12751e1 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -17,7 +17,33 @@ class Settings(BaseSettings): COMPOSIO_API_KEY: Optional[str] = None COMPOSIO_AUTH_CONFIG_ID: Optional[str] = None - class Config: - env_file = ".env" + # Neo4j Settings + NEO4J_URI: Optional[str] = None + NEO4J_USERNAME: Optional[str] = None + NEO4J_PASSWORD: Optional[str] = None + NEO4J_DATABASE: str = "neo4j" + AURA_INSTANCEID: Optional[str] = None + AURA_INSTANCENAME: Optional[str] = None + + # Unipile Settings + UNIPILE_DSN: Optional[str] = None + UNIPILE_API_KEY: Optional[str] = None + + # Neo4j Settings + NEO4J_URI: Optional[str] = None + NEO4J_USERNAME: Optional[str] = None + NEO4J_PASSWORD: Optional[str] = None + NEO4J_DATABASE: str = "neo4j" + AURA_INSTANCEID: Optional[str] = None + AURA_INSTANCENAME: Optional[str] = None + + # Unipile Settings + UNIPILE_DSN: Optional[str] = None + UNIPILE_API_KEY: Optional[str] = None + + model_config = { + "env_file": ".env", + "extra": "ignore" + } settings = Settings() diff --git a/backend/app/core/sender.py b/backend/app/core/sender.py index 2a51050..a52fa2d 100644 --- a/backend/app/core/sender.py +++ b/backend/app/core/sender.py @@ -1,6 +1,7 @@ from app.core.config import settings from composio import Composio -import base64 +from typing import Optional +import os # Lazy initialization to ensure settings are loaded _composio_client = None @@ -13,72 +14,103 @@ def get_composio_client(): _composio_client = Composio(api_key=settings.COMPOSIO_API_KEY) return _composio_client -async def send_email_via_composio(user_id: str, recipient: str, subject: str, body: str, attachments: list = []): - """Execute Gmail Send Email action via Composio SDK using user_id for authentication.""" - try: - client = get_composio_client() - - # Build arguments - arguments = { - "recipient_email": recipient, - "subject": subject, - "body": body, - } + +async def send_email_via_composio( + user_id: str, + recipient: str, + subject: str, + body: str, + attachments: Optional[list] = None, +): + """ + Execute Gmail Send Email action via Composio SDK. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + recipient: Email address to send to + subject: Email subject line + body: Email body content + attachments: Optional list of dicts with 'asset_id' keys - # Handle attachments if present - temp_files = [] # Keep track to clean up later (optional, or rely on OS) - if attachments: - from app.models import UserAsset - import tempfile - import os - - attachment_data = [] # List of dicts or paths? SDK docs say "paths to tools that need them" - # But GMAIL_SEND_EMAIL input schema for 'attachments' expects a list of objects with 'path' or just list of paths. - # Let's try passing list of dicts with 'path' and 'name' + Returns: + Dict with execution result + """ + import tempfile + + client = get_composio_client() + + arguments = { + "recipient_email": recipient, + "subject": subject, + "body": body, + "user_id": "me", # Required by Gmail API + } + + temp_files = [] + + if attachments: + from app.models import UserAsset + import base64 + + # Process first attachment (Composio uses singular "attachment" parameter) + for att in attachments: + asset_id = att.get("asset_id") + if not asset_id: + continue + + asset = await UserAsset.get(asset_id) + if not asset: + continue + + # Get file extension and create proper filename + filename = asset.filename + suffix = os.path.splitext(filename)[1] if "." in filename else "" - for att in attachments: - asset_id = att.get('asset_id') - if asset_id: - asset = await UserAsset.get(asset_id) - if asset: - # Create temp file - suffix = "." + asset.filename.split(".")[-1] if "." in asset.filename else "" - tf = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) - tf.write(asset.file_data) - tf.close() - temp_files.append(tf.name) - - # Manual Upload using Composio Internal Helpers - # This guarantees we get the correct {s3key, mimetype, name} structure - # bypassing any auto-magic issues with lists. - from composio.core.models._files import FileUploadable - - # We need the tool schema to get toolkit slug - tool_schema = client.tools.get_raw_composio_tool_by_slug("GMAIL_SEND_EMAIL") - toolkit_slug = tool_schema.toolkit.slug if tool_schema.toolkit else "gmail" + # Create temp file with the asset data - use original filename + tf = tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix=os.path.splitext(filename)[0] + "_") + tf.write(asset.file_data) + tf.close() - uploaded_file = FileUploadable.from_path( - client=client._client, # Access the underlying HttpClient - file=tf.name, - tool="GMAIL_SEND_EMAIL", - toolkit=toolkit_slug, - ).model_dump() - - print(f"DEBUG: Uploaded file manually: {uploaded_file}") - attachment_data.append(uploaded_file) + temp_files.append(tf.name) + + # Composio Gmail uses singular "attachment" parameter with file path + arguments["attachment"] = tf.name + print(f"Including attachment: {filename} -> {tf.name}") + break # Gmail SEND_EMAIL only supports one attachment at a time - if attachment_data: - arguments["attachments"] = attachment_data - print(f"Including {len(attachment_data)} attachment(s) via temp files") - + try: result = client.tools.execute( slug="GMAIL_SEND_EMAIL", arguments=arguments, user_id=user_id, dangerously_skip_version_check=True, ) - print(f"Email sent successfully: {result}") - return result + print(f"Email sent - Composio result: {result}") + + # Composio SDK returns a result object with 'successful' key + if hasattr(result, 'successful'): + if result.successful: + return {"success": True, "data": result} + else: + error_msg = getattr(result, 'error', None) or str(result) + return {"success": False, "error": error_msg} + + # Handle dict response (older SDK versions) + if isinstance(result, dict): + if result.get("successful") or result.get("success"): + return {"success": True, "data": result} + else: + return {"success": False, "error": result.get("error") or result.get("message") or "Unknown error"} + + # If we got here, assume success if no error + return {"success": True, "data": result} except Exception as e: print(f"ERROR: Composio SDK execution failed: {e}") - raise + return {"success": False, "error": str(e)} + finally: + # Cleanup temp files + for p in temp_files: + try: + os.unlink(p) + except Exception: + pass diff --git a/backend/app/core/socket.py b/backend/app/core/socket.py new file mode 100644 index 0000000..a2ca39d --- /dev/null +++ b/backend/app/core/socket.py @@ -0,0 +1,46 @@ +from fastapi import WebSocket +from typing import Dict, List +import json + +class ConnectionManager: + def __init__(self): + # Map user_id to list of websockets + self.user_connections: Dict[str, List[WebSocket]] = {} + + async def connect(self, websocket: WebSocket, user_id: str): + await websocket.accept() + if user_id not in self.user_connections: + self.user_connections[user_id] = [] + self.user_connections[user_id].append(websocket) + + def disconnect(self, websocket: WebSocket, user_id: str): + if user_id in self.user_connections: + self.user_connections[user_id].remove(websocket) + if not self.user_connections[user_id]: + del self.user_connections[user_id] + + async def send_to_user(self, user_id: str, message: dict): + """Send message to all connections of a specific user""" + if user_id in self.user_connections: + msg_str = json.dumps(message) + for ws in self.user_connections[user_id]: + try: + await ws.send_text(msg_str) + except: + pass + + async def broadcast(self, message: dict): + """Broadcast to all connected users""" + msg_str = json.dumps(message) + for user_id, connections in self.user_connections.items(): + for ws in connections: + try: + await ws.send_text(msg_str) + except: + pass + +# Global manager instance +manager = ConnectionManager() + +def get_connection_manager(): + return manager diff --git a/backend/app/integrations/__init__.py b/backend/app/integrations/__init__.py new file mode 100644 index 0000000..8a8bd57 --- /dev/null +++ b/backend/app/integrations/__init__.py @@ -0,0 +1,3 @@ +# Platform Integrations Package +# Each platform file handles Composio actions ONLY +# No LLM logic, no database logic, deterministic functions only diff --git a/backend/app/integrations/github.py b/backend/app/integrations/github.py new file mode 100644 index 0000000..4a8c16e --- /dev/null +++ b/backend/app/integrations/github.py @@ -0,0 +1,220 @@ +""" +GitHub Integration Module +Uses Composio SDK +https://docs.composio.dev/reference/sdk-reference/python +""" + +from typing import Dict, Optional, List +from app.core.config import settings +from composio import Composio + +GITHUB_AUTH_CONFIG_ID = "ac_UE__S2Ls9sMT" + +# Lazy initialization +_composio_client = None + +def get_composio_client(): + global _composio_client + if _composio_client is None: + if not settings.COMPOSIO_API_KEY: + raise ValueError("COMPOSIO_API_KEY is not set") + _composio_client = Composio(api_key=settings.COMPOSIO_API_KEY) + return _composio_client + + +async def create_issue(user_id: str, owner: str, repo: str, title: str, body: str, labels: Optional[List[str]] = None) -> Dict: + """ + Create an issue in a GitHub repository. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + owner: Repository owner + repo: Repository name + title: Issue title + body: Issue body + labels: Optional list of labels + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + arguments = { + "owner": owner, + "repo": repo, + "title": title, + "body": body + } + + if labels: + arguments["labels"] = labels + + result = client.tools.execute( + slug="GITHUB_CREATE_AN_ISSUE", + arguments=arguments, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: GitHub create_issue failed: {e}") + return {"success": False, "error": str(e)} + + +async def comment_issue(user_id: str, owner: str, repo: str, issue_number: int, body: str) -> Dict: + """ + Comment on a GitHub issue. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + owner: Repository owner + repo: Repository name + issue_number: Issue number + body: Comment body + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="GITHUB_CREATE_AN_ISSUE_COMMENT", + arguments={ + "owner": owner, + "repo": repo, + "issue_number": issue_number, + "body": body + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: GitHub comment_issue failed: {e}") + return {"success": False, "error": str(e)} + + +async def fetch_repo_info(user_id: str, owner: str, repo: str) -> Dict: + """ + Fetch repository information. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + owner: Repository owner + repo: Repository name + + Returns: + Dict with success status and repo data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="GITHUB_GET_A_REPOSITORY", + arguments={ + "owner": owner, + "repo": repo + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: GitHub fetch_repo_info failed: {e}") + return {"success": False, "error": str(e)} + + +async def list_issues(user_id: str, owner: str, repo: str, state: str = "open") -> Dict: + """ + List issues in a repository. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + owner: Repository owner + repo: Repository name + state: open, closed, or all + + Returns: + Dict with success status and issues data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="GITHUB_LIST_REPOSITORY_ISSUES", + arguments={ + "owner": owner, + "repo": repo, + "state": state + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: GitHub list_issues failed: {e}") + return {"success": False, "error": str(e)} + + +async def list_pull_requests(user_id: str, owner: str, repo: str, state: str = "open") -> Dict: + """ + List pull requests in a repository. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + owner: Repository owner + repo: Repository name + state: open, closed, or all + + Returns: + Dict with success status and PRs data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="GITHUB_LIST_PULL_REQUESTS", + arguments={ + "owner": owner, + "repo": repo, + "state": state + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: GitHub list_pull_requests failed: {e}") + return {"success": False, "error": str(e)} + + +async def star_repo(user_id: str, owner: str, repo: str) -> Dict: + """ + Star a GitHub repository. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + owner: Repository owner + repo: Repository name + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER", + arguments={ + "owner": owner, + "repo": repo + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: GitHub star_repo failed: {e}") + return {"success": False, "error": str(e)} diff --git a/backend/app/integrations/linkedin.py b/backend/app/integrations/linkedin.py new file mode 100644 index 0000000..aa277db --- /dev/null +++ b/backend/app/integrations/linkedin.py @@ -0,0 +1,178 @@ +""" +LinkedIn Integration Module +Uses Composio SDK +https://docs.composio.dev/reference/sdk-reference/python +""" + +from typing import Dict, Optional +from app.core.config import settings +from composio import Composio + +LINKEDIN_AUTH_CONFIG_ID = "ac_SdzD1ondK6Zi" + +# Lazy initialization +_composio_client = None + +def get_composio_client(): + global _composio_client + if _composio_client is None: + if not settings.COMPOSIO_API_KEY: + raise ValueError("COMPOSIO_API_KEY is not set") + _composio_client = Composio(api_key=settings.COMPOSIO_API_KEY) + return _composio_client + + +async def send_message(user_id: str, member_id: str, message: str) -> Dict: + """ + Send a direct message to a LinkedIn member. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + member_id: LinkedIn member URN or profile ID + message: Message content + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="LINKEDIN_SEND_MESSAGE", + arguments={ + "recipient_id": member_id, + "message": message + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: LinkedIn send_message failed: {e}") + return {"success": False, "error": str(e)} + + +async def send_connection_request(user_id: str, member_id: str, message: Optional[str] = None) -> Dict: + """ + Send a connection request to a LinkedIn member. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + member_id: LinkedIn member URN or profile URL + message: Optional personalized note (max 300 chars) + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + arguments = { + "invitee_profile_url": member_id + } + + if message: + arguments["message"] = message[:300] # LinkedIn limit + + result = client.tools.execute( + slug="LINKEDIN_SEND_INVITATION", + arguments=arguments, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: LinkedIn send_connection_request failed: {e}") + return {"success": False, "error": str(e)} + + +async def publish_post(user_id: str, content: str, visibility: str = "PUBLIC") -> Dict: + """ + Publish a post on LinkedIn feed. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + content: Post content text + visibility: PUBLIC, CONNECTIONS, or LOGGED_IN + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="LINKEDIN_CREATE_LINKED_IN_POST", + arguments={ + "text": content, + "visibility": visibility + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: LinkedIn publish_post failed: {e}") + return {"success": False, "error": str(e)} + + +async def get_profile(user_id: str, profile_url: Optional[str] = None) -> Dict: + """ + Get LinkedIn profile information (own profile or by URL). + + Args: + user_id: Composio entity ID (e.g., clerk_id) + profile_url: Optional profile URL to fetch (if None, fetches own profile) + + Returns: + Dict with success status and profile data + """ + try: + client = get_composio_client() + + slug = "LINKEDIN_GET_OWN_PROFILE" if not profile_url else "LINKEDIN_GET_PROFILE" + arguments = {} + + if profile_url: + arguments["profile_url"] = profile_url + + result = client.tools.execute( + slug=slug, + arguments=arguments, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: LinkedIn get_profile failed: {e}") + return {"success": False, "error": str(e)} + + +async def get_messages(user_id: str, conversation_id: Optional[str] = None) -> Dict: + """ + Get LinkedIn messages/conversations. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + conversation_id: Optional specific conversation to fetch + + Returns: + Dict with success status and messages data + """ + try: + client = get_composio_client() + + arguments = {} + if conversation_id: + arguments["conversation_id"] = conversation_id + + result = client.tools.execute( + slug="LINKEDIN_GET_MESSAGES", + arguments=arguments, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: LinkedIn get_messages failed: {e}") + return {"success": False, "error": str(e)} diff --git a/backend/app/integrations/reddit.py b/backend/app/integrations/reddit.py new file mode 100644 index 0000000..e890557 --- /dev/null +++ b/backend/app/integrations/reddit.py @@ -0,0 +1,168 @@ +""" +Reddit Integration Module +Uses Composio SDK +https://docs.composio.dev/reference/sdk-reference/python +""" + +from typing import Dict, Optional +from app.core.config import settings +from composio import Composio + +REDDIT_AUTH_CONFIG_ID = "ac_2_IjyXggGH8F" + +# Lazy initialization +_composio_client = None + +def get_composio_client(): + global _composio_client + if _composio_client is None: + if not settings.COMPOSIO_API_KEY: + raise ValueError("COMPOSIO_API_KEY is not set") + _composio_client = Composio(api_key=settings.COMPOSIO_API_KEY) + return _composio_client + + +async def search_posts(user_id: str, query: str, subreddit: Optional[str] = None, limit: int = 25) -> Dict: + """ + Search Reddit posts. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + query: Search query + subreddit: Optional subreddit to search within + limit: Number of results + + Returns: + Dict with success status and search results + """ + try: + client = get_composio_client() + + arguments = { + "query": query, + "limit": min(limit, 100) + } + + if subreddit: + arguments["subreddit"] = subreddit + + result = client.tools.execute( + slug="REDDIT_SEARCH_SUBMISSIONS", + arguments=arguments, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Reddit search_posts failed: {e}") + return {"success": False, "error": str(e)} + + +async def create_post(user_id: str, subreddit: str, title: str, content: str, is_self: bool = True) -> Dict: + """ + Create a post in a subreddit. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + subreddit: Target subreddit name (without r/) + title: Post title + content: Post content (text for self posts, URL for link posts) + is_self: True for text post, False for link post + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + arguments = { + "subreddit": subreddit.replace("r/", ""), + "title": title, + "kind": "self" if is_self else "link" + } + + if is_self: + arguments["text"] = content + else: + arguments["url"] = content + + result = client.tools.execute( + slug="REDDIT_CREATE_REDDIT_POST", + arguments=arguments, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Reddit create_post failed: {e}") + return {"success": False, "error": str(e)} + + +async def comment_post(user_id: str, post_id: str, comment: str) -> Dict: + """ + Comment on a Reddit post. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + post_id: Reddit post ID (thing ID) + comment: Comment content + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="REDDIT_COMMENT", + arguments={ + "thing_id": post_id, + "text": comment + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Reddit comment_post failed: {e}") + return {"success": False, "error": str(e)} + + +async def get_subreddit_posts(user_id: str, subreddit: str, sort: str = "hot", limit: int = 25) -> Dict: + """ + Get posts from a subreddit. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + subreddit: Subreddit name (without r/) + sort: hot, new, top, rising + limit: Number of posts + + Returns: + Dict with success status and posts data + """ + try: + client = get_composio_client() + + action_map = { + "hot": "REDDIT_GET_HOT_SUBMISSIONS", + "new": "REDDIT_GET_NEW_SUBMISSIONS", + "top": "REDDIT_GET_TOP_SUBMISSIONS", + "rising": "REDDIT_GET_RISING_SUBMISSIONS" + } + + slug = action_map.get(sort, "REDDIT_GET_HOT_SUBMISSIONS") + + result = client.tools.execute( + slug=slug, + arguments={ + "subreddit": subreddit.replace("r/", ""), + "limit": min(limit, 100) + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Reddit get_subreddit_posts failed: {e}") + return {"success": False, "error": str(e)} diff --git a/backend/app/integrations/slack.py b/backend/app/integrations/slack.py new file mode 100644 index 0000000..59c02fa --- /dev/null +++ b/backend/app/integrations/slack.py @@ -0,0 +1,206 @@ +""" +Slack Integration Module +Uses Composio SDK +https://docs.composio.dev/reference/sdk-reference/python +""" + +from typing import Dict, Optional, List +from app.core.config import settings +from composio import Composio + +SLACK_AUTH_CONFIG_ID = "ac_YPQ1Q5xomR5i" + +# Lazy initialization +_composio_client = None + +def get_composio_client(): + global _composio_client + if _composio_client is None: + if not settings.COMPOSIO_API_KEY: + raise ValueError("COMPOSIO_API_KEY is not set") + _composio_client = Composio(api_key=settings.COMPOSIO_API_KEY) + return _composio_client + + +async def send_message(user_id: str, channel: str, text: str, thread_ts: Optional[str] = None) -> Dict: + """ + Send a message to a Slack channel or user. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + channel: Channel ID or user ID (e.g., C01234567 or U01234567) + text: Message text + thread_ts: Optional thread timestamp to reply in thread + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + arguments = { + "channel": channel, + "text": text + } + + if thread_ts: + arguments["thread_ts"] = thread_ts + + result = client.tools.execute( + slug="SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL", + arguments=arguments, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Slack send_message failed: {e}") + return {"success": False, "error": str(e)} + + +async def create_thread(user_id: str, channel: str, text: str) -> Dict: + """ + Create a new message that can serve as a thread parent. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + channel: Channel ID + text: Message text + + Returns: + Dict with success status and response data (includes thread_ts) + """ + return await send_message(user_id, channel, text) + + +async def reply_to_thread(user_id: str, channel: str, thread_ts: str, text: str) -> Dict: + """ + Reply to an existing thread. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + channel: Channel ID + thread_ts: Thread timestamp + text: Reply text + + Returns: + Dict with success status and response data + """ + return await send_message(user_id, channel, text, thread_ts=thread_ts) + + +async def post_to_channel(user_id: str, channel: str, text: str, blocks: Optional[list] = None) -> Dict: + """ + Post a message to a channel with optional rich formatting. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + channel: Channel ID or name + text: Fallback text + blocks: Optional Slack blocks for rich formatting + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + arguments = { + "channel": channel, + "text": text + } + + if blocks: + arguments["blocks"] = blocks + + result = client.tools.execute( + slug="SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL", + arguments=arguments, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Slack post_to_channel failed: {e}") + return {"success": False, "error": str(e)} + + +async def get_channel_messages(user_id: str, channel: str, limit: int = 100) -> Dict: + """ + Get recent messages from a channel. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + channel: Channel ID + limit: Number of messages to fetch + + Returns: + Dict with success status and messages data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="SLACK_LIST_MESSAGES_IN_CHANNEL", + arguments={ + "channel": channel, + "limit": min(limit, 1000) + }, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Slack get_channel_messages failed: {e}") + return {"success": False, "error": str(e)} + + +async def list_channels(user_id: str) -> Dict: + """ + List available Slack channels. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + + Returns: + Dict with success status and channels data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="SLACK_LIST_ALL_SLACK_CHANNELS", + arguments={}, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Slack list_channels failed: {e}") + return {"success": False, "error": str(e)} + + +async def search_messages(user_id: str, query: str) -> Dict: + """ + Search for messages in Slack. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + query: Search query + + Returns: + Dict with success status and search results + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="SLACK_SEARCH_FOR_A_MESSAGE_IN_SLACK", + arguments={"query": query}, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Slack search_messages failed: {e}") + return {"success": False, "error": str(e)} diff --git a/backend/app/integrations/twitter.py b/backend/app/integrations/twitter.py new file mode 100644 index 0000000..b2e381c --- /dev/null +++ b/backend/app/integrations/twitter.py @@ -0,0 +1,176 @@ +""" +Twitter/X Integration Module +Uses Composio SDK +https://docs.composio.dev/reference/sdk-reference/python +""" + +from typing import Dict, Optional +from app.core.config import settings +from composio import Composio + +TWITTER_AUTH_CONFIG_ID = "ac_46x65PoeAWsM" + +# Lazy initialization +_composio_client = None + +def get_composio_client(): + global _composio_client + if _composio_client is None: + if not settings.COMPOSIO_API_KEY: + raise ValueError("COMPOSIO_API_KEY is not set") + _composio_client = Composio(api_key=settings.COMPOSIO_API_KEY) + return _composio_client + + +async def post_tweet(user_id: str, text: str, reply_to_id: Optional[str] = None) -> Dict: + """ + Post a tweet. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + text: Tweet content (max 280 chars) + reply_to_id: Optional tweet ID to reply to + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + slug = "TWITTER_CREATION_OF_A_POST" if not reply_to_id else "TWITTER_REPLY_TO_A_POST" + + arguments = { + "text": text[:280] + } + + if reply_to_id: + arguments["tweet_id"] = reply_to_id + + result = client.tools.execute( + slug=slug, + arguments=arguments, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Twitter post_tweet failed: {e}") + return {"success": False, "error": str(e)} + + +async def reply(user_id: str, tweet_id: str, text: str) -> Dict: + """ + Reply to a tweet. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + tweet_id: Tweet ID to reply to + text: Reply content + + Returns: + Dict with success status and response data + """ + return await post_tweet(user_id, text, reply_to_id=tweet_id) + + +async def like_tweet(user_id: str, tweet_id: str) -> Dict: + """ + Like a tweet. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + tweet_id: Tweet ID to like + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="TWITTER_LIKE_A_POST", + arguments={"tweet_id": tweet_id}, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Twitter like_tweet failed: {e}") + return {"success": False, "error": str(e)} + + +async def retweet(user_id: str, tweet_id: str) -> Dict: + """ + Retweet a tweet. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + tweet_id: Tweet ID to retweet + + Returns: + Dict with success status and response data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="TWITTER_REPOST_A_POST", + arguments={"tweet_id": tweet_id}, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Twitter retweet failed: {e}") + return {"success": False, "error": str(e)} + + +async def get_timeline(user_id: str, count: int = 20) -> Dict: + """ + Get user's Twitter timeline. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + count: Number of tweets to fetch + + Returns: + Dict with success status and timeline data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="TWITTER_GET_HOME_TIMELINE", + arguments={"max_results": min(count, 100)}, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Twitter get_timeline failed: {e}") + return {"success": False, "error": str(e)} + + +async def get_mentions(user_id: str) -> Dict: + """ + Get mentions of the authenticated user. + + Args: + user_id: Composio entity ID (e.g., clerk_id) + + Returns: + Dict with success status and mentions data + """ + try: + client = get_composio_client() + + result = client.tools.execute( + slug="TWITTER_GET_USER_MENTIONS", + arguments={}, + user_id=user_id, + dangerously_skip_version_check=True, + ) + return {"success": True, "data": result} + except Exception as e: + print(f"ERROR: Twitter get_mentions failed: {e}") + return {"success": False, "error": str(e)} diff --git a/backend/app/models.py b/backend/app/models.py index f069816..0790665 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -9,6 +9,15 @@ class DraftStatus(str, Enum): PENDING = "PENDING" APPROVED = "APPROVED" REJECTED = "REJECTED" + SENT = "SENT" + +class UserSettings(BaseModel): + """User preferences that persist""" + email_notifications: bool = True + daily_digest_time: str = "9am" + auto_approve_low_risk: bool = False + personalization_threshold: int = 80 + daily_sending_limit: int = 50 class User(Document): clerk_id: Indexed(str, unique=True) @@ -16,7 +25,8 @@ class User(Document): credits: int = 10 gmail_connection_id: Optional[str] = None slack_connection_id: Optional[str] = None - other_connections: Dict[str, str] = {} # Map tool_name -> connection_id + other_connections: Dict[str, str] = {} # Map tool_name -> connection_id + settings: UserSettings = Field(default_factory=UserSettings) class Settings: name = "users" @@ -50,12 +60,14 @@ class Settings: name = "prospects" class Draft(Document): - prospect_id: str # Link to Prospect.id - subject: str + prospect_id: Optional[str] = None # Link to Prospect.id (optional for direct posts) + channel: str = "email" # email, linkedin, twitter, reddit, slack, instagram, etc. + subject: str = "" body: str - ai_reasoning: str + ai_reasoning: str = "" status: DraftStatus = DraftStatus.PENDING attachments: List[Dict] = [] # List of {"filename": str, "content_type": str, "asset_id": str} + metadata: Dict = {} # Channel-specific metadata (subreddit, message_type, etc.) class Settings: name = "drafts" @@ -72,14 +84,37 @@ class Settings: name = "mission_logs" +class PendingAction(Document): + """Stores pending actions to execute after OAuth callback""" + user_id: str # Link to User.clerk_id + mission_id: str # Which mission/chat to return to + action_type: str # e.g., "linkedin_post", "twitter_post", "slack_message" + action_data: Dict = {} # The extracted content: {"content": "...", "subreddit": "...", etc.} + tool: str # The tool that needs to be connected + created_at: datetime = Field(default_factory=datetime.utcnow) + executed: bool = False # Whether the action has been executed + + class Settings: + name = "pending_actions" + + +class AgentStats(BaseModel): + """Real-time agent statistics""" + processed: int = 0 + queued: int = 0 + errors: int = 0 + last_run_at: Optional[datetime] = None + class Agent(Document): user_id: str name: str description: Optional[str] = None - status: str = "active" # active, paused, error - workflow: Dict = {} # Stores the React Flow nodes and edges - integrations: List[str] = [] # List of enabled integration IDs - api_keys: Dict[str, str] = {} # Map of integration_id -> api_key + agent_type: str = "custom" # scout, writer, enricher, custom + status: str = "idle" # active, idle, paused, error + workflow: Dict = {} # Stores the React Flow nodes and edges + integrations: List[str] = [] # List of enabled integration IDs + api_keys: Dict[str, str] = {} # Map of integration_id -> api_key + stats: AgentStats = Field(default_factory=AgentStats) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) @@ -96,3 +131,88 @@ class UserAsset(Document): class Settings: name = "user_assets" + +class EmailEventType(str, Enum): + SENT = "sent" + REPLY_RECEIVED = "reply_received" + FOLLOW_UP = "follow_up" + OPENED = "opened" + CLICKED = "clicked" + +class EmailEvent(BaseModel): + """Individual email event in a thread""" + type: EmailEventType + timestamp: datetime = Field(default_factory=datetime.utcnow) + email_id: str # Gmail message ID + subject: Optional[str] = None + preview: Optional[str] = None # First 100 chars of body + from_email: str + to_email: str + metadata: Dict = {} # Additional data (opened_at, clicked_link, etc.) + +class ThreadStatus(str, Enum): + ACTIVE = "active" + WAITING_REPLY = "waiting_reply" + REPLIED = "replied" + CLOSED = "closed" + +class EmailThread(Document): + """Tracks complete email conversation timeline""" + user_id: str # Link to User.clerk_id + mission_id: str # Link to Mission.id + prospect_id: str # Link to Prospect.id + thread_id: str # Gmail thread ID + + # Timeline of all events + events: List[EmailEvent] = [] + + # Current status + status: ThreadStatus = ThreadStatus.WAITING_REPLY + last_activity: datetime = Field(default_factory=datetime.utcnow) + + # Quick access fields + first_sent_at: Optional[datetime] = None + last_reply_at: Optional[datetime] = None + reply_count: int = 0 + + created_at: datetime = Field(default_factory=datetime.utcnow) + + class Settings: + name = "email_threads" + + +class ContactHistory(Document): + """Track all email communications to prevent duplicates""" + user_id: str # Link to User.clerk_id + prospect_email: str # Email address (normalized/lowercase) + prospect_name: Optional[str] = None + + # First contact info + first_contacted_at: datetime = Field(default_factory=datetime.utcnow) + first_mission_id: str # Mission that first contacted this person + + # Latest contact info + last_contacted_at: datetime = Field(default_factory=datetime.utcnow) + last_mission_id: str + + # Contact count + total_emails_sent: int = 1 + + # Email thread tracking + thread_ids: List[str] = [] # All Gmail thread IDs + + # Response tracking + has_replied: bool = False + last_reply_at: Optional[datetime] = None + + # Status + status: str = "contacted" # contacted, replied, bounced, unsubscribed + + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Settings: + name = "contact_history" + indexes = [ + [("user_id", 1), ("prospect_email", 1)], # Unique per user+email + ] diff --git a/backend/app/routers/agents.py b/backend/app/routers/agents.py index 2beb66e..ac2f082 100644 --- a/backend/app/routers/agents.py +++ b/backend/app/routers/agents.py @@ -22,10 +22,44 @@ class AgentUpdate(BaseModel): integrations: Optional[List[str]] = None api_keys: Optional[Dict[str, str]] = None -@router.get("/", response_model=List[Agent]) +@router.get("/") async def get_agents(user: User = Depends(get_current_user)): - """Get all agents for the current user""" - return await Agent.find(Agent.user_id == user.clerk_id).to_list() + """Get all agents for the current user with stats""" + agents = await Agent.find(Agent.user_id == user.clerk_id).to_list() + + result = [] + now = datetime.utcnow() + + for agent in agents: + # Calculate uptime + uptime_delta = now - agent.created_at + hours = int(uptime_delta.total_seconds() // 3600) + minutes = int((uptime_delta.total_seconds() % 3600) // 60) + uptime_str = f"{hours}h {minutes}m" + + # Get stats from model or defaults + stats = agent.stats if hasattr(agent, 'stats') and agent.stats else {} + + result.append({ + "_id": str(agent.id), + "id": str(agent.id), + "name": agent.name, + "description": agent.description, + "agent_type": getattr(agent, 'agent_type', 'custom'), + "status": agent.status, + "workflow": agent.workflow, + "integrations": agent.integrations, + "stats": { + "processed": getattr(stats, 'processed', 0) if hasattr(stats, 'processed') else stats.get('processed', 0), + "queued": getattr(stats, 'queued', 0) if hasattr(stats, 'queued') else stats.get('queued', 0), + "errors": getattr(stats, 'errors', 0) if hasattr(stats, 'errors') else stats.get('errors', 0), + }, + "uptime": uptime_str, + "created_at": agent.created_at.isoformat(), + "updated_at": agent.updated_at.isoformat(), + }) + + return result @router.post("/", response_model=Agent) async def create_agent(agent_data: AgentCreate, user: User = Depends(get_current_user)): diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 587c845..52af2b2 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -30,7 +30,7 @@ async def upload_asset(file: UploadFile = File(...), user: User = Depends(get_cu size_bytes=size ) await asset.insert() - print(f"[DEBUG] Uploaded asset {asset.id} for user {user.clerk_id}") + return { "status": "success", @@ -46,12 +46,12 @@ async def options_list(): @router.get("/") async def list_assets(user: User = Depends(get_current_user)): - print(f"[DEBUG] list_assets for user: {user.clerk_id}") + try: # Fetch assets without projection (simpler, works reliably) assets = await UserAsset.find(UserAsset.user_id == user.clerk_id).to_list() - print(f"[DEBUG] Found {len(assets)} assets") + return [ { @@ -64,7 +64,7 @@ async def list_assets(user: User = Depends(get_current_user)): for a in assets ] except Exception as e: - print(f"[ERROR] list_assets failed: {e}") + return [] @router.get("/{asset_id}") @@ -94,6 +94,65 @@ async def delete_asset(asset_id: str, user: User = Depends(get_current_user)): await asset.delete() return {"status": "deleted", "asset_id": asset_id} + +@router.get("/{asset_id}/content") +async def get_asset_content(asset_id: str, user: User = Depends(get_current_user)): + """ + Extract and return the text content of a knowledge asset. + Used for RAG (Retrieval Augmented Generation) context. + """ + from app.services.rag import rag_service + + asset = await UserAsset.get(asset_id) + if not asset: + raise HTTPException(status_code=404, detail="Asset not found") + + # Verify ownership + if asset.user_id != user.clerk_id: + raise HTTPException(status_code=403, detail="Access denied") + + content = await rag_service.get_asset_content(asset_id) + + return { + "asset_id": asset_id, + "filename": asset.filename, + "content": content, + "content_length": len(content) if content else 0 + } + + +@router.post("/context") +async def build_rag_context( + asset_ids: List[str], + max_chars: int = 8000, + user: User = Depends(get_current_user) +): + """ + Build a combined context string from multiple knowledge assets. + + This is used to inject relevant knowledge into AI prompts for + content generation (posts, emails, etc.) + """ + from app.services.rag import rag_service + + # Verify ownership of all assets + for asset_id in asset_ids: + asset = await UserAsset.get(asset_id) + if not asset or asset.user_id != user.clerk_id: + raise HTTPException( + status_code=403, + detail=f"Asset {asset_id} not found or access denied" + ) + + context = await rag_service.build_context_from_assets(asset_ids, max_chars) + + return { + "context": context, + "asset_count": len(asset_ids), + "context_length": len(context) + } + + # Explicit options handler if needed for some environments (usually main CORSMiddleware handles this, but sometimes specific routers need help) from fastapi import Response @router.options("/upload") diff --git a/backend/app/routers/contacts.py b/backend/app/routers/contacts.py new file mode 100644 index 0000000..d00df11 --- /dev/null +++ b/backend/app/routers/contacts.py @@ -0,0 +1,130 @@ +from fastapi import APIRouter, Depends, HTTPException +from typing import List +from datetime import datetime +from beanie.operators import In +from pydantic import BaseModel + +from app.models import ContactHistory, User +from app.api.deps import get_current_user + +router = APIRouter() + + +class CheckDuplicatesRequest(BaseModel): + emails: List[str] + + +class RecordContactRequest(BaseModel): + email: str + name: str = None + mission_id: str + thread_id: str = None + + +@router.get("/history") +async def get_contact_history( + skip: int = 0, + limit: int = 50, + user: User = Depends(get_current_user) +): + """Get all previously contacted prospects""" + contacts = await ContactHistory.find( + ContactHistory.user_id == user.clerk_id + ).sort(-ContactHistory.last_contacted_at).skip(skip).limit(limit).to_list() + + return contacts + + +@router.post("/check-duplicates") +async def check_duplicates( + request: CheckDuplicatesRequest, + user: User = Depends(get_current_user) +): + """Check which emails have been contacted before""" + normalized_emails = [email.lower().strip() for email in request.emails] + + existing = await ContactHistory.find( + In(ContactHistory.prospect_email, normalized_emails), + ContactHistory.user_id == user.clerk_id + ).to_list() + + existing_map = {c.prospect_email: c for c in existing} + + duplicates = [ + { + "email": email, + "first_contacted": existing_map[email].first_contacted_at.isoformat(), + "last_contacted": existing_map[email].last_contacted_at.isoformat(), + "total_emails": existing_map[email].total_emails_sent, + "has_replied": existing_map[email].has_replied, + "status": existing_map[email].status + } + for email in normalized_emails + if email in existing_map + ] + + new_contacts = [ + email for email in normalized_emails + if email not in existing_map + ] + + return { + "duplicates": duplicates, + "new_contacts": new_contacts + } + + +@router.post("/record") +async def record_contact( + request: RecordContactRequest, + user: User = Depends(get_current_user) +): + """Record a new email contact or update existing""" + email = request.email.lower().strip() + + existing = await ContactHistory.find_one( + ContactHistory.user_id == user.clerk_id, + ContactHistory.prospect_email == email + ) + + if existing: + # Update existing + existing.last_contacted_at = datetime.utcnow() + existing.last_mission_id = request.mission_id + existing.total_emails_sent += 1 + existing.updated_at = datetime.utcnow() + if request.thread_id and request.thread_id not in existing.thread_ids: + existing.thread_ids.append(request.thread_id) + await existing.save() + return existing + else: + # Create new + contact = ContactHistory( + user_id=user.clerk_id, + prospect_email=email, + prospect_name=request.name, + first_mission_id=request.mission_id, + last_mission_id=request.mission_id, + thread_ids=[request.thread_id] if request.thread_id else [] + ) + await contact.insert() + return contact + + +@router.get("/stats") +async def get_contact_stats(user: User = Depends(get_current_user)): + """Get contact statistics""" + total_contacts = await ContactHistory.find( + ContactHistory.user_id == user.clerk_id + ).count() + + replied_contacts = await ContactHistory.find( + ContactHistory.user_id == user.clerk_id, + ContactHistory.has_replied == True + ).count() + + return { + "total_contacts": total_contacts, + "replied_contacts": replied_contacts, + "reply_rate": (replied_contacts / total_contacts * 100) if total_contacts > 0 else 0 + } diff --git a/backend/app/routers/integrations.py b/backend/app/routers/integrations.py index fdc81ea..bc3e70a 100644 --- a/backend/app/routers/integrations.py +++ b/backend/app/routers/integrations.py @@ -1,17 +1,45 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Optional, Dict +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Optional, Dict, List from app.api.deps import get_current_user -from app.models import User +from app.models import User, PendingAction from app.core.config import settings import httpx +import urllib.parse router = APIRouter() +EMAIL_SCRAPING_REPOS: List[Dict[str, str]] = [ + { + "name": "steveclarke/email-scraper", + "url": "https://github.com/steveclarke/email-scraper", + "description": "Python scraper that extracts emails from websites with domain filtering support." + }, + { + "name": "mailboxvalidator/email-finder", + "url": "https://github.com/mailboxvalidator/email-finder", + "description": "Email discovery tool combining scraping and validation heuristics." + }, + { + "name": "mario/email-extractor", + "url": "https://github.com/mario/email-extractor", + "description": "CLI utility to crawl pages and output deduplicated email addresses." + } +] + +@router.get("/email-scraping/repositories") +async def list_email_scraping_repositories(_: User = Depends(get_current_user)) -> Dict[str, List[Dict[str, str]]]: + """ + Return a curated list of repositories that perform email scraping. + This helps authenticated users discover open-source tooling. + """ + return {"repositories": EMAIL_SCRAPING_REPOS} + @router.post("/gmail/connect") -async def connect_gmail(user: User = Depends(get_current_user)): +async def connect_gmail(redirect_url: str = None, user: User = Depends(get_current_user)): """ Initiate Gmail connection via Composio. Returns the redirect URL for the user to authenticate. + Optional redirect_url param allows returning to a specific page after OAuth. """ # Using v1/connected_accounts which is the current standard (often called v2/v3 in SDKs but v1 in generic API paths sometimes, OR it is v1/connected_accounts) # The deprecated one was v1/connections. @@ -26,18 +54,22 @@ async def connect_gmail(user: User = Depends(get_current_user)): "connection": {"user_id": user.clerk_id} } + # Add custom redirect if provided (Composio may support this in some integrations) + if redirect_url: + payload["redirect_url"] = redirect_url + async with httpx.AsyncClient() as client: - print(f"Sending payload to {url}: {payload}") # Debug log + resp = await client.post(url, json=payload, headers=headers) if resp.status_code not in [200, 201, 202]: - print(f"Composio Error: {resp.text}") # Log error to terminal + raise HTTPException(status_code=400, detail=f"Composio Error: {resp.text}") data = resp.json() - print(f"Composio Response: {data}") # Debug log + connection_id = data.get("id") or data.get("connection_id") - redirect_url = data.get("redirectUrl") or data.get("redirect_url") + composio_redirect = data.get("redirectUrl") or data.get("redirect_url") if not connection_id: raise HTTPException(status_code=500, detail="No connection_id returned from Composio") @@ -46,7 +78,14 @@ async def connect_gmail(user: User = Depends(get_current_user)): user.gmail_connection_id = connection_id await user.save() - return {"redirect_url": redirect_url} + # If user provided a redirect_url, append it as a query param so frontend can redirect after OAuth + final_redirect = composio_redirect + if redirect_url and composio_redirect: + # Append our redirect as a query param (some OAuth flows support this) + separator = "&" if "?" in composio_redirect else "?" + final_redirect = f"{composio_redirect}{separator}return_to={redirect_url}" + + return {"redirect_url": final_redirect, "return_to": redirect_url} @router.get("/gmail/status") async def get_gmail_status(user: User = Depends(get_current_user)): @@ -55,11 +94,10 @@ async def get_gmail_status(user: User = Depends(get_current_user)): """ if not user.gmail_connection_id: return {"status": "INACTIVE"} - - # Verify with Composio + url = f"https://backend.composio.dev/api/v3/connected_accounts/{user.gmail_connection_id}" headers = {"x-api-key": settings.COMPOSIO_API_KEY} - + if not settings.COMPOSIO_API_KEY: return {"status": "INACTIVE"} # Config missing @@ -68,15 +106,15 @@ async def get_gmail_status(user: User = Depends(get_current_user)): resp = await client.get(url, headers=headers) if resp.status_code == 200: data = resp.json() - # Check status field. might be "status": "ACTIVE" or "connected" status = data.get("status") if status == "ACTIVE" or status == "CONNECTED": return {"status": "ACTIVE"} except Exception: pass # Fallback to inactive on error - + return {"status": "INACTIVE"} + @router.post("/slack/connect") async def connect_slack(user: User = Depends(get_current_user)): """ @@ -98,7 +136,7 @@ async def connect_slack(user: User = Depends(get_current_user)): async with httpx.AsyncClient() as client: resp = await client.post(url, json=payload, headers=headers) if resp.status_code not in [200, 201, 202]: - print(f"Composio Error: {resp.text}") + raise HTTPException(status_code=400, detail=f"Composio Error: {resp.text}") data = resp.json() @@ -146,7 +184,9 @@ async def get_slack_status(user: User = Depends(get_current_user)): "reddit": "ac_2_IjyXggGH8F", "perplexity": "ac_9u_yICXpCVs4", "google_sheets": "ac_E9vuh1t4AzEu", - "sheets": "ac_E9vuh1t4AzEu" # Alias + "sheets": "ac_E9vuh1t4AzEu", # Alias + "linkedin": "ac_SdzD1ondK6Zi", + "twitter": "ac_46x65PoeAWsM" } from pydantic import BaseModel @@ -169,22 +209,23 @@ async def connect_tool(req: ConnectRequest, user: User = Depends(get_current_use "connection": {"user_id": user.clerk_id} } + redirect_url = None + if req.params and "redirect_url" in req.params: + redirect_url = req.params.pop("redirect_url") + if req.params: # Pass user inputs as top-level 'data' (common for Composio inputs) payload["data"] = req.params + if redirect_url: + # Try both common variations for V3 API + payload["redirectUrl"] = redirect_url + payload["redirect_uri"] = redirect_url + if auth_config_id: payload["auth_config"] = {"id": auth_config_id} auth_mode = "auth_config" else: - # Try identifying by integrationId directly - # v3 supports creating via integrationId if no auth config needed or default available ? - # Actually, better to default to 'integrationId' logic if we strictly don't have auth config - # But usually Composio needs auth config. - # For this specific user request, I will assume we might fallback or error. - # I will use 'integrationId' as key. - # Note: v3 connected_accounts usually takes auth_config. - # For simplicity, if unknown, I'll error gracefully or try integrationId payload["integrationId"] = tool auth_mode = "integrationId" @@ -192,16 +233,15 @@ async def connect_tool(req: ConnectRequest, user: User = Depends(get_current_use headers = {"x-api-key": settings.COMPOSIO_API_KEY} async with httpx.AsyncClient() as client: - print(f"Connecting {tool} (mode={auth_mode}): {payload}") + resp = await client.post(url, json=payload, headers=headers) if resp.status_code not in [200, 201, 202]: - print(f"Composio Error: {resp.text}") raise HTTPException(status_code=400, detail=f"Composio Error: {resp.text}") data = resp.json() connection_id = data.get("id") or data.get("connection_id") - redirect_url = data.get("redirectUrl") or data.get("redirect_url") + composio_redirect = data.get("redirectUrl") or data.get("redirect_url") if not connection_id: raise HTTPException(status_code=500, detail="No connection_id returned") @@ -218,7 +258,9 @@ async def connect_tool(req: ConnectRequest, user: User = Depends(get_current_use await user.save() - return {"redirect_url": redirect_url, "connection_id": connection_id} + # Return the Composio URL directly. + # If payload['redirectUrl'] works, Composio will handle the final redirect. + return {"redirect_url": composio_redirect, "connection_id": connection_id} @router.get("/") async def list_integrations(user: User = Depends(get_current_user)): @@ -259,3 +301,44 @@ async def disconnect_integration(tool: str, user: User = Depends(get_current_use await user.save() return {"status": "disconnected", "tool": tool} + +@router.get("/{tool}/status") +async def get_tool_status(tool: str, user: User = Depends(get_current_user)): + """Check the status of any integration""" + tool = tool.lower() + + # Get connection ID for the tool + connection_id = None + if tool == "gmail": + connection_id = user.gmail_connection_id + elif tool == "slack": + connection_id = user.slack_connection_id + elif user.other_connections: + connection_id = user.other_connections.get(tool) + + if not connection_id: + return {"status": "INACTIVE", "tool": tool} + + # Verify connection status with Composio + if settings.COMPOSIO_API_KEY: + url = f"https://backend.composio.dev/api/v3/connected_accounts/{connection_id}" + headers = {"x-api-key": settings.COMPOSIO_API_KEY} + + async with httpx.AsyncClient() as client: + try: + resp = await client.get(url, headers=headers, timeout=10.0) + if resp.status_code == 200: + data = resp.json() + status = data.get("status", "UNKNOWN") + # Only return ACTIVE if explicitly connected + if status in ["ACTIVE", "CONNECTED"]: + return {"status": "ACTIVE", "tool": tool, "connection_id": connection_id} + else: + return {"status": status, "tool": tool, "connection_id": connection_id} + except Exception as e: + pass + + # Fallback: If we can't verify, we should probably be cautious. + # But for UX, if we have an ID, typically we assume pending/inactive if check failed? + # Returning INACTIVE ensures we don't assume success erroneously. + return {"status": "INACTIVE", "tool": tool, "connection_id": connection_id} diff --git a/backend/app/routers/missions.py b/backend/app/routers/missions.py index 714e581..3d8b18c 100644 --- a/backend/app/routers/missions.py +++ b/backend/app/routers/missions.py @@ -1,42 +1,505 @@ from fastapi import APIRouter, Depends, HTTPException from typing import List, Optional, Dict -from app.models import Mission, User, MissionLog +from app.models import Mission, User, MissionLog, PendingAction from app.api.deps import get_current_user from app.core.agent import run_mission_agent +from app.core.config import settings from pydantic import BaseModel import asyncio +import re router = APIRouter() +def sanitize_json_string(content: str) -> str: + """ + Sanitize a JSON string by properly escaping control characters. + LLMs often generate JSON with unescaped newlines/tabs in string values. + """ + # First, extract JSON from markdown code blocks if present + if "```json" in content: + content = content.split("```json")[1].split("```")[0].strip() + elif "```" in content: + content = content.replace("```", "").strip() + + # Replace actual control characters inside string values with escaped versions + # This regex finds strings and escapes control characters within them + def escape_string_content(match): + s = match.group(0) + # Replace unescaped control characters + s = s.replace('\n', '\\n') + s = s.replace('\r', '\\r') + s = s.replace('\t', '\\t') + return s + + # Match JSON string values (between quotes, handling escaped quotes) + # This is a simplified approach - replace control chars globally first + content = content.replace('\r\n', '\\n').replace('\r', '\\n') + + # Handle newlines that are inside JSON string values + # Find content between quotes and escape newlines + result = [] + in_string = False + escape_next = False + + for char in content: + if escape_next: + result.append(char) + escape_next = False + continue + + if char == '\\': + result.append(char) + escape_next = True + continue + + if char == '"': + in_string = not in_string + result.append(char) + continue + + if in_string: + if char == '\n': + result.append('\\n') + elif char == '\t': + result.append('\\t') + else: + result.append(char) + else: + result.append(char) + + return ''.join(result) + class MissionCreate(BaseModel): objective: str attachments: Optional[List[Dict]] = [] # List of {asset_id, filename, content_type} +# Direct action patterns - bypass agent workflow for these +DIRECT_ACTION_PATTERNS = { + "twitter_post": ["post on twitter", "tweet this", "create a tweet", "post a tweet", "tweet:", "post to twitter", "send tweet", "twitter post", "publish a tweet", "publish on twitter"], + "reddit_post": ["post on reddit", "create a reddit post", "post to reddit", "post in r/", "submit to reddit", "reddit post", "post to r/", "post a reddit", "publish a reddit", "publish on reddit", "publish to reddit", "reddit about", "in r/", "in reddit", "on reddit"], + "linkedin_post": ["post on linkedin", "create a linkedin post", "share on linkedin", "linkedin post", "post to linkedin", "post a linkedin", "publish on linkedin", "publish a linkedin", "on linkedin"], + "slack_message": ["send to slack", "slack message", "post to slack", "message on slack", "notify slack"], + "gmail_send": ["send email", "send an email", "email to", "compose email", "mail to"], +} + +async def detect_and_execute_direct_action(objective: str, mission_id: str, user: User, attachments: List[Dict] = None) -> Optional[Dict]: + """ + Detect if the objective is a direct action (post, tweet, etc.) and execute it. + Returns response dict if handled, None if should continue with normal agent flow. + + Supports RAG: If attachments are provided, their content will be used to enhance + the generated content. + """ + obj_lower = objective.lower() + attachments = attachments or [] + + detected_action = None + for action_type, patterns in DIRECT_ACTION_PATTERNS.items(): + if any(p in obj_lower for p in patterns): + detected_action = action_type + break + + if not detected_action: + return None # Not a direct action, continue with normal flow + + # Log that we're processing a direct action + await MissionLog( + mission_id=mission_id, + role="agent", + content=f"Processing direct {detected_action.replace('_', ' ')} request...", + log_type="thinking" + ).insert() + + # Build RAG context from attachments if provided + rag_context = "" + if attachments: + from app.services.rag import rag_service + asset_ids = [att.get("asset_id") for att in attachments if att.get("asset_id")] + if asset_ids: + rag_context = await rag_service.build_context_from_assets(asset_ids, max_chars=6000) + if rag_context: + await MissionLog( + mission_id=mission_id, + role="agent", + content=f"πŸ“š Using context from {len(asset_ids)} knowledge asset(s) to generate content...", + log_type="thinking" + ).insert() + + # Use LLM to extract action content + from langchain_groq import ChatGroq + from langchain_core.messages import SystemMessage, HumanMessage + import json + + try: + extract_llm = ChatGroq( + temperature=0.0, + groq_api_key=settings.GROQ_API_KEY, + model_name="llama-3.1-8b-instant" + ) + + # Add RAG context to prompts if available + rag_injection = "" + if rag_context: + rag_injection = f""" + +IMPORTANT: Use the following knowledge base content to inform and enhance your generated content. +Extract key details, features, benefits, and talking points from this source material: + +{rag_context} + +--- +""" + + if detected_action == "twitter_post": + extraction_prompt = [ + SystemMessage(content=f"""Extract the tweet content from the user's request. +Return JSON only: {{"tweet": "the tweet text", "ready": true/false}} +Set ready=true and generate the tweet based on what they want to promote/share. +Max 280 characters for tweet.{rag_injection}"""), + HumanMessage(content=f"User request: {objective}") + ] + elif detected_action == "reddit_post": + extraction_prompt = [ + SystemMessage(content=f"""Extract Reddit post details from the user's request. +Return JSON only: {{"subreddit": "subreddit_name", "title": "post title", "body": "post content", "ready": true/false}} + +IMPORTANT: +- If they mention a specific subreddit (r/something), extract it and set ready=true +- If they DON'T specify a subreddit, set ready=false and leave subreddit empty +- Generate a compelling title and body based on what they want to promote +- Make the content authentic and not spammy{rag_injection}"""), + HumanMessage(content=f"User request: {objective}") + ] + elif detected_action == "linkedin_post": + extraction_prompt = [ + SystemMessage(content=f"""Extract LinkedIn post content from the user's request. +Return JSON only: {{"content": "the post text", "ready": true/false}} +Generate professional LinkedIn post content based on what they want to share/promote. Set ready=true.{rag_injection}"""), + HumanMessage(content=f"User request: {objective}") + ] + elif detected_action == "slack_message": + extraction_prompt = [ + SystemMessage(content=f"""Extract Slack message details from the user's request. +Return JSON only: {{"channel": "channel_name_or_id", "message": "the message text", "ready": true/false}} +Set ready=false if channel is unclear.{rag_injection}"""), + HumanMessage(content=f"User request: {objective}") + ] + elif detected_action == "gmail_send": + extraction_prompt = [ + SystemMessage(content=f"""Extract email details from the user's request. +Return JSON only: {{"to": "recipient@email.com", "subject": "email subject", "body": "email body", "ready": true/false}} +Set ready=false if recipient email is unclear.{rag_injection}"""), + HumanMessage(content=f"User request: {objective}") + ] + else: + return None + + ex_res = await extract_llm.ainvoke(extraction_prompt) + content_str = sanitize_json_string(ex_res.content) + + action_data = json.loads(content_str) + + # If not ready, ask for clarification + if not action_data.get("ready", False): + clarify_msg = f"I detected you want to {detected_action.replace('_', ' ')}. Could you provide more details?" + if detected_action == "reddit_post": + clarify_msg = "Which subreddit should I post to? Please reply with the subreddit name (e.g., 'r/SaaS' or 'r/startups')." + elif detected_action == "slack_message": + clarify_msg = "Which Slack channel should I send to? Please reply with the channel name." + + await MissionLog(mission_id=mission_id, role="agent", content=clarify_msg, log_type="action").insert() + return {"handled": True, "needs_input": True, "action_type": detected_action, "action_data": action_data} + + # Get tool connection + tool_map = { + "twitter_post": "twitter", + "reddit_post": "reddit", + "linkedin_post": "linkedin", + "slack_message": "slack", + "gmail_send": "gmail" + } + tool = tool_map.get(detected_action) + + # Check connection + conn_id = None + if tool == "slack": + conn_id = user.slack_connection_id + elif tool == "gmail": + conn_id = user.gmail_connection_id + else: + conn_id = user.other_connections.get(tool) if user.other_connections else None + + if not conn_id: + # Create pending action and return connect prompt + pending = PendingAction( + user_id=user.clerk_id, + mission_id=mission_id, + action_type=detected_action, + action_data=action_data, + tool=tool + ) + await pending.insert() + + # Generate OAuth URL + import httpx + TOOL_CONFIG_MAP = { + "linkedin": "ac_SdzD1ondK6Zi", + "twitter": "ac_EjFjyYk1dXE2", + "reddit": "ac_2_IjyXggGH8F", + "github": "ac_UE__S2Ls9sMT", + "slack": "ac_YPQ1Q5xomR5i", + "gmail": settings.COMPOSIO_AUTH_CONFIG_ID + } + + auth_config_id = TOOL_CONFIG_MAP.get(tool) + frontend_base = "http://localhost:5173" + redirect_url = f"{frontend_base}/chat/{mission_id}?pending_action={pending.id}" + + url = "https://backend.composio.dev/api/v3/connected_accounts" + headers = {"x-api-key": settings.COMPOSIO_API_KEY} + payload = { + "auth_config": {"id": auth_config_id}, + "connection": {"user_id": user.clerk_id}, + "redirectUrl": redirect_url, + "redirect_uri": redirect_url + } + + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=payload, headers=headers, timeout=30.0) + if resp.status_code in [200, 201, 202]: + data = resp.json() + composio_redirect = data.get("redirectUrl") or data.get("redirect_url") + connection_id = data.get("id") or data.get("connection_id") + + # Pre-save connection ID + if connection_id: + if tool == "slack": + user.slack_connection_id = connection_id + elif tool == "gmail": + user.gmail_connection_id = connection_id + else: + if not user.other_connections: + user.other_connections = {} + user.other_connections[tool] = connection_id + await user.save() + + msg = f"To post on {tool.title()}, please connect your account first. Click the button below to connect, and I'll publish automatically!" + await MissionLog( + mission_id=mission_id, + role="agent", + content=msg, + log_type="action", + metadata={"action": "connect_tool", "tool": tool, "connect_url": composio_redirect, "pending_action_id": str(pending.id)} + ).insert() + return {"handled": True, "needs_connection": True} + + # Execute the action! + result = {"success": False, "error": "Unknown action"} + + # Instead of auto-posting, create a draft preview and let user confirm + # Save the action data in PendingAction for later execution + pending = PendingAction( + user_id=user.clerk_id, + mission_id=mission_id, + action_type=detected_action, + action_data=action_data, + tool=tool + ) + await pending.insert() + + # For emails: send to Review Queue (no Post Now in chat) + # For social media: show draft_preview in chat with Post Now button + if detected_action == "gmail_send": + # Create a Draft for Review Queue + from app.models import Draft, DraftStatus, Prospect + + # Create a minimal prospect for the email recipient + prospect = Prospect( + mission_id=mission_id, + name=action_data.get("to", "").split("@")[0].replace(".", " ").title(), + company="Email Contact", + context_source="Direct Email", + public_contact=action_data.get("to", ""), + relevance_score=1.0, + relevance_reason="Direct email request from mission chat" + ) + await prospect.insert() + + # Create Draft in Review Queue + draft = Draft( + prospect_id=str(prospect.id), + channel="email", + subject=action_data.get("subject", ""), + body=action_data.get("body", ""), + ai_reasoning="Generated from mission chat request", + status=DraftStatus.PENDING, + attachments=action_data.get("attachments", []) + ) + await draft.insert() + + # Mark the pending action as pointing to this draft + pending.action_data["draft_id"] = str(draft.id) + await pending.save() + + # Log to chat - direct user to Review Queue + preview_content = f"πŸ“§ **Email Draft Created**\n\n**To:** {action_data.get('to', '')}\n**Subject:** {action_data.get('subject', '')}\n\n{action_data.get('body', '')[:200]}..." + await MissionLog( + mission_id=mission_id, + role="agent", + content=preview_content, + log_type="success", + metadata={ + "action": "draft_ready", + "draft_id": str(draft.id), + "channel": "email" + } + ).insert() + + return {"handled": True, "draft_created": True, "draft_id": str(draft.id)} + + # For social media: show preview in chat with Post Now button + # Also create a Draft so Edit button can redirect to Review Queue + from app.models import Draft, DraftStatus + + # Determine channel and content for Draft + channel_map = { + "twitter_post": "twitter", + "reddit_post": "reddit", + "linkedin_post": "linkedin", + "slack_message": "slack", + "github_issue": "github" + } + channel = channel_map.get(detected_action, "email") + + # Create Draft for Review Queue (for Edit flow) + draft = Draft( + prospect_id=None, # Social media posts don't always have a prospect + channel=channel, + subject=action_data.get("title") or action_data.get("subject", ""), + body=action_data.get("content") or action_data.get("body") or action_data.get("tweet") or action_data.get("message", ""), + ai_reasoning="Generated from mission chat request", + status=DraftStatus.PENDING, + metadata={ + "subreddit": action_data.get("subreddit"), + "slackChannel": action_data.get("channel"), + "repo": action_data.get("repo"), + "pending_action_id": str(pending.id) + } + ) + await draft.insert() + + # Link draft to pending action + pending.action_data["draft_id"] = str(draft.id) + await pending.save() + + # Format preview message based on action type + preview_content = "" + if detected_action == "twitter_post": + preview_content = f"**Tweet Preview:**\n\n{action_data.get('tweet', '')}" + elif detected_action == "reddit_post": + preview_content = f"**Reddit Post Preview:**\n\n**Subreddit:** r/{action_data.get('subreddit', 'unknown')}\n\n**Title:** {action_data.get('title', '')}\n\n**Content:**\n{action_data.get('body', '')}" + elif detected_action == "linkedin_post": + preview_content = f"**LinkedIn Post Preview:**\n\n{action_data.get('content', '')}" + elif detected_action == "slack_message": + preview_content = f"**Slack Message Preview:**\n\n**Channel:** #{action_data.get('channel', 'general')}\n\n**Message:**\n{action_data.get('message', '')}" + elif detected_action == "github_issue": + preview_content = f"**GitHub Issue Preview:**\n\n**Repo:** {action_data.get('repo', '')}\n**Title:** {action_data.get('title', '')}\n\n**Body:**\n{action_data.get('body', '')}" + + # Log the preview with action button metadata (Post Now + Edit) + await MissionLog( + mission_id=mission_id, + role="agent", + content=preview_content, + log_type="action", + metadata={ + "action": "draft_preview", + "action_type": detected_action, + "pending_action_id": str(pending.id), + "draft_id": str(draft.id), + "tool": tool, + "channel": channel, + "action_data": action_data + } + ).insert() + + return {"handled": True, "draft_created": True, "pending_action_id": str(pending.id)} + + except Exception as e: + error_msg = f"Error processing action: {str(e)[:100]}" + await MissionLog(mission_id=mission_id, role="agent", content=error_msg, log_type="error").insert() + return {"handled": True, "error": str(e)} + @router.post("/", response_model=Mission) async def create_mission(mission_in: MissionCreate, user: User = Depends(get_current_user)): mission = Mission(user_id=user.clerk_id, objective=mission_in.objective) await mission.insert() + mission_id = str(mission.id) + # Save initial log entry attachment_msg = "" if mission_in.attachments: attachment_msg = f" with {len(mission_in.attachments)} attachment(s)" initial_log = MissionLog( - mission_id=str(mission.id), + mission_id=mission_id, role="system", content=f"Mission started: {mission_in.objective}{attachment_msg}", log_type="success" ) await initial_log.insert() - # Trigger background agent with attachments - asyncio.create_task(run_mission_agent(str(mission.id), mission_in.objective, user.clerk_id, mission_in.attachments or [])) + # Check if this is a direct action (post, tweet, etc.) - execute immediately + action_result = await detect_and_execute_direct_action( + mission_in.objective, + mission_id, + user, + attachments=mission_in.attachments + ) + + if action_result and action_result.get("handled"): + # Direct action was handled, don't start agent workflow + return mission + + # Not a direct action - trigger background agent for outreach workflow + asyncio.create_task(run_mission_agent(mission_id, mission_in.objective, user.clerk_id, mission_in.attachments or [])) return mission -@router.get("/", response_model=List[Mission]) +@router.get("/") async def list_missions(user: User = Depends(get_current_user)): - return await Mission.find(Mission.user_id == user.clerk_id).to_list() + from app.models import Prospect, Draft, DraftStatus + + missions = await Mission.find(Mission.user_id == user.clerk_id).to_list() + + # Enrich with counts + result = [] + for mission in missions: + mission_id = str(mission.id) + + # Count prospects for this mission + prospects_count = await Prospect.find(Prospect.mission_id == mission_id).count() + + # Count pending drafts for this mission (via prospect_id lookup) + prospects = await Prospect.find(Prospect.mission_id == mission_id).to_list() + prospect_ids = [str(p.id) for p in prospects] + drafts_count = 0 + if prospect_ids: + drafts_count = await Draft.find( + {"prospect_id": {"$in": prospect_ids}, "status": DraftStatus.PENDING} + ).count() + + result.append({ + "_id": str(mission.id), + "id": str(mission.id), + "user_id": mission.user_id, + "objective": mission.objective, + "status": mission.status, + "created_at": mission.created_at.isoformat(), + "prospects_count": prospects_count, + "drafts_count": drafts_count, + }) + + return result @router.get("/{mission_id}/logs") async def get_mission_logs(mission_id: str, user: User = Depends(get_current_user)): @@ -59,6 +522,131 @@ async def get_mission_logs(mission_id: str, user: User = Depends(get_current_use for log in logs ] +@router.post("/pending-action/{action_id}/execute") +async def execute_pending_action(action_id: str, user: User = Depends(get_current_user)): + """Execute a pending action after OAuth callback""" + from app.core.config import settings + + # Find the pending action + pending = await PendingAction.get(action_id) + if not pending: + raise HTTPException(status_code=404, detail="Pending action not found") + + if pending.user_id != user.clerk_id: + raise HTTPException(status_code=403, detail="Not authorized") + + if pending.executed: + return {"success": False, "message": "Action already executed", "already_executed": True} + + # Get connection ID for the tool + tool = pending.tool + conn_id = None + if tool == "slack": + conn_id = user.slack_connection_id + elif tool == "gmail": + conn_id = user.gmail_connection_id + else: + conn_id = user.other_connections.get(tool) if user.other_connections else None + + if not conn_id: + return {"success": False, "message": f"{tool.title()} is still not connected. Please try connecting again."} + + # Verify connection is active with Composio + import httpx + url = f"https://backend.composio.dev/api/v3/connected_accounts/{conn_id}" + headers = {"x-api-key": settings.COMPOSIO_API_KEY} + + async with httpx.AsyncClient() as client: + try: + resp = await client.get(url, headers=headers, timeout=10.0) + if resp.status_code == 200: + data = resp.json() + status = data.get("status", "") + if status not in ["ACTIVE", "CONNECTED"]: + return {"success": False, "message": f"{tool.title()} connection is not active yet. Status: {status}. Please wait a moment and try again."} + else: + return {"success": False, "message": f"Could not verify {tool.title()} connection status."} + except Exception as e: + return {"success": False, "message": f"Error checking connection: {str(e)}"} + + # Execute the action based on type + action_data = pending.action_data + result = {"success": False, "error": "Unknown action type"} + + try: + if pending.action_type == "linkedin_post": + from app.integrations import linkedin + result = await linkedin.publish_post(user.clerk_id, action_data.get("content", "")) + + elif pending.action_type == "twitter_post": + from app.integrations import twitter + result = await twitter.post_tweet(user.clerk_id, action_data.get("tweet", "")) + + elif pending.action_type == "reddit_post": + from app.integrations import reddit + result = await reddit.create_post( + user.clerk_id, # Use clerk_id as Composio entity ID + action_data.get("subreddit", "test"), + action_data.get("title", "Post"), + action_data.get("body", "") + ) + + elif pending.action_type == "slack_message": + from app.integrations import slack + result = await slack.send_message( + user.clerk_id, + action_data.get("channel", "general"), + action_data.get("message", "") + ) + + elif pending.action_type == "gmail_send": + from app.core.sender import send_email_via_composio + result = await send_email_via_composio( + user.clerk_id, + action_data.get("to", ""), + action_data.get("subject", ""), + action_data.get("body", "") + ) + + elif pending.action_type == "github_issue": + from app.integrations import github + repo_str = action_data.get("repo", "") + if "/" in repo_str: + owner, repo_name = repo_str.split("/", 1) + else: + owner, repo_name = repo_str, "" + result = await github.create_issue( + user.clerk_id, + owner, + repo_name, + action_data.get("title", ""), + action_data.get("body", "") + ) + except Exception as e: + result = {"success": False, "error": str(e)} + + # Mark as executed + pending.executed = True + await pending.save() + + # Log the result to the mission chat + if result.get("success"): + success_messages = { + "linkedin_post": "βœ… LinkedIn post published successfully!", + "twitter_post": "βœ… Tweet posted successfully!", + "reddit_post": f"βœ… Posted to r/{action_data.get('subreddit', 'unknown')} successfully!", + "slack_message": f"βœ… Message sent to Slack #{action_data.get('channel', 'general')}!", + "gmail_send": f"βœ… Email sent to {action_data.get('to', 'recipient')}!", + "github_issue": f"βœ… Issue created in {action_data.get('repo', 'repo')}!" + } + msg = success_messages.get(pending.action_type, "βœ… Action completed successfully!") + await MissionLog(mission_id=pending.mission_id, role="agent", content=msg, log_type="success").insert() + return {"success": True, "message": msg, "mission_id": pending.mission_id} + else: + error_msg = f"❌ Failed to execute: {result.get('error', 'Unknown error')}" + await MissionLog(mission_id=pending.mission_id, role="agent", content=error_msg, log_type="error").insert() + return {"success": False, "message": error_msg, "mission_id": pending.mission_id} + class ChatMessage(BaseModel): message: str @@ -87,10 +675,399 @@ async def chat_with_mission(mission_id: str, chat: ChatMessage, user: User = Dep msg_lower = chat.message.lower() + import re + force_draft = False + + # CHECK FOR SUBREDDIT NAME (follow-up to Reddit post request) + # Matches r/SomeName or just SomeName when mission is about Reddit + subreddit_regex = r"^r/(\w+)$|^(\w+)$" + subreddit_match = re.match(subreddit_regex, chat.message.strip(), re.IGNORECASE) + + # Check if mission objective mentions Reddit and this looks like a subreddit response + if subreddit_match and ("reddit" in mission.objective.lower() or "r/" in mission.objective.lower()): + # User is providing a subreddit for a Reddit post + subreddit_name = subreddit_match.group(1) or subreddit_match.group(2) + + # Generate the Reddit post content using the mission objective + try: + from langchain_groq import ChatGroq + from langchain_core.messages import SystemMessage, HumanMessage + import json + + gen_llm = ChatGroq( + temperature=0.7, + groq_api_key=settings.GROQ_API_KEY, + model_name="llama-3.3-70b-versatile" + ) + + gen_prompt = [ + SystemMessage(content=f"""Generate a Reddit post for r/{subreddit_name}. +Create engaging, authentic content that matches the subreddit culture. +Return JSON only: {{"title": "post title", "body": "post content"}} +Make the content informative and valuable to the community."""), + HumanMessage(content=f"Topic from user's mission: {mission.objective}") + ] + + gen_res = await gen_llm.ainvoke(gen_prompt) + content_str = sanitize_json_string(gen_res.content) + post_data = json.loads(content_str) + + # Check if user has Reddit connected + reddit_conn = user.other_connections.get("reddit") if user.other_connections else None + + if not reddit_conn: + # Create pending action and ask to connect + pending = PendingAction( + user_id=user.clerk_id, + mission_id=mission_id, + action_type="reddit_post", + action_data={ + "subreddit": subreddit_name, + "title": post_data.get("title", ""), + "body": post_data.get("body", ""), + "ready": True + }, + tool="reddit" + ) + await pending.insert() + + # Generate OAuth URL for Reddit + import httpx + auth_config_id = "ac_2_IjyXggGH8F" + frontend_base = "http://localhost:5173" + redirect_url = f"{frontend_base}/chat/{mission_id}?pending_action={pending.id}" + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + "https://backend.composio.dev/api/v3/auth/create-url", + json={ + "authConfigId": auth_config_id, + "redirectUrl": redirect_url, + "entityId": user.clerk_id + }, + headers={"x-api-key": settings.COMPOSIO_API_KEY} + ) + if resp.status_code == 200: + composio_redirect = resp.json().get("url") + msg = f"I've drafted your Reddit post for r/{subreddit_name}. Please connect your Reddit account first to post." + await MissionLog( + mission_id=mission_id, + role="agent", + content=msg, + log_type="action", + metadata={"action": "connect_tool", "tool": "reddit", "connect_url": composio_redirect, "pending_action_id": str(pending.id)} + ).insert() + return {"message": msg, "role": "agent", "type": "action", "metadata": {"connect_url": composio_redirect}} + except Exception as e: + print(f"Failed to get Reddit OAuth URL: {e}") + + return {"message": "Please connect your Reddit account to post.", "role": "agent", "type": "action"} + + # Reddit is connected - create draft with Post Now button + from app.models import Prospect, Draft, DraftStatus + + # Create prospect for this post (Reddit post target) + prospect = Prospect( + mission_id=mission_id, + name=f"Reddit: r/{subreddit_name}", + company="Reddit", + context_source="Reddit Post", + public_contact=f"r/{subreddit_name}", + relevance_reason="User requested Reddit post", + relevance_score=1.0 + ) + await prospect.insert() + + # Create draft + draft = Draft( + prospect_id=str(prospect.id), + channel="reddit", + subject=post_data.get("title", ""), + body=post_data.get("body", ""), + status=DraftStatus.PENDING, + metadata={"subreddit": subreddit_name} + ) + await draft.insert() + + # Create pending action linked to draft + pending = PendingAction( + user_id=user.clerk_id, + mission_id=mission_id, + action_type="reddit_post", + action_data={ + "subreddit": subreddit_name, + "title": post_data.get("title", ""), + "body": post_data.get("body", ""), + "draft_id": str(draft.id), + "ready": True + }, + tool="reddit" + ) + await pending.insert() + + # Log with draft preview action + preview_msg = f"πŸ“ **Reddit Post for r/{subreddit_name}**\n\n**Title:** {post_data.get('title', '')}\n\n{post_data.get('body', '')[:500]}..." + await MissionLog( + mission_id=mission_id, + role="agent", + content=preview_msg, + log_type="action", + metadata={ + "action": "draft_preview", + "draft_id": str(draft.id), + "channel": "reddit", + "pending_action_id": str(pending.id) + } + ).insert() + + return { + "message": preview_msg, + "role": "agent", + "type": "action", + "metadata": { + "action": "draft_preview", + "draft_id": str(draft.id), + "channel": "reddit", + "pending_action_id": str(pending.id) + } + } + + except Exception as e: + print(f"Reddit post generation failed: {e}") + error_msg = f"Failed to generate Reddit post: {str(e)[:100]}" + await MissionLog(mission_id=mission_id, role="agent", content=error_msg, log_type="error").insert() + return {"message": error_msg, "role": "agent", "type": "error"} + + # CHECK FOR LINKEDIN URL + linkedin_regex = r"(https?://(?:www\.)?linkedin\.com/in/[a-zA-Z0-9_-]+/?)" + found_linkedin = re.findall(linkedin_regex, chat.message) + + if found_linkedin: + linkedin_url = found_linkedin[0] + + # Extract username from URL + username_match = re.search(r"linkedin\.com/in/([a-zA-Z0-9_-]+)", linkedin_url) + linkedin_username = username_match.group(1) if username_match else "Unknown" + + # Try to extract name from context or use username + extracted_name = None + try: + from langchain_groq import ChatGroq + from langchain_core.messages import SystemMessage, HumanMessage + + extract_llm = ChatGroq( + temperature=0.0, + groq_api_key=settings.GROQ_API_KEY, + model_name="llama-3.1-8b-instant" + ) + extraction_prompt = [ + SystemMessage(content="Extract the PERSON NAME from the text. Return JSON only: {'name': 'extracted name'}. If unclear, use the LinkedIn username formatted as a name (e.g., 'john-doe' becomes 'John Doe')."), + HumanMessage(content=f"Text: {chat.message}\nLinkedIn Username: {linkedin_username}\nMission: {mission.objective}") + ] + ex_res = await extract_llm.ainvoke(extraction_prompt) + import json + content_str = sanitize_json_string(ex_res.content) + ex_data = json.loads(content_str) + if ex_data.get("name") and ex_data["name"] not in ["null", "None", ""]: + extracted_name = ex_data["name"] + except Exception as e: + print(f"Name extraction failed: {e}") + + # Format username as name fallback + if not extracted_name: + extracted_name = " ".join(word.capitalize() for word in linkedin_username.replace("-", " ").replace("_", " ").split()) + + # Store in Neo4j + from app.services.neo4j import neo4j_service + try: + neo4j_service.resolve_person(extracted_name) + neo4j_service.add_contact_method(extracted_name, "linkedin", linkedin_url) + except Exception as e: + print(f"Neo4j storage failed: {e}") + + # Find or create prospect + prospects = await Prospect.find(Prospect.mission_id == mission_id).to_list() + + if prospects: + target_prospect = prospects[0] + target_prospect.public_contact = linkedin_url + target_prospect.name = extracted_name + if not target_prospect.scraped_data: + target_prospect.scraped_data = {} + target_prospect.scraped_data["linkedin"] = linkedin_url + await target_prospect.save() + msg_content = f"Saved LinkedIn for {extracted_name}: {linkedin_url}" + else: + target_prospect = Prospect( + mission_id=mission_id, + name=extracted_name, + company="Unknown", + context_source="LinkedIn (User Input)", + public_contact=linkedin_url, + scraped_data={"linkedin": linkedin_url}, + relevance_reason="User provided LinkedIn profile", + relevance_score=1.0 + ) + await target_prospect.insert() + msg_content = f"Created prospect for {extracted_name} with LinkedIn: {linkedin_url}" + + await MissionLog( + mission_id=mission_id, + role="agent", + content=msg_content, + log_type="success" + ).insert() + + # Now automatically create a draft for this person + force_draft = True + + # CHECK FOR EMAIL UPDATE + email_regex = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" + found_emails = re.findall(email_regex, chat.message) + + if found_emails and not found_linkedin: # Don't process email if we already processed LinkedIn + new_email = found_emails[0] + + # 0. SMART EXTRACTION (LLM) + # Try to extract the name associated with this email from the user's natural language message + extracted_name = None + try: + extract_llm = ChatGroq( + temperature=0.0, + groq_api_key=settings.GROQ_API_KEY, + model_name="llama-3.1-8b-instant" + ) + extraction_prompt = [ + SystemMessage(content="You are an Entity Extractor. Extract the PERSON NAME associated with the target email from the user text. Return valid JSON only: {'name': 'extracted name'}. If no name is found, return {'name': null}."), + HumanMessage(content=f"User Text: {chat.message}\nTarget Email: {new_email}") + ] + ex_res = await extract_llm.ainvoke(extraction_prompt) + import json + ex_data = {} + try: + content_str = sanitize_json_string(ex_res.content) + ex_data = json.loads(content_str) + if ex_data.get("name") and ex_data["name"] not in ["null", "None", ""]: + extracted_name = ex_data["name"] + except: + pass + except Exception as e: + print(f"Extraction failed: {e}") + + # 1. Check Global Contacts for Name (Fallback) + from app.models import ContactHistory + contact_record = await ContactHistory.find_one( + ContactHistory.user_id == user.clerk_id, + ContactHistory.prospect_email == new_email.lower() + ) + + known_name = contact_record.prospect_name if contact_record and contact_record.prospect_name else None + + # FINAL NAME PRIORITY: Extracted > Known (History) > Email Handle + final_name = extracted_name or known_name or new_email.split("@")[0] + + # 2. Find Mission Prospects + prospects = await Prospect.find(Prospect.mission_id == mission_id).to_list() + + target_prospect = None + msg_content = "" + + if prospects: + # Update existing + target_prospect = prospects[0] + target_prospect.public_contact = new_email + target_prospect.name = final_name # Always update name if we found a better one + await target_prospect.save() + + msg_content = f"Updated contact info for {target_prospect.name}: {new_email}" + else: + # Create New Prospect + target_prospect = Prospect( + mission_id=mission_id, + name=final_name, + company="Unknown", + context_source="Chat Input", + public_contact=new_email, + relevance_reason="User provided email directly", + relevance_score=1.0 + ) + await target_prospect.insert() + msg_content = f"Created new prospect for {target_prospect.name} ({new_email})" + + await MissionLog( + mission_id=mission_id, + role="agent", + content=msg_content, + log_type="success" + ).insert() + + # Automatically trigger draft regeneration + force_draft = True + + else: + # No email found? Try looking up by NAME in ContactHistory + cleaned_msg = chat.message.lower() + + # Get user's contacts (optimistic fetch, or use text search if available) + # For now, fetching recent 100 contacts to check against + from app.models import ContactHistory + contacts = await ContactHistory.find( + ContactHistory.user_id == user.clerk_id + ).sort("-last_contacted_at").limit(100).to_list() + + found_contact = None + for c in contacts: + if c.prospect_name and c.prospect_name.lower() in cleaned_msg: + # Found a name match! + # Avoid single-word matches if they are common words (simple heuristic) + if len(c.prospect_name) < 3: continue + found_contact = c + break + + if found_contact: + new_email = found_contact.prospect_email + known_name = found_contact.prospect_name + + # Find Mission Prospects + prospects = await Prospect.find(Prospect.mission_id == mission_id).to_list() + + target_prospect = None + msg_content = "" + + if prospects: + target_prospect = prospects[0] + target_prospect.public_contact = new_email + target_prospect.name = known_name + await target_prospect.save() + msg_content = f"Found {known_name} in your contacts. Updated info with email: {new_email}" + else: + target_prospect = Prospect( + mission_id=mission_id, + name=known_name, + company="Unknown", + context_source="Contact History", + public_contact=new_email, + relevance_reason="User referenced known contact", + relevance_score=1.0 + ) + await target_prospect.insert() + msg_content = f"Found {known_name} in contacts. Created prospect with email: {new_email}" + + await MissionLog( + mission_id=mission_id, + role="agent", + content=msg_content, + log_type="success" + ).insert() + + force_draft = True + + # CHECK FOR INTEGRATION REQUEST (Slack, Gmail, etc.) integration_keywords = { "slack": ["slack", "notify me on slack", "integrate slack"], - "gmail": ["gmail", "connect email", "connect gmail"], + "gmail": ["connect email", "connect gmail", "link gmail"], } for tool, keywords in integration_keywords.items(): @@ -125,8 +1102,108 @@ async def chat_with_mission(mission_id: str, chat: ChatMessage, user: User = Dep await MissionLog(mission_id=mission_id, role="agent", content=success_msg, log_type="success").insert() return {"message": success_msg, "role": "agent", "type": "success"} - # CHECK FOR APPROVAL INTENT - approval_keywords = ["approve", "send it", "send the mail", "looks good", "proceed"] + # CHECK FOR CREATE DRAFT COMMAND + create_draft_keywords = ["create draft", "regenerate draft", "generate draft", "new draft", "draft again", "make another draft", "proceed", "proceed and create", "yes create", "go ahead", "make draft", "write draft"] + if force_draft or any(k in msg_lower for k in create_draft_keywords): + # Find existing prospects for this mission + prospects = await Prospect.find(Prospect.mission_id == mission_id).to_list() + + if not prospects: + fail_msg = "No prospects found for this mission yet. The agent is still working on discovery. Please wait a moment." + await MissionLog(mission_id=mission_id, role="agent", content=fail_msg, log_type="error").insert() + return {"message": fail_msg, "role": "agent", "type": "error"} + + # Pick the first prospect (or could pick one without a pending draft) + prospect = prospects[0] + + await MissionLog( + mission_id=mission_id, + role="agent", + content=f"Regenerating draft for {prospect.name}...", + log_type="thinking" + ).insert() + + try: + from langchain_groq import ChatGroq + from langchain_core.messages import SystemMessage, HumanMessage + + llm = ChatGroq( + temperature=0.7, + groq_api_key=settings.GROQ_API_KEY, + model_name="llama-3.3-70b-versatile" + ) + + system_prompt = """You are an expert outreach specialist. +Write a personalized email based on the publicly available context. +Format: +SUBJECT: [Subject] +EMAIL: [Body] +REASONING: [Reasoning]""" + + human_prompt = f"""Target Context: {prospect.name} at {prospect.company} +Source: {prospect.context_source} +Relevance: {prospect.relevance_reason} +Mission: {mission.objective} +""" + messages = [ + SystemMessage(content=system_prompt), + HumanMessage(content=human_prompt) + ] + + response = await llm.ainvoke(messages) + content = response.content + + # Parse response + subject, body, reasoning = "Hello", content, "AI Generated" + if "SUBJECT:" in content: + parts = content.split("EMAIL:") + subject = parts[0].replace("SUBJECT:", "").strip() + if len(parts) > 1: + rem = parts[1] + if "REASONING:" in rem: + bps = rem.split("REASONING:") + body = bps[0].strip() + reasoning = bps[1].strip() if len(bps) > 1 else "" + else: + body = rem.strip() + + # Clean up markdown + subject = subject.replace("`", "").strip() + body = body.replace("```", "").strip() + + # Save new draft + new_draft = Draft( + prospect_id=str(prospect.id), + subject=subject, + body=body, + ai_reasoning=reasoning, + status=DraftStatus.PENDING + ) + await new_draft.insert() + + success_msg = f"Draft generated for {prospect.name}! Ready for review." + await MissionLog( + mission_id=mission_id, + role="agent", + content=success_msg, + log_type="success", + metadata={"action": "draft_ready", "draft_id": str(new_draft.id), "mission_id": mission_id} + ).insert() + + return { + "message": success_msg, + "role": "agent", + "type": "success", + "metadata": {"action": "draft_ready", "draft_id": str(new_draft.id), "mission_id": mission_id} + } + + except Exception as e: + error_msg = f"Failed to generate draft: {str(e)[:100]}" + await MissionLog(mission_id=mission_id, role="agent", content=error_msg, log_type="error").insert() + return {"message": error_msg, "role": "agent", "type": "error"} + + # CHECK FOR APPROVAL INTENT (only for sending already created drafts) + approval_keywords = ["approve", "send it", "send the mail", "looks good", "send now", "approve and send", "all good send", "lgtm"] if any(k in msg_lower for k in approval_keywords): # Find pending draft for this mission # 1. Get prospects for mission @@ -184,7 +1261,7 @@ async def chat_with_mission(mission_id: str, chat: ChatMessage, user: User = Dep send_status = "sent successfully" start_msg = f"Draft approved and email sent to {recipient}!" except Exception as e: - print(f"Send Failed: {e}") + send_status = f"failed to send ({str(e)})" start_msg = f"Draft approved but sending failed: {str(e)}" else: @@ -212,6 +1289,259 @@ async def chat_with_mission(mission_id: str, chat: ChatMessage, user: User = Dep await MissionLog(mission_id=mission_id, role="agent", content=fail_msg, log_type="error").insert() return {"message": fail_msg, "role": "agent", "type": "error"} + # ========================================================================= + # CHECK FOR DIRECT ACTION COMMANDS (Tool Execution via Composio) + # ========================================================================= + action_patterns = { + "twitter_post": ["post on twitter", "tweet this", "create a tweet", "post a tweet", "tweet:", "post to twitter", "send tweet"], + "reddit_post": ["post on reddit", "create a reddit post", "post to reddit", "post in r/", "submit to reddit", "reddit post:"], + "linkedin_post": ["post on linkedin", "create a linkedin post", "share on linkedin", "linkedin post:", "post to linkedin"], + "slack_message": ["send to slack", "slack message", "post to slack", "message on slack", "notify slack"], + "gmail_send": ["send email", "send an email", "email to", "compose email", "mail to"], + "github_issue": ["create issue", "open issue", "github issue", "new issue on github"], + } + + detected_action = None + for action_type, patterns in action_patterns.items(): + if any(p in msg_lower for p in patterns): + detected_action = action_type + break + + if detected_action: + # Extract content using LLM + try: + extract_llm = ChatGroq( + temperature=0.0, + groq_api_key=settings.GROQ_API_KEY, + model_name="llama-3.1-8b-instant" + ) + + if detected_action == "twitter_post": + extraction_prompt = [ + SystemMessage(content="""Extract the tweet content from the user's message. +Return JSON only: {"tweet": "the tweet text", "ready": true/false} +Set ready=false if the content is unclear and needs clarification. +Max 280 characters for tweet."""), + HumanMessage(content=f"User: {chat.message}\nMission context: {mission.objective}") + ] + elif detected_action == "reddit_post": + extraction_prompt = [ + SystemMessage(content="""Extract Reddit post details from the user's message. +Return JSON only: {"subreddit": "subreddit_name", "title": "post title", "body": "post content", "ready": true/false} +Set ready=false if subreddit or content is unclear."""), + HumanMessage(content=f"User: {chat.message}\nMission context: {mission.objective}") + ] + elif detected_action == "linkedin_post": + extraction_prompt = [ + SystemMessage(content="""Extract LinkedIn post content from the user's message. +Return JSON only: {"content": "the post text", "ready": true/false} +Set ready=false if the content is unclear."""), + HumanMessage(content=f"User: {chat.message}\nMission context: {mission.objective}") + ] + elif detected_action == "slack_message": + extraction_prompt = [ + SystemMessage(content="""Extract Slack message details from the user's message. +Return JSON only: {"channel": "channel_name_or_id", "message": "the message text", "ready": true/false} +Set ready=false if channel or message is unclear."""), + HumanMessage(content=f"User: {chat.message}\nMission context: {mission.objective}") + ] + elif detected_action == "gmail_send": + extraction_prompt = [ + SystemMessage(content="""Extract email details from the user's message. +Return JSON only: {"to": "recipient@email.com", "subject": "email subject", "body": "email body", "ready": true/false} +Set ready=false if recipient email, subject, or body is unclear."""), + HumanMessage(content=f"User: {chat.message}\nMission context: {mission.objective}") + ] + elif detected_action == "github_issue": + extraction_prompt = [ + SystemMessage(content="""Extract GitHub issue details from the user's message. +Return JSON only: {"repo": "owner/repo", "title": "issue title", "body": "issue description", "ready": true/false} +Set ready=false if repo, title, or description is unclear."""), + HumanMessage(content=f"User: {chat.message}\nMission context: {mission.objective}") + ] + + ex_res = await extract_llm.ainvoke(extraction_prompt) + import json + content_str = sanitize_json_string(ex_res.content) + + action_data = json.loads(content_str) + + if not action_data.get("ready", False): + # Need more info + clarify_msg = f"I detected you want to {detected_action.replace('_', ' ')}. Could you please provide more details?" + if detected_action == "reddit_post": + clarify_msg += " What subreddit should I post to and what's the content?" + elif detected_action == "twitter_post": + clarify_msg += " What would you like the tweet to say?" + elif detected_action == "linkedin_post": + clarify_msg += " What would you like to post on LinkedIn?" + elif detected_action == "slack_message": + clarify_msg += " What channel and message would you like to send?" + elif detected_action == "gmail_send": + clarify_msg += " Who should I email, and what's the subject and message?" + elif detected_action == "github_issue": + clarify_msg += " Which repo (owner/repo), what's the issue title and description?" + await MissionLog(mission_id=mission_id, role="agent", content=clarify_msg, log_type="action").insert() + return {"message": clarify_msg, "role": "agent", "type": "action"} + + # Helper function to get connection ID and handle missing connections + async def get_connection_or_prompt(tool: str): + """Get connection ID or create pending action and return connect prompt""" + conn_id = None + if tool == "slack": + conn_id = user.slack_connection_id + elif tool == "gmail": + conn_id = user.gmail_connection_id + else: + conn_id = user.other_connections.get(tool) if user.other_connections else None + + if conn_id: + return conn_id, None # Connected, no prompt needed + + # Not connected - save pending action + pending = PendingAction( + user_id=user.clerk_id, + mission_id=mission_id, + action_type=detected_action, + action_data=action_data, + tool=tool + ) + await pending.insert() + + # Generate connect URL + import httpx + + TOOL_CONFIG_MAP = { + "linkedin": "ac_SdzD1ondK6Zi", + "twitter": "ac_EjFjyYk1dXE2", + "reddit": "ac_2_IjyXggGH8F", + "github": "ac_UE__S2Ls9sMT", + "slack": "ac_YPQ1Q5xomR5i", + "gmail": settings.COMPOSIO_AUTH_CONFIG_ID + } + + auth_config_id = TOOL_CONFIG_MAP.get(tool) + if not auth_config_id: + return None, {"error": f"Unknown tool: {tool}"} + + # Create Composio connection request with redirect back to chat + frontend_base = "http://localhost:5173" + redirect_url = f"{frontend_base}/chat/{mission_id}?pending_action={pending.id}" + + url = "https://backend.composio.dev/api/v3/connected_accounts" + headers = {"x-api-key": settings.COMPOSIO_API_KEY} + payload = { + "auth_config": {"id": auth_config_id}, + "connection": {"user_id": user.clerk_id}, + "redirectUrl": redirect_url, + "redirect_uri": redirect_url + } + + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=payload, headers=headers, timeout=30.0) + if resp.status_code in [200, 201, 202]: + data = resp.json() + composio_redirect = data.get("redirectUrl") or data.get("redirect_url") + connection_id = data.get("id") or data.get("connection_id") + + # Pre-save the connection ID (will be activated after OAuth) + if connection_id: + if tool == "slack": + user.slack_connection_id = connection_id + elif tool == "gmail": + user.gmail_connection_id = connection_id + else: + if not user.other_connections: + user.other_connections = {} + user.other_connections[tool] = connection_id + await user.save() + + return None, { + "connect_url": composio_redirect, + "pending_action_id": str(pending.id), + "tool": tool + } + else: + return None, {"error": f"Failed to create connection: {resp.text}"} + + # Map action types to tools + tool_map = { + "twitter_post": "twitter", + "reddit_post": "reddit", + "linkedin_post": "linkedin", + "slack_message": "slack", + "gmail_send": "gmail", + "github_issue": "github" + } + tool = tool_map.get(detected_action, "unknown") + + # Check connection first + connection_id, prompt = await get_connection_or_prompt(tool) + if prompt: + if "error" in prompt: + return {"message": prompt["error"], "role": "agent", "type": "error"} + msg = f"To {detected_action.replace('_', ' ')}, I need you to connect your {tool.title()} account first. Click the button below to connect." + await MissionLog(mission_id=mission_id, role="agent", content=msg, log_type="action", metadata={"action": "connect_tool", "tool": tool, "connect_url": prompt.get("connect_url"), "pending_action_id": prompt.get("pending_action_id")}).insert() + return {"message": msg, "role": "agent", "type": "action", "metadata": {"action": "connect_tool", "tool": tool, "connect_url": prompt.get("connect_url"), "pending_action_id": prompt.get("pending_action_id")}} + + # Connected! Create a draft preview instead of auto-posting + pending = PendingAction( + user_id=user.clerk_id, + mission_id=mission_id, + action_type=detected_action, + action_data=action_data, + tool=tool + ) + await pending.insert() + + # Format preview message based on action type + preview_content = "" + if detected_action == "twitter_post": + preview_content = f"**Tweet Preview:**\n\n{action_data.get('tweet', '')}" + elif detected_action == "reddit_post": + preview_content = f"**Reddit Post Preview:**\n\n**Subreddit:** r/{action_data.get('subreddit', 'unknown')}\n\n**Title:** {action_data.get('title', '')}\n\n**Content:**\n{action_data.get('body', '')}" + elif detected_action == "linkedin_post": + preview_content = f"**LinkedIn Post Preview:**\n\n{action_data.get('content', '')}" + elif detected_action == "slack_message": + preview_content = f"**Slack Message Preview:**\n\n**Channel:** #{action_data.get('channel', 'general')}\n\n**Message:**\n{action_data.get('message', '')}" + elif detected_action == "gmail_send": + preview_content = f"**Email Preview:**\n\n**To:** {action_data.get('to', '')}\n**Subject:** {action_data.get('subject', '')}\n\n**Body:**\n{action_data.get('body', '')}" + elif detected_action == "github_issue": + preview_content = f"**GitHub Issue Preview:**\n\n**Repo:** {action_data.get('repo', '')}\n**Title:** {action_data.get('title', '')}\n\n**Body:**\n{action_data.get('body', '')}" + + # Log the preview with action button metadata + await MissionLog( + mission_id=mission_id, + role="agent", + content=preview_content, + log_type="action", + metadata={ + "action": "draft_preview", + "action_type": detected_action, + "pending_action_id": str(pending.id), + "tool": tool, + "action_data": action_data + } + ).insert() + + return { + "message": preview_content, + "role": "agent", + "type": "action", + "metadata": { + "action": "draft_preview", + "action_type": detected_action, + "pending_action_id": str(pending.id), + "tool": tool, + "action_data": action_data + } + } + + except Exception as e: + error_msg = f"Failed to process action: {str(e)[:100]}" + await MissionLog(mission_id=mission_id, role="agent", content=error_msg, log_type="error").insert() + return {"message": error_msg, "role": "agent", "type": "error"} + try: llm = ChatGroq( temperature=0.7, diff --git a/backend/app/routers/reviews.py b/backend/app/routers/reviews.py index fee42e1..0151f25 100644 --- a/backend/app/routers/reviews.py +++ b/backend/app/routers/reviews.py @@ -1,23 +1,34 @@ from fastapi import APIRouter, Depends, HTTPException from typing import List -from app.models import Draft, DraftStatus, User, Prospect +from app.models import Draft, DraftStatus, User, Prospect, ContactHistory +from datetime import datetime from app.api.deps import get_current_user router = APIRouter() @router.get("/pending") -async def get_pending_drafts(user: User = Depends(get_current_user)): +async def get_pending_drafts(mission_id: str = None, user: User = Depends(get_current_user)): from app.models import Mission, Prospect from beanie.operators import In - # 1. Get all missions for this user - missions = await Mission.find(Mission.user_id == user.clerk_id).to_list() + # 1. Get missions for this user (optionally filtered) + if mission_id: + # Filter to specific mission + mission = await Mission.get(mission_id) + if not mission or mission.user_id != user.clerk_id: + return [] # No access or not found + missions = [mission] + else: + missions = await Mission.find(Mission.user_id == user.clerk_id).to_list() + mission_ids = [str(m.id) for m in missions] + mission_map = {str(m.id): m for m in missions} # 2. Get all prospects for these missions prospects = await Prospect.find(In(Prospect.mission_id, mission_ids)).to_list() prospect_ids = [str(p.id) for p in prospects] + prospect_map = {str(p.id): p for p in prospects} # 3. Fetch pending drafts for these prospects ONLY drafts = await Draft.find(In(Draft.prospect_id, prospect_ids), Draft.status == DraftStatus.PENDING).to_list() @@ -27,14 +38,21 @@ async def get_pending_drafts(user: User = Depends(get_current_user)): draft_dict = draft.model_dump() draft_dict["id"] = str(draft.id) - # Fetch associated prospect (optimize to use pre-fetched list if possible, but safe to query or lookup) - # Simple lookup map - prospect = next((p for p in prospects if str(p.id) == draft.prospect_id), None) + # Ensure metadata is included + if not draft_dict.get("metadata"): + draft_dict["metadata"] = {} + + # Fetch associated prospect + prospect = prospect_map.get(draft.prospect_id) if prospect: draft_dict["name"] = prospect.name draft_dict["company"] = prospect.company draft_dict["scraped_data"] = prospect.scraped_data + draft_dict["mission_id"] = prospect.mission_id + # Include mission objective for UI + mission_obj = mission_map.get(prospect.mission_id) + draft_dict["mission_objective"] = mission_obj.objective if mission_obj else None result.append(draft_dict) @@ -49,30 +67,21 @@ async def clear_all_pending_drafts(user: User = Depends(get_current_user)): # 1. Get all missions for this user missions = await Mission.find(Mission.user_id == user.clerk_id).to_list() mission_ids = [str(m.id) for m in missions] - print(f"DEBUG: Found {len(missions)} missions for user {user.clerk_id}: {mission_ids}") if not mission_ids: - print("DEBUG: No missions found. Returning 0.") return {"status": "cleared", "count": 0} # 2. Get all prospects for these missions prospects = await Prospect.find(In(Prospect.mission_id, mission_ids)).to_list() prospect_ids = [str(p.id) for p in prospects] - print(f"DEBUG: Found {len(prospects)} prospects for {len(missions)} missions. IDs sample: {prospect_ids[:5]}") if not prospect_ids: - print("DEBUG: No prospects found. Returning 0.") return {"status": "cleared", "count": 0} - - # Check if ANY drafts exist for these prospects before deleting - existing_drafts = await Draft.find(In(Draft.prospect_id, prospect_ids), Draft.status == DraftStatus.PENDING).count() - print(f"DEBUG: Found {existing_drafts} PENDING drafts matching these prospects.") # 3. Delete pending drafts for these prospects result = await Draft.find(In(Draft.prospect_id, prospect_ids), Draft.status == DraftStatus.PENDING).delete() count = result.deleted_count if result else 0 - print(f"DEBUG: Delete operation returned count: {count}") return {"status": "cleared", "count": count} @router.post("/{id}/approve") @@ -90,7 +99,7 @@ async def approve_draft(id: str, subject: str = None, body: str = None, user: Us await draft.save() # 1. Create Active Agent (Workflow) - from app.models import Agent + from app.models import Agent, Prospect new_agent = Agent( user_id=user.clerk_id, name=f"Lead: {subject[:20] if subject else 'New Lead'}...", @@ -110,35 +119,179 @@ async def approve_draft(id: str, subject: str = None, body: str = None, user: Us ) await new_agent.insert() - # 2. Attempt to Send Email via Composio (if connected) - # 2. Attempt to Send Email via Composio (if connected) - if user.gmail_connection_id: - from app.models import Prospect - from app.core.sender import send_email_via_composio - - recipient = None - if draft.prospect_id: + + # 2.5 Record in Contact History (The "Fix" for missing contacts) + if draft.prospect_id: + prospect = await Prospect.get(draft.prospect_id) + if prospect and prospect.public_contact: + # Check for existing contact + email = prospect.public_contact.lower().strip() + existing_contact = await ContactHistory.find_one( + ContactHistory.user_id == user.clerk_id, + ContactHistory.prospect_email == email + ) + + if existing_contact: + existing_contact.last_contacted_at = datetime.utcnow() + existing_contact.last_mission_id = prospect.mission_id + existing_contact.total_emails_sent += 1 + await existing_contact.save() + else: + new_contact = ContactHistory( + user_id=user.clerk_id, + prospect_email=email, + prospect_name=prospect.name, + first_mission_id=prospect.mission_id, + last_mission_id=prospect.mission_id, + total_emails_sent=1 + ) + await new_contact.insert() + + # 2.6 Neo4j Contact Graph Update (Identity & Channel Storage) + if draft.prospect_id: + try: prospect = await Prospect.get(draft.prospect_id) - if prospect: - recipient = prospect.public_contact + if prospect and prospect.name: + # 1. Resolve Person + from app.services.neo4j import neo4j_service + person_node = neo4j_service.resolve_person(prospect.name) + # 2. Store Email Contact if available + contact_email = prospect.public_contact or (prospect.scraped_data and prospect.scraped_data.get('email')) + if contact_email: + neo4j_service.add_contact_method(prospect.name, "email", contact_email) + print(f"Neo4j: Linked {contact_email} to {prospect.name}") + + except Exception as e: + print(f"Neo4j Update Failed: {e}") + + + + # 2. Execute Outreach based on Channel using Composio integrations + channel = draft.channel.lower() if draft.channel else "email" + + # Get recipient/target from prospect + recipient = None + if draft.prospect_id: + from app.models import Prospect + prospect = await Prospect.get(draft.prospect_id) + if prospect: + recipient = prospect.public_contact + + execution_result = {"success": False, "error": "No handler"} + + if channel in ["email", "gmail"] and user.gmail_connection_id: + from app.core.sender import send_email_via_composio if recipient: - try: - print(f"Approved: Sending email to {recipient} via connection {user.gmail_connection_id}") - # Get attachments from draft - attachments = getattr(draft, 'attachments', []) or [] - await send_email_via_composio( - user.clerk_id, - recipient, - draft.subject, - draft.body, - attachments - ) - print("Email sent successfully.") - except Exception as e: - print(f"Failed to trigger email send: {e}") + print(f"Approved: Sending email to {recipient} via user {user.clerk_id}") + attachments = getattr(draft, 'attachments', []) or [] + execution_result = await send_email_via_composio( + user.clerk_id, + recipient, + draft.subject, + draft.body, + attachments # Pass attachments to send function + ) + print(f"Email result: {execution_result}") else: - print("Approved: No recipient email found for this prospect.") + execution_result = {"success": False, "error": "No recipient email found"} + + elif channel == "linkedin": + connection_id = user.other_connections.get("linkedin") if user.other_connections else None + if connection_id: + from app.integrations import linkedin + metadata = getattr(draft, 'metadata', {}) or {} + # If no recipient, it's a public post; otherwise it's a DM + if recipient: + print(f"Approved: Sending LinkedIn message to {recipient}") + execution_result = await linkedin.send_message(user.clerk_id, recipient, draft.body) + else: + print(f"Approved: Publishing LinkedIn post") + execution_result = await linkedin.publish_post(user.clerk_id, draft.body) + print(f"LinkedIn result: {execution_result}") + else: + execution_result = {"success": False, "error": "LinkedIn not connected"} + + elif channel == "twitter": + connection_id = user.other_connections.get("twitter") if user.other_connections else None + if connection_id: + from app.integrations import twitter + print(f"Approved: Posting tweet") + execution_result = await twitter.post_tweet(user.clerk_id, draft.body) + print(f"Twitter result: {execution_result}") + else: + execution_result = {"success": False, "error": "Twitter not connected"} + + elif channel == "reddit": + connection_id = user.other_connections.get("reddit") if user.other_connections else None + if connection_id: + from app.integrations import reddit + # Extract subreddit from metadata, recipient, or use default + metadata = getattr(draft, 'metadata', {}) or {} + subreddit = metadata.get("subreddit") + if not subreddit and recipient: + subreddit = recipient.replace("r/", "") if recipient.startswith("r/") else recipient + if not subreddit: + subreddit = "test" + print(f"Approved: Creating Reddit post in r/{subreddit}") + execution_result = await reddit.create_post( + user.clerk_id, + subreddit, + draft.subject or "Post", + draft.body + ) + print(f"Reddit result: {execution_result}") + else: + execution_result = {"success": False, "error": "Reddit not connected"} + + elif channel == "slack": + # Get slack channel from metadata or recipient + metadata = getattr(draft, 'metadata', {}) or {} + slack_channel = metadata.get("slackChannel") or recipient or "general" + if user.slack_connection_id: + from app.integrations import slack + print(f"Approved: Sending Slack message to #{slack_channel}") + execution_result = await slack.send_message( + user.clerk_id, + slack_channel, + draft.body + ) + print(f"Slack result: {execution_result}") + else: + execution_result = {"success": False, "error": "Slack not connected"} + + elif channel == "github": + connection_id = user.other_connections.get("github") if user.other_connections else None + metadata = getattr(draft, 'metadata', {}) or {} + repo = metadata.get("repo") or recipient + if connection_id and repo: + from app.integrations import github + # recipient should be in format owner/repo + import re + match = re.search(r"([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)", recipient) + if match: + owner, repo = match.groups() + print(f"Approved: Creating GitHub issue in {owner}/{repo}") + execution_result = await github.create_issue( + user.clerk_id, + owner, + repo, + draft.subject or "Issue", + draft.body + ) + print(f"GitHub result: {execution_result}") + else: + execution_result = {"success": False, "error": "Invalid repo format"} + else: + execution_result = {"success": False, "error": "GitHub not connected or no repo specified"} + + # Update draft status based on result + if execution_result.get("success"): + draft.status = DraftStatus.SENT + await draft.save() + + # Log the result + print(f"Execution result for {channel}: {execution_result}") return { "status": "approved", @@ -161,25 +314,34 @@ async def reject_draft(id: str, feedback: str, user: User = Depends(get_current_ @router.post("/{id}/regenerate") async def regenerate_draft(id: str, user: User = Depends(get_current_user)): - """Regenerate a draft using the AI model""" + """Regenerate a draft using the AI model, preserving the original mission context""" from app.core.config import settings from langchain_groq import ChatGroq from langchain_core.messages import SystemMessage, HumanMessage + from app.models import Mission draft = await Draft.get(id) if not draft: raise HTTPException(status_code=404, detail="Draft not found") - # Get prospect data + # Get prospect data and MISSION CONTEXT prospect_data = {} + mission_objective = "" if draft.prospect_id: prospect = await Prospect.get(draft.prospect_id) if prospect: prospect_data = { "name": prospect.name, "company": prospect.company, - "scraped_data": prospect.scraped_data + "public_contact": prospect.public_contact, + "scraped_data": prospect.scraped_data, + "relevance_reason": prospect.relevance_reason } + # Get mission objective for context + if prospect.mission_id: + mission = await Mission.get(prospect.mission_id) + if mission: + mission_objective = mission.objective try: llm = ChatGroq( @@ -188,27 +350,146 @@ async def regenerate_draft(id: str, user: User = Depends(get_current_user)): model_name="llama-3.3-70b-versatile" ) - system_prompt = """You are an expert sales development representative (SDR). -Write a short, personalized cold outreach email that: -- Is 3-4 sentences max -- Has a compelling subject line -- References something specific about the prospect's PROFESSIONAL work -- Has a clear, low-friction call to action -- Sounds human and conversational, not salesy -- Focus on BUSINESS VALUE, not personal interests + channel = (draft.channel or "email").lower() + + # Channel-specific prompts with MISSION CONTEXT + if channel in ["email", "gmail"]: + system_prompt = """You are an expert sales development representative (SDR). +Write a personalized, professional outreach email that: +- Is 6-10 sentences (2-3 paragraphs) +- Has a compelling, specific subject line that relates to the mission/purpose +- Opens with something relevant to the prospect or their company +- Clearly explains the value proposition based on the MISSION OBJECTIVE +- Has a clear, specific call to action +- Sounds warm, human and conversational - not generic or salesy +- If there are attachments mentioned, reference them naturally in the body + +IMPORTANT: The email MUST be relevant to the original mission objective provided. Return your response in this exact format: -SUBJECT: [your subject line] -EMAIL: -[your email body] -REASONING: [1 sentence explaining why this approach]""" +SUBJECT: [your subject line - make it specific to the mission] +BODY: +[your email body - 2-3 paragraphs, warm and professional] +REASONING: [1-2 sentences explaining your approach]""" + + human_prompt = f"""MISSION OBJECTIVE (the user's original request): +{mission_objective or 'Send a professional outreach email'} - human_prompt = f"""Prospect Information: +PROSPECT INFORMATION: Name: {prospect_data.get('name', 'Unknown')} Company: {prospect_data.get('company', 'Unknown')} -Data: {str(prospect_data.get('scraped_data', {}))[:500]} +Email: {prospect_data.get('public_contact', 'N/A')} +Why they're relevant: {prospect_data.get('relevance_reason', 'N/A')} +Additional Data: {str(prospect_data.get('scraped_data', {}))[:800]} + +CURRENT DRAFT (to improve upon): +Subject: {draft.subject} +Body: {draft.body} + +ATTACHMENTS: {len(draft.attachments or [])} file(s) attached + +Write a better email that stays TRUE to the mission objective. The email should be warm, professional, and 2-3 paragraphs long. Reference any attachments if present.""" + + elif channel == "twitter": + system_prompt = """You are a social media expert. Write an engaging tweet that: +- Is under 280 characters +- Is catchy and gets engagement +- Relates to the MISSION OBJECTIVE +- Uses appropriate hashtags (1-2 max) +- Sounds authentic, not corporate + +Return your response in this exact format: +BODY: +[your tweet] +REASONING: [why this tweet works]""" + + human_prompt = f"""MISSION OBJECTIVE: {mission_objective or 'Create an engaging tweet'} + +Previous tweet: {draft.body} + +Write a better, more engaging tweet that aligns with the mission objective.""" + + elif channel == "linkedin": + system_prompt = """You are a LinkedIn content expert. Write a professional LinkedIn post that: +- Is 150-300 words (not too short!) +- Has a hook in the first line +- Relates to the MISSION OBJECTIVE +- Provides genuine insights or value +- Encourages engagement without being salesy +- Uses appropriate line breaks for readability + +Return your response in this exact format: +BODY: +[your post] +REASONING: [why this works]""" -Write a professional business outreach email.""" + human_prompt = f"""MISSION OBJECTIVE: {mission_objective or 'Create an engaging LinkedIn post'} + +Previous post: {draft.body[:500] if draft.body else ''} + +Write a better, more engaging LinkedIn post that aligns with the mission objective. Make it 150-300 words.""" + + elif channel == "reddit": + metadata = draft.metadata or {} + subreddit = metadata.get("subreddit", "unknown") + system_prompt = f"""You are a Reddit power user. Write a post for r/{subreddit} that: +- Has a compelling, attention-grabbing title +- Provides genuine value to the community +- Is 2-4 paragraphs for text posts +- Follows typical Reddit etiquette +- Is authentic and not promotional +- Relates to the MISSION OBJECTIVE + +Return your response in this exact format: +SUBJECT: [post title] +BODY: +[post content - 2-4 paragraphs] +REASONING: [why this works for this subreddit]""" + + human_prompt = f"""MISSION OBJECTIVE: {mission_objective or 'Create an engaging Reddit post'} + +Subreddit: r/{subreddit} +Previous title: {draft.subject} +Previous content: {draft.body[:500] if draft.body else ''} + +Write a better post for r/{subreddit} that aligns with the mission objective.""" + + elif channel == "slack": + metadata = draft.metadata or {} + slack_channel = metadata.get("slackChannel", "general") + system_prompt = f"""You are writing a Slack message for #{slack_channel}. Write a message that: +- Is clear and well-structured +- Is appropriate for workplace communication +- Gets the point across effectively +- Can be a few sentences to a paragraph + +Return your response in this exact format: +BODY: +[your message] +REASONING: [why this works]""" + + human_prompt = f"""MISSION OBJECTIVE: {mission_objective or 'Send a Slack message'} + +Channel: #{slack_channel} +Previous message: {draft.body} + +Write a better Slack message that aligns with the mission objective.""" + + else: + # Generic fallback + system_prompt = """You are a content expert. Rewrite this content to be more engaging and effective. + +Return your response in this exact format: +SUBJECT: [title/subject if applicable] +BODY: +[your content] +REASONING: [why this works]""" + + human_prompt = f"""Previous content: +Subject: {draft.subject} +Body: {draft.body} + +Write a better version.""" messages = [ SystemMessage(content=system_prompt), @@ -219,12 +500,12 @@ async def regenerate_draft(id: str, user: User = Depends(get_current_user)): content = response.content # Parse the response - subject = "Quick question" + subject = draft.subject # Keep existing subject as fallback body = content - reasoning = "AI-regenerated outreach" + reasoning = "AI-regenerated content" if "SUBJECT:" in content: - parts = content.split("EMAIL:") + parts = content.split("BODY:") subject = parts[0].replace("SUBJECT:", "").strip() if len(parts) > 1: remaining = parts[1] @@ -234,6 +515,16 @@ async def regenerate_draft(id: str, user: User = Depends(get_current_user)): reasoning = body_parts[1].strip() if len(body_parts) > 1 else "" else: body = remaining.strip() + elif "BODY:" in content: + parts = content.split("BODY:") + if len(parts) > 1: + remaining = parts[1] + if "REASONING:" in remaining: + body_parts = remaining.split("REASONING:") + body = body_parts[0].strip() + reasoning = body_parts[1].strip() if len(body_parts) > 1 else "" + else: + body = remaining.strip() # Update draft draft.subject = subject @@ -252,3 +543,56 @@ async def regenerate_draft(id: str, user: User = Depends(get_current_user)): except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to regenerate: {str(e)}") + +from pydantic import BaseModel +from typing import List, Optional + +class AttachmentUpdate(BaseModel): + asset_ids: List[str] # List of UserAsset IDs to attach + +@router.post("/{id}/attachments") +async def update_draft_attachments(id: str, data: AttachmentUpdate, user: User = Depends(get_current_user)): + """Update attachments for a draft by selecting from Knowledge Assets""" + from app.models import UserAsset + + draft = await Draft.get(id) + if not draft: + raise HTTPException(status_code=404, detail="Draft not found") + + # Validate and fetch asset info + attachments = [] + for asset_id in data.asset_ids: + asset = await UserAsset.get(asset_id) + if asset and asset.user_id == user.clerk_id: + attachments.append({ + "asset_id": str(asset.id), + "filename": asset.filename, + "content_type": asset.content_type + }) + + draft.attachments = attachments + await draft.save() + + return { + "status": "updated", + "attachments": attachments + } + +@router.get("/{id}/available-assets") +async def get_available_assets(id: str, user: User = Depends(get_current_user)): + """Get list of user's Knowledge Assets that can be attached to this draft""" + from app.models import UserAsset + + # Get all user's assets + assets = await UserAsset.find(UserAsset.user_id == user.clerk_id).to_list() + + return [ + { + "id": str(asset.id), + "filename": asset.filename, + "content_type": asset.content_type, + "size": len(asset.file_data) if asset.file_data else 0 + } + for asset in assets + ] + diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..ef02c81 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,65 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from app.api.deps import get_current_user +from app.models import User, UserSettings + +router = APIRouter() + + +class SettingsUpdate(BaseModel): + email_notifications: Optional[bool] = None + daily_digest_time: Optional[str] = None + auto_approve_low_risk: Optional[bool] = None + personalization_threshold: Optional[int] = None + daily_sending_limit: Optional[int] = None + + +@router.get("/") +async def get_settings(user: User = Depends(get_current_user)): + """Get current user settings""" + # Ensure settings field exists (for older users) + if not hasattr(user, 'settings') or user.settings is None: + user.settings = UserSettings() + await user.save() + + return { + "email_notifications": user.settings.email_notifications, + "daily_digest_time": user.settings.daily_digest_time, + "auto_approve_low_risk": user.settings.auto_approve_low_risk, + "personalization_threshold": user.settings.personalization_threshold, + "daily_sending_limit": user.settings.daily_sending_limit, + } + + +@router.patch("/") +async def update_settings(update: SettingsUpdate, user: User = Depends(get_current_user)): + """Update user settings""" + # Ensure settings field exists + if not hasattr(user, 'settings') or user.settings is None: + user.settings = UserSettings() + + # Update only provided fields + if update.email_notifications is not None: + user.settings.email_notifications = update.email_notifications + if update.daily_digest_time is not None: + user.settings.daily_digest_time = update.daily_digest_time + if update.auto_approve_low_risk is not None: + user.settings.auto_approve_low_risk = update.auto_approve_low_risk + if update.personalization_threshold is not None: + user.settings.personalization_threshold = update.personalization_threshold + if update.daily_sending_limit is not None: + user.settings.daily_sending_limit = update.daily_sending_limit + + await user.save() + + return { + "status": "updated", + "settings": { + "email_notifications": user.settings.email_notifications, + "daily_digest_time": user.settings.daily_digest_time, + "auto_approve_low_risk": user.settings.auto_approve_low_risk, + "personalization_threshold": user.settings.personalization_threshold, + "daily_sending_limit": user.settings.daily_sending_limit, + } + } diff --git a/backend/app/routers/timeline.py b/backend/app/routers/timeline.py new file mode 100644 index 0000000..29b0a6e --- /dev/null +++ b/backend/app/routers/timeline.py @@ -0,0 +1,162 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import List, Optional +from datetime import datetime, timedelta +from beanie import PydanticObjectId + +from app.models import EmailThread, EmailEvent, User, Mission, MissionLog +from app.api.deps import get_current_user + +router = APIRouter() + +@router.get("/timeline") +async def get_email_timeline( + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + limit: int = 100, + user: User = Depends(get_current_user) +): + """ + Get all events for calendar display (Email + Agent Activity) + """ + try: + # 1. Fetch Email Threads + email_query = {"user_id": user.clerk_id} + if start_date or end_date: + date_filter = {} + if start_date: + date_filter["$gte"] = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + if end_date: + date_filter["$lte"] = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + if date_filter: + email_query["last_activity"] = date_filter + + threads = await EmailThread.find(email_query).to_list() + + calendar_events = [] + for thread in threads: + for event in thread.events: + calendar_events.append({ + "id": f"{thread.id}_{event.email_id}", + "thread_id": thread.thread_id, + "type": "email", + "event_type": event.type, + "timestamp": event.timestamp.isoformat(), + "subject": event.subject, + "preview": event.preview, + "from_email": event.from_email, + "to_email": event.to_email, + "status": thread.status, + "mission_id": thread.mission_id, + "prospect_id": thread.prospect_id + }) + + # 2. Fetch Mission Logs (Agent Activity) + # First get user missions + missions = await Mission.find({"user_id": user.clerk_id}).to_list() + mission_ids = [str(m.id) for m in missions] + + if mission_ids: + log_query = {"mission_id": {"$in": mission_ids}, "log_type": {"$in": ["action", "success", "error"]}} + if start_date or end_date: + date_filter = {} + if start_date: + date_filter["$gte"] = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + if end_date: + date_filter["$lte"] = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + if date_filter: + log_query["timestamp"] = date_filter + + logs = await MissionLog.find(log_query).sort("-timestamp").limit(limit).to_list() + + for log in logs: + calendar_events.append({ + "id": str(log.id), + "type": "agent_log", + "timestamp": log.timestamp.isoformat(), + "content": log.content, + "role": log.role, + "metadata": log.metadata, + "mission_id": log.mission_id, + "log_type": log.log_type + }) + + # Sort combined events by timestamp desc + calendar_events.sort(key=lambda x: x["timestamp"], reverse=True) + + return calendar_events + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch timeline: {str(e)}") + +@router.get("/threads/{thread_id}") +async def get_thread_details( + thread_id: str, + user: User = Depends(get_current_user) +): + """ + Get complete conversation thread with all events + """ + thread = await EmailThread.find_one({ + "thread_id": thread_id, + "user_id": user.clerk_id + }) + + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + return { + "thread_id": thread.thread_id, + "mission_id": thread.mission_id, + "prospect_id": thread.prospect_id, + "status": thread.status, + "first_sent_at": thread.first_sent_at.isoformat() if thread.first_sent_at else None, + "last_reply_at": thread.last_reply_at.isoformat() if thread.last_reply_at else None, + "reply_count": thread.reply_count, + "events": [ + { + "type": event.type, + "timestamp": event.timestamp.isoformat(), + "email_id": event.email_id, + "subject": event.subject, + "preview": event.preview, + "from_email": event.from_email, + "to_email": event.to_email, + "metadata": event.metadata + } + for event in thread.events + ] + } + +@router.post("/threads/{thread_id}/events") +async def add_thread_event( + thread_id: str, + event_data: dict, + user: User = Depends(get_current_user) +): + """ + Add a new event to an existing thread (for reply detection) + """ + thread = await EmailThread.find_one({ + "thread_id": thread_id, + "user_id": user.clerk_id + }) + + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + # Create new event + new_event = EmailEvent(**event_data) + thread.events.append(new_event) + thread.last_activity = new_event.timestamp + + # Update status and counters + if new_event.type == "reply_received": + thread.status = "replied" + thread.reply_count += 1 + thread.last_reply_at = new_event.timestamp + elif new_event.type == "follow_up": + thread.status = "waiting_reply" + + await thread.save() + + return {"message": "Event added successfully", "thread_id": thread.thread_id} diff --git a/backend/app/services/neo4j.py b/backend/app/services/neo4j.py new file mode 100644 index 0000000..e32ee9d --- /dev/null +++ b/backend/app/services/neo4j.py @@ -0,0 +1,139 @@ +import logging +from typing import List, Dict, Optional, Any +from neo4j import GraphDatabase +from app.core.config import settings + +logger = logging.getLogger(__name__) + +class Neo4jService: + def __init__(self): + self._driver = None + self._database = settings.NEO4J_DATABASE + + def connect(self): + """Establish connection to Neo4j database""" + if self._driver: + return + + if not settings.NEO4J_URI or not settings.NEO4J_USERNAME or not settings.NEO4J_PASSWORD: + logger.warning("Neo4j credentials not found. Contact Graph features will be disabled.") + return + + try: + self._driver = GraphDatabase.driver( + settings.NEO4J_URI, + auth=(settings.NEO4J_USERNAME, settings.NEO4J_PASSWORD) + ) + self._driver.verify_connectivity() + logger.info("Connected to Neo4j Contact Graph") + except Exception as e: + logger.error(f"Failed to connect to Neo4j: {e}") + self._driver = None + + def close(self): + """Close the Neo4j driver connection""" + if self._driver: + self._driver.close() + self._driver = None + logger.info("Closed Neo4j connection") + + def execute_query(self, query: str, params: Dict[str, Any] = None) -> List[Dict[str, Any]]: + """Execute a Cypher query and return results as a list of dictionaries""" + if not self._driver: + self.connect() + if not self._driver: + return [] + + if params is None: + params = {} + + try: + records, _, _ = self._driver.execute_query( + query, + parameters_=params, + database_=self._database + ) + return [record.data() for record in records] + except Exception as e: + logger.error(f"Neo4j Query Error: {e}") + return [] + + def resolve_person(self, name: str) -> Dict[str, Any]: + """ + Find or create a Person node by name. + Behaves deterministically: if exists, return it; if not, create it. + """ + query = """ + MERGE (p:Person {name: $name}) + RETURN p + """ + results = self.execute_query(query, {"name": name}) + if results: + return results[0].get("p", {}) + return {} + + def add_contact_method(self, person_name: str, channel: str, identifier: str) -> bool: + """ + Add a contact method to a person. + Example: (Person {name: "Sriram"})-[:HAS_EMAIL]->(Email {email: "sriram@gmail.com"}) + + Channels: email, linkedin, github, twitter, phone, etc. + """ + # Normalize channel to uppercase for relationship type convention (e.g., HAS_EMAIL) + # But user asked for specific node labels like 'Email', 'LinkedIn'. + # Let's follow the pattern: (Person)-[:HAS_]->(ChannelNode) + + channel_upper = channel.upper() + rel_type = f"HAS_{channel_upper}" + + # Determine node label and property based on channel + # e.g., channel="email" -> Label="Email", prop="email" or just "id" + # The prompt used: (Sriram)-[:HAS_EMAIL]->(sriram@gmail.com) + # But (sriram@gmail.com) implies a node. + # Let's standardize node labels: Email, LinkedIn, etc. + # And the property should probably be `value` or `id` to be generic, or `email`/`url`. + # Prompt said: extract Identifier: sriram@gmail.com + + node_label = channel.capitalize() # Email, Linkedin + if node_label == "Linkedin": node_label = "LinkedIn" # Fix casing + + # We need a generic way to creating the node. + # Query: Match Person, Merge ChannelNode, Merge Relationship + + query = f""" + MERGE (p:Person {{name: $name}}) + MERGE (c:{node_label} {{id: $identifier}}) + MERGE (p)-[:{rel_type}]->(c) + RETURN p, c + """ + + results = self.execute_query(query, {"name": person_name, "identifier": identifier}) + return len(results) > 0 + + def get_contact_methods(self, person_name: str) -> Dict[str, str]: + """ + Retrieve all known contact methods for a person. + Returns a dictionary: { "email": "sriram@example.com", "linkedin": "..." } + """ + # Dynamic query to fetch all outgoing relationships that look like contact methods + query = """ + MATCH (p:Person {name: $name})-[r]->(c) + WHERE type(r) STARTS WITH 'HAS_' + RETURN type(r) as type, c.id as value + """ + + results = self.execute_query(query, {"name": person_name}) + + contacts = {} + for row in results: + rel_type = row.get("type", "") + value = row.get("value") + + # Parse channel name from rel_type (HAS_EMAIL -> email) + if rel_type.startswith("HAS_"): + channel = rel_type[4:].lower() + contacts[channel] = value + + return contacts + +neo4j_service = Neo4jService() diff --git a/backend/app/services/rag.py b/backend/app/services/rag.py new file mode 100644 index 0000000..4429037 --- /dev/null +++ b/backend/app/services/rag.py @@ -0,0 +1,195 @@ +""" +RAG (Retrieval Augmented Generation) Service + +This module provides document parsing and context retrieval from user-uploaded +knowledge assets. It supports PDF, TXT, MD, and DOCX files. +""" + +import io +from typing import List, Dict, Optional +from app.models import UserAsset + +# Optional imports for document parsing +try: + import PyPDF2 + HAS_PYPDF = True +except ImportError: + HAS_PYPDF = False + +try: + import docx + HAS_DOCX = True +except ImportError: + HAS_DOCX = False + + +class RAGService: + """Service for extracting and retrieving content from knowledge assets.""" + + @staticmethod + async def get_asset_content(asset_id: str) -> Optional[str]: + """ + Extract text content from a user asset. + + Supports: + - PDF files + - Plain text (.txt) + - Markdown (.md) + - Word documents (.docx) + """ + asset = await UserAsset.get(asset_id) + if not asset: + return None + + content_type = asset.content_type.lower() + filename = asset.filename.lower() + + try: + # PDF files + if content_type == "application/pdf" or filename.endswith(".pdf"): + return RAGService._extract_pdf(asset.file_data) + + # Plain text and markdown + elif content_type in ["text/plain", "text/markdown"] or \ + filename.endswith((".txt", ".md")): + return asset.file_data.decode("utf-8", errors="ignore") + + # Word documents + elif content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" or \ + filename.endswith(".docx"): + return RAGService._extract_docx(asset.file_data) + + # JSON files + elif content_type == "application/json" or filename.endswith(".json"): + return asset.file_data.decode("utf-8", errors="ignore") + + else: + # Try to decode as text + try: + return asset.file_data.decode("utf-8", errors="ignore") + except: + return f"[Binary file: {asset.filename}]" + + except Exception as e: + return f"[Error extracting content from {asset.filename}: {str(e)}]" + + @staticmethod + def _extract_pdf(file_data: bytes) -> str: + """Extract text from PDF bytes.""" + if not HAS_PYPDF: + return "[PDF parsing not available - install PyPDF2]" + + try: + reader = PyPDF2.PdfReader(io.BytesIO(file_data)) + text_parts = [] + for page in reader.pages: + text = page.extract_text() + if text: + text_parts.append(text) + return "\n\n".join(text_parts) + except Exception as e: + return f"[Error reading PDF: {str(e)}]" + + @staticmethod + def _extract_docx(file_data: bytes) -> str: + """Extract text from DOCX bytes.""" + if not HAS_DOCX: + return "[DOCX parsing not available - install python-docx]" + + try: + doc = docx.Document(io.BytesIO(file_data)) + paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] + return "\n\n".join(paragraphs) + except Exception as e: + return f"[Error reading DOCX: {str(e)}]" + + @staticmethod + async def get_multiple_assets_content(asset_ids: List[str]) -> Dict[str, str]: + """ + Extract content from multiple assets. + + Returns a dict mapping asset_id -> content + """ + results = {} + for asset_id in asset_ids: + content = await RAGService.get_asset_content(asset_id) + if content: + results[asset_id] = content + return results + + @staticmethod + async def build_context_from_assets(asset_ids: List[str], max_chars: int = 8000) -> str: + """ + Build a combined context string from multiple assets. + + Truncates content to fit within max_chars while preserving + structure from each document. + """ + if not asset_ids: + return "" + + contents = await RAGService.get_multiple_assets_content(asset_ids) + + if not contents: + return "" + + # Get asset metadata for context + context_parts = [] + chars_used = 0 + + for asset_id, content in contents.items(): + asset = await UserAsset.get(asset_id) + if not asset: + continue + + # Add document header + header = f"\n--- From: {asset.filename} ---\n" + + # Calculate available space + remaining = max_chars - chars_used - len(header) - 100 # Buffer + + if remaining <= 0: + break + + # Truncate content if needed + if len(content) > remaining: + content = content[:remaining] + "... [truncated]" + + context_parts.append(header + content) + chars_used += len(header) + len(content) + + return "\n".join(context_parts) + + @staticmethod + def chunk_content(content: str, chunk_size: int = 1000, overlap: int = 200) -> List[str]: + """ + Split content into overlapping chunks for better retrieval. + + This is useful for future semantic search implementation. + """ + if len(content) <= chunk_size: + return [content] + + chunks = [] + start = 0 + + while start < len(content): + end = start + chunk_size + + # Try to break at a sentence boundary + if end < len(content): + # Look for sentence endings near the chunk boundary + for delim in ['. ', '.\n', '\n\n', '\n']: + last_delim = content.rfind(delim, start + chunk_size - 200, end) + if last_delim != -1: + end = last_delim + len(delim) + break + + chunks.append(content[start:end].strip()) + start = end - overlap + + return chunks + + +# Singleton instance +rag_service = RAGService() diff --git a/backend/app/services/unipile.py b/backend/app/services/unipile.py new file mode 100644 index 0000000..6cd79d1 --- /dev/null +++ b/backend/app/services/unipile.py @@ -0,0 +1,103 @@ +import logging +import httpx +from typing import Dict, Any, List +from app.core.config import settings + +logger = logging.getLogger(__name__) + +class UnipileService: + def __init__(self): + self.dsn = settings.UNIPILE_DSN + self.api_key = settings.UNIPILE_API_KEY + self.headers = { + "X-API-KEY": self.api_key, + "accept": "application/json", + "Content-Type": "application/json" + } + + async def get_accounts(self) -> List[Dict[str, Any]]: + """Retrieve connected accounts.""" + if not self.dsn or not self.api_key: + logger.warning("Unipile unavailable: Missing DSN or API Key") + return [] + + url = f"{self.dsn}/api/v1/accounts" + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers, timeout=10.0) + if response.status_code == 200: + data = response.json() + return data.get("items", []) # Adjust based on actual API response + else: + logger.error(f"Unipile Error {response.status_code}: {response.text}") + return [] + except Exception as e: + logger.error(f"Unipile Connection Failed: {e}") + return [] + + async def send_linkedin_message(self, account_id: str, linkedin_url: str, message: str) -> Dict[str, Any]: + """ + Send a LinkedIn message. + Note: Unipile usually requires a chat/thread ID or specific user ID + derived from the Linkedin URL. + Assuming we might need to 'resolve' the URL to a Unipile identifier (provider_id) first. + + For simplicity in this step, we'll try to use the 'messaging' endpoint if available, + or we assume we might need to look up the profile first. + + Based on typical Unipile flow: + 1. Resolve Linkedin URL to Profile/Chat. + 2. Send Message. + + If we don't have exact Unipile docs for 'send to url', we will assume + a standard /chats or /messages endpoint where payload takes a provider_id. + """ + if not self.dsn or not self.api_key: + return {"status": "error", "message": "Unipile not configured"} + + # Placeholder: 1. Search/Resolve Profile to get internal ID + # Only implementing basic specific call if known, else mock. + # But user wants REAL implementation. + # Let's assume we need to FIND the conversation or CREATE one. + + # Step 1: Start chat / Get Chat ID based on public identifier (linkedin url) + # Endpoint: POST /api/v1/chats + # Body: { "account_id": "...", "attendees": ["linkedin_public_id_or_url"] } + + create_chat_url = f"{self.dsn}/api/v1/chats" + try: + async with httpx.AsyncClient() as client: + # 1. Create/Get Chat + chat_payload = { + "account_id": account_id, + "attendees_ids": [linkedin_url] # Unipile often accepts URLs or Provider IDs here + } + chat_res = await client.post(create_chat_url, headers=self.headers, json=chat_payload) + + chat_id = None + if chat_res.status_code in [200, 201]: + chat_data = chat_res.json() + chat_id = chat_data.get("id") + else: + logger.error(f"Failed to resolve chat for {linkedin_url}: {chat_res.text}") + return {"status": "error", "message": f"Could not resolve chat: {chat_res.text}"} + + if not chat_id: + return {"status": "error", "message": "No chat ID returned"} + + # 2. Send Message + msg_url = f"{self.dsn}/api/v1/chats/{chat_id}/messages" + msg_payload = { + "text": message + } + msg_res = await client.post(msg_url, headers=self.headers, json=msg_payload) + + if msg_res.status_code in [200, 201]: + return {"status": "success", "data": msg_res.json()} + else: + return {"status": "error", "message": f"Failed to send: {msg_res.text}"} + + except Exception as e: + return {"status": "error", "message": str(e)} + +unipile_service = UnipileService() diff --git a/backend/main.py b/backend/main.py index 80dc573..b56a0c6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,5 @@ -from fastapi import FastAPI +from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from beanie import init_beanie @@ -7,14 +7,18 @@ import json from app.core.config import settings -from app.models import User, Mission, Prospect, Draft, MissionLog, Agent, UserAsset -from app.routers import missions, reviews, agents +from app.models import User, Mission, Prospect, Draft, MissionLog, Agent, UserAsset, EmailThread, ContactHistory, PendingAction +from app.routers import missions, reviews, agents, contacts @asynccontextmanager async def lifespan(app: FastAPI): # Startup - client = AsyncIOMotorClient(settings.MONGODB_URI) - await init_beanie(database=client.outbound_ai, document_models=[User, Mission, Prospect, Draft, MissionLog, Agent, UserAsset]) + # Validate required environment variables + if not settings.MONGODB_URI: + raise ValueError("MONGODB_URI environment variable is required") + + client = AsyncIOMotorClient(settings.MONGODB_URI, tlsAllowInvalidCertificates=True) + await init_beanie(database=client.outbound_ai, document_models=[User, Mission, Prospect, Draft, MissionLog, Agent, UserAsset, EmailThread, ContactHistory, PendingAction]) yield # Shutdown @@ -31,6 +35,7 @@ async def lifespan(app: FastAPI): app.include_router(missions.router, prefix="/api/v1/missions", tags=["missions"]) app.include_router(reviews.router, prefix="/api/v1/reviews", tags=["reviews"]) app.include_router(agents.router, prefix="/api/v1/agents", tags=["agents"]) +app.include_router(contacts.router, prefix="/api/v1/contacts", tags=["contacts"]) from app.routers import integrations app.include_router(integrations.router, prefix="/api/v1/integrations", tags=["integrations"]) from app.routers import users @@ -39,54 +44,13 @@ async def lifespan(app: FastAPI): app.include_router(assets.router, prefix="/api/v1/assets", tags=["assets"]) from app.routers import health app.include_router(health.router, prefix="/api/v1/health", tags=["health"]) +from app.routers import timeline +app.include_router(timeline.router, prefix="/api/v1", tags=["timeline"]) +from app.routers import settings as settings_router +app.include_router(settings_router.router, prefix="/api/v1/settings", tags=["settings"]) -from fastapi import WebSocket, WebSocketDisconnect -from typing import Dict, List - -class ConnectionManager: - def __init__(self): - # Map user_id to list of websockets - self.user_connections: Dict[str, List[WebSocket]] = {} - - async def connect(self, websocket: WebSocket, user_id: str): - await websocket.accept() - if user_id not in self.user_connections: - self.user_connections[user_id] = [] - self.user_connections[user_id].append(websocket) - - def disconnect(self, websocket: WebSocket, user_id: str): - if user_id in self.user_connections: - self.user_connections[user_id].remove(websocket) - if not self.user_connections[user_id]: - del self.user_connections[user_id] - - async def send_to_user(self, user_id: str, message: dict): - """Send message to all connections of a specific user""" - if user_id in self.user_connections: - msg_str = json.dumps(message) - for ws in self.user_connections[user_id]: - try: - await ws.send_text(msg_str) - except: - pass - - async def broadcast(self, message: dict): - """Broadcast to all connected users""" - msg_str = json.dumps(message) - for user_id, connections in self.user_connections.items(): - for ws in connections: - try: - await ws.send_text(msg_str) - except: - pass - -# Global manager instance -manager = ConnectionManager() - -# Export for use in agent -def get_connection_manager(): - return manager +from app.core.socket import manager @app.websocket("/ws/brain/{user_id}") async def websocket_endpoint(websocket: WebSocket, user_id: str): diff --git a/backend/requirements.txt b/backend/requirements.txt index a9ce75e..7a6b60d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,3 +14,7 @@ websockets boto3 python-multipart composio +neo4j~=5.28 +# RAG document parsing +PyPDF2 +python-docx diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6c6276c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +# Build Stage +FROM node:18-alpine as build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Production Stage +FROM nginx:alpine + +# Copy built assets to nginx +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/components.json b/frontend/components.json index 62e1011..e7b1f03 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -10,11 +10,15 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" + }, + "registries": { + "@react-bits": "https://reactbits.dev/r/{name}.json" } } diff --git a/frontend/lint_report.txt b/frontend/lint_report.txt new file mode 100644 index 0000000..c31bff5 --- /dev/null +++ b/frontend/lint_report.txt @@ -0,0 +1,119 @@ + +> vite_react_shadcn_ts@0.0.0 lint +> eslint . + + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/calendar/CalendarEventCard.tsx + 6:11 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/launchpad/HeroInput.tsx + 12:24 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 13:30 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 23:58 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 24:66 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 152:37 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/FloatingLines.tsx + 461:36 warning The ref value 'containerRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'containerRef.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps + 477:8 warning React Hook useEffect has missing dependencies: 'bottomLineCount', 'bottomLineDistance', 'middleLineCount', 'middleLineDistance', 'topLineCount', and 'topLineDistance'. Either include them or remove the dependency array react-hooks/exhaustive-deps + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/MagnetLines.tsx + 91:17 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/badge.tsx + 29:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/button.tsx + 47:18 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/canvas-reveal-effect.tsx + 86:38 warning The ref value 'canvasRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'canvasRef.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/command.tsx + 24:11 error An interface declaring no members is equivalent to its supertype @typescript-eslint/no-empty-object-type + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/form.tsx + 129:10 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/google-gemini-effect.tsx + 11:17 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/navigation-menu.tsx + 111:3 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/neural-graph.tsx + 261:6 warning React Hook useEffect has missing dependencies: 'connections' and 'nodes'. Either include them or remove the dependency array react-hooks/exhaustive-deps + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/sidebar.tsx + 636:3 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/sonner.tsx + 27:19 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/textarea.tsx + 5:18 error An interface declaring no members is equivalent to its supertype @typescript-eslint/no-empty-object-type + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/components/ui/toggle.tsx + 37:18 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/lib/api.ts + 27:63 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 188:36 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 203:50 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 352:9 warning React Hook useMemo has a missing dependency: 'getToken'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/pages/ActiveAgents.tsx + 86:55 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 110:6 warning React Hook useEffect has a missing dependency: 'fetchAgents'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 115:86 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 330:16 error Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free @typescript-eslint/ban-ts-comment + 332:42 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 333:42 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/pages/Calendar.tsx + 12:40 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 28:73 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 34:69 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 41:74 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/pages/ContactHistory.tsx + 10:46 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 13:40 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 18:8 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/pages/DeployAgent.tsx + 297:71 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/pages/Integrations.tsx + 42:54 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 102:25 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 130:25 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/pages/Launchpad.tsx + 42:56 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/pages/MissionChat.tsx + 37:28 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 38:34 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 48:16 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 61:44 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 74:60 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 75:68 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 235:53 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 242:79 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 270:8 warning React Hook useEffect has a missing dependency: 'api'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 313:29 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 338:8 warning React Hook useEffect has a missing dependency: 'setSearchParams'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 347:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 389:39 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 467:52 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 480:8 warning React Hook useEffect has a missing dependency: 'api'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 653:61 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/Users/aryawadhwa/Desktop/outbound ai/frontend/src/pages/ReviewQueue.tsx + 43:40 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 73:32 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 81:40 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +βœ– 62 problems (45 errors, 17 warnings) + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..a6a7490 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Proxy WebSocket connections + location /ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d9a252e..def305b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,9 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", + "@react-three/drei": "^9.92.0", + "@react-three/fiber": "^8.15.0", + "@shadergradient/react": "^2.4.20", "@tanstack/react-query": "^5.83.0", "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", @@ -45,6 +48,7 @@ "date-fns": "^3.6.0", "driver.js": "^1.4.0", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.29.2", "input-otp": "^1.4.2", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", @@ -52,20 +56,24 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.61.1", + "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.9", "react-router-dom": "^6.30.1", "recharts": "^2.15.4", + "remark-gfm": "^4.0.1", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "three": "^0.160.0", "vaul": "^0.9.9", "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.32.0", - "@tailwindcss/typography": "^0.5.16", + "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", + "@types/dom-speech-recognition": "^0.0.7", "@types/node": "^22.16.5", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", @@ -187,6 +195,12 @@ } } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -944,6 +958,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.8.tgz", + "integrity": "sha512-Rp7ll8BHrKB3wXaRFKhrltwZl1CiXGdibPxuWXvqGnKTnv8fqa/nvftYNuSbf+pbJWKYCXdBtYTITdAUTGGh0Q==", + "license": "Apache-2.0" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2518,6 +2538,214 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-spring/animated": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.6.1.tgz", + "integrity": "sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.6.1.tgz", + "integrity": "sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/rafz": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.6.1.tgz", + "integrity": "sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.6.1.tgz", + "integrity": "sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.6.1.tgz", + "integrity": "sha512-Tyw2YhZPKJAX3t2FcqvpLRb71CyTe1GvT3V+i+xJzfALgpk10uPGdGaQQ5Xrzmok1340DAeg2pR/MCfaW7b8AA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/core": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.6.1.tgz", + "integrity": "sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.92.0", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.92.0.tgz", + "integrity": "sha512-CVugeEp84JmXvhkuBJJwC3dbq/5guWfH61v0loCR2+deZQDCxXFlwtPzog19YebB6giHWzuS+kL4Zh0yqJ1sYg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@mediapipe/tasks-vision": "0.10.8", + "@react-spring/three": "~9.6.1", + "@use-gesture/react": "^10.2.24", + "camera-controls": "^2.4.2", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.28", + "glsl-noise": "^0.0.0", + "lodash.clamp": "^4.0.3", + "lodash.omit": "^4.5.0", + "lodash.pick": "^4.4.0", + "maath": "^0.9.0", + "meshline": "^3.1.6", + "react-composer": "^5.0.3", + "react-merge-refs": "^1.1.0", + "stats-gl": "^2.0.0", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.6.7", + "three-stdlib": "^2.28.0", + "troika-three-text": "^0.47.2", + "utility-types": "^3.10.0", + "uuid": "^9.0.1", + "zustand": "^3.5.13" + }, + "peerDependencies": { + "@react-three/fiber": ">=8.0", + "react": ">=18.0", + "react-dom": ">=18.0", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/drei/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.15.0.tgz", + "integrity": "sha512-rYefFqu6ki0iuPlIFhnfZsucJjY+4ZQNTdt+rvQgWo9f2T1Ia+1yotlNMs7jlnNG5nNBLQcq8zcz4pxbWg6rmw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.1", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18.0", + "react-dom": ">=18.0", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/@react-three/fiber/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -2758,6 +2986,16 @@ "win32" ] }, + "node_modules/@shadergradient/react": { + "version": "2.4.20", + "resolved": "https://registry.npmjs.org/@shadergradient/react/-/react-2.4.20.tgz", + "integrity": "sha512-MVYvYgTHK3d36C2jNKzt4OzWeyNyc/bWI0zKRyv5EgI2hmUee8nD0cmuNX9X1lw1RCcuof6n1Rnj63jWepZHJA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.2.0 || ^19.0.0", + "react-dom": "^18.2.0 || ^19.0.0" + } + }, "node_modules/@swc/core": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.2.tgz", @@ -2985,15 +3223,12 @@ } }, "node_modules/@tailwindcss/typography": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", - "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", "dev": true, "license": "MIT", "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { @@ -3125,6 +3360,12 @@ "node": ">= 10" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3241,6 +3482,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3248,13 +3498,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dom-speech-recognition": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.7.tgz", + "integrity": "sha512-NjiUoJbBlKhyufNsMZLSp+pbPNtPAFnR738RCJvtZy/HVQ2TZjmqpMyaeOSMXgxdfZM60nt8QGbtfmQrJAH2sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3262,6 +3542,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.16.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz", @@ -3272,18 +3567,22 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3300,6 +3599,48 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", + "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.38.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", @@ -3558,6 +3899,30 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react-swc": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", @@ -3687,6 +4052,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, "node_modules/@xyflow/react": { "version": "12.10.0", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", @@ -3940,12 +4311,51 @@ "postcss": "^8.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4014,6 +4424,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4057,6 +4491,15 @@ "node": ">= 6" } }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001727", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", @@ -4078,6 +4521,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -4112,6 +4565,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -4231,6 +4724,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4247,9 +4750,27 @@ "dev": true, "license": "MIT" }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", @@ -4528,7 +5049,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4555,6 +5075,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4591,12 +5124,34 @@ "node": ">=6" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4641,6 +5196,12 @@ "node": ">=12" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/driver.js": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", @@ -5044,6 +5605,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -5080,6 +5651,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5147,6 +5724,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5257,6 +5840,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.2.tgz", + "integrity": "sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.29.2", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5403,6 +6013,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5474,6 +6090,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -5487,6 +6143,16 @@ "node": ">=12" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -5529,6 +6195,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5576,6 +6262,12 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", @@ -5595,6 +6287,30 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5622,6 +6338,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5652,6 +6378,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5661,6 +6397,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -5674,6 +6422,27 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -5857,17 +6626,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "node_modules/lodash.clamp": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/lodash.clamp/-/lodash.clamp-4.0.3.tgz", + "integrity": "sha512-HvzRFWjtcguTW7yd8NJBshuNaCa8aqNFtnswdT7f/cMd/1YKy5Zzoq4W/Oxvnx9l7aeY258uSdDfM793+eLsVg==", + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -5876,6 +6639,30 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.omit": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==", + "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.", + "license": "MIT" + }, + "node_modules/lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==", + "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6366,6 +7153,16 @@ "lz-string": "bin/bin.js" } }, + "node_modules/maath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.9.0.tgz", + "integrity": "sha512-aAR8hoUqPxlsU8VOxkS9y37jhUzdUxM017NpCuxFU1Gk+nMaZASZxymZrV8LRSHzRk/watlbfyNKu6XPUhCFrQ==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.144.0", + "three": ">=0.144.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6376,6 +7173,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6386,88 +7193,962 @@ "node": ">= 0.4" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, - "engines": { - "node": ">=8.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" }, - "engines": { - "node": "*" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz", + "integrity": "sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -6636,6 +8317,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -6886,6 +8592,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6962,6 +8674,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -7024,6 +8746,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-day-picker": { "version": "8.10.1", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", @@ -7073,6 +8807,68 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-merge-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-1.1.0.tgz", + "integrity": "sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", @@ -7215,6 +9011,21 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7282,6 +9093,81 @@ "node": ">=8" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -7497,6 +9383,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7504,6 +9400,32 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -7569,6 +9491,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", @@ -7652,6 +9588,24 @@ "dev": true, "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -7699,6 +9653,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/swr": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz", @@ -7796,6 +9759,44 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.160.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz", + "integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.6.8.tgz", + "integrity": "sha512-EGebF9DZx1S8+7OZYNNTT80GXJZVf+UYXD/HyTg/e2kR/ApofIFfUS4ZzIHNnUVIadpnLSzM4n96wX+l7GMbnQ==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -7935,6 +9936,56 @@ "node": ">=12" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/troika-three-text": { + "version": "0.47.2", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.47.2.tgz", + "integrity": "sha512-qylT0F+U7xGs+/PEf3ujBdJMYWbn0Qci0kLqI5BJG2kW1wdg4T1XSxneypnF05DxFqJhEzuaOR9S2SjiyknMng==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.47.2", + "troika-worker-utils": "^0.47.2", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.47.2", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.47.2.tgz", + "integrity": "sha512-/28plhCxfKtH7MSxEGx8e3b/OXU5A0xlwl+Sbdp0H8FXUHKZDoksduEKmjQayXYtxAyuUiCRunYIv/8Vi7aiyg==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.47.2", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.47.2.tgz", + "integrity": "sha512-mzss4MeyzUkYBppn4x5cdAqrhBHFEuVmMMgLMTyFV23x6GvQMyo+/R5E5Lsbrt7WSt5RfvewjcwD1DChRTA9lA==", + "license": "MIT" + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -8018,6 +10069,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -8138,6 +10276,28 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vaul": { "version": "0.9.9", "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz", @@ -8151,6 +10311,34 @@ "react-dom": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -8355,6 +10543,17 @@ "node": ">=14" } }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -8633,6 +10832,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 4c8e141..d61abd3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,9 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", + "@react-three/drei": "^9.92.0", + "@react-three/fiber": "^8.15.0", + "@shadergradient/react": "^2.4.20", "@tanstack/react-query": "^5.83.0", "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", @@ -50,6 +53,7 @@ "date-fns": "^3.6.0", "driver.js": "^1.4.0", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.29.2", "input-otp": "^1.4.2", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", @@ -57,20 +61,24 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.61.1", + "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.9", "react-router-dom": "^6.30.1", "recharts": "^2.15.4", + "remark-gfm": "^4.0.1", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "three": "^0.160.0", "vaul": "^0.9.9", "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.32.0", - "@tailwindcss/typography": "^0.5.16", + "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", + "@types/dom-speech-recognition": "^0.0.7", "@types/node": "^22.16.5", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", diff --git a/frontend/public/fonts/GalgoVF.woff b/frontend/public/fonts/GalgoVF.woff new file mode 100644 index 0000000..4b5f7cf Binary files /dev/null and b/frontend/public/fonts/GalgoVF.woff differ diff --git a/frontend/public/fonts/GalgoVF.woff2 b/frontend/public/fonts/GalgoVF.woff2 new file mode 100644 index 0000000..fa1ea41 Binary files /dev/null and b/frontend/public/fonts/GalgoVF.woff2 differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2f89c5..6c10e84 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,103 +12,158 @@ import Settings from "./pages/Settings"; import Landing from "./pages/Landing"; import NotFound from "./pages/NotFound"; import MissionChat from "./pages/MissionChat"; +import Calendar from "./pages/Calendar"; +import ContactHistory from "./pages/ContactHistory"; +import { Integrations } from "./pages/Integrations"; +import Profile from "./pages/Profile"; import { SignedIn, SignedOut, RedirectToSignIn } from "@clerk/clerk-react"; import SignIn from "./pages/SignIn"; import SignUp from "./pages/SignUp"; +import ErrorBoundary from "./components/ErrorBoundary"; const queryClient = new QueryClient(); const App = () => ( - - - - - - - {/* Public Routes */} - } /> - } /> - } /> + + + + + + + + {/* Public Routes */} + } /> + } /> + } /> - {/* Protected Routes */} - - - - - - - - - - - } /> - - - - - - - - - - - } /> - - - - - - - - - - - } /> - - - - - - - - - - - } /> - - - - - - - - - - - } /> - - - - - - - - - - - } /> - } /> - - - - + {/* Protected Routes */} + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + + + + + + + + + + + } /> + } /> + + + + + ); export default App; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..da3dd0f --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,52 @@ +import React, { Component, ErrorInfo, ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Uncaught error:", error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return ( +
+
+

+ Something went wrong +

+

+ {this.state.error?.message || "An unexpected error occurred"} +

+ +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/calendar/CalendarEventCard.tsx b/frontend/src/components/calendar/CalendarEventCard.tsx new file mode 100644 index 0000000..ded3310 --- /dev/null +++ b/frontend/src/components/calendar/CalendarEventCard.tsx @@ -0,0 +1,81 @@ +import { cn } from "@/lib/utils"; +import { Mail, Zap, Play, CheckCircle, Bot } from "lucide-react"; +import { useMemo } from "react"; + +interface CalendarEvent { + type: 'mission' | 'draft' | 'email' | 'agent_log' | string; + content?: string; + subject?: string; + objective?: string; + preview?: string; + [key: string]: unknown; +} + +interface CalendarEventCardProps { + item: CalendarEvent; + onClick?: () => void; +} + +export function CalendarEventCard({ item, onClick }: CalendarEventCardProps) { + const config = useMemo(() => { + switch (item.type) { + case 'mission': + return { + icon: Play, + color: "text-emerald-400", + bg: "bg-emerald-500/10", + border: "border-emerald-500/20", + label: "Mission" + }; + case 'draft': + return { + icon: Mail, + color: "text-cyan-400", + bg: "bg-cyan-500/10", + border: "border-cyan-500/20", + label: "Draft" + }; + case 'email': + return { + icon: Zap, + color: "text-purple-400", + bg: "bg-purple-500/10", + border: "border-purple-500/20", + label: "Activity" + }; + case 'agent_log': + return { + icon: Bot, + color: "text-indigo-400", + bg: "bg-indigo-500/10", + border: "border-indigo-500/20", + label: "Agent Log" + }; + default: + return { + icon: CheckCircle, + color: "text-zinc-400", + bg: "bg-zinc-500/10", + border: "border-zinc-500/20", + label: "Event" + }; + } + }, [item.type]); + + return ( +
+ + {item.content || item.objective || item.subject || item.preview || "Event"} + + {/* Hover visual */} +
+
+ ); +} diff --git a/frontend/src/components/landing/CTASection.tsx b/frontend/src/components/landing/CTASection.tsx new file mode 100644 index 0000000..06117a2 --- /dev/null +++ b/frontend/src/components/landing/CTASection.tsx @@ -0,0 +1,81 @@ +import { useRef } from "react"; +import { motion, useScroll, useTransform } from "framer-motion"; +import { ArrowRight } from "lucide-react"; +import { Link } from "react-router-dom"; +import { SignedIn, SignedOut, SignInButton } from "@clerk/clerk-react"; +import { GlassButton } from "@/components/ui/glass-button"; + +export function CTASection() { + const containerRef = useRef(null); + const { scrollYProgress } = useScroll({ + target: containerRef, + offset: ["start end", "end end"] + }); + + const y = useTransform(scrollYProgress, [0, 1], [100, 0]); + const opacity = useTransform(scrollYProgress, [0, 0.5], [0, 1]); + + return ( +
+ {/* Background Texture */} +
+
+
+
+ +
+
+ +

+ /// MISSION READY +

+

+ + DEPLOY + + + YOUR + + + AGENTS + +

+
+ +
+
+

+ Join hundreds of forward-thinking teams using Expedite AI to automate their growth engine. +

+
+ +
+ + + + Launch Mission Control + + + + + + + + Get Started + + + + +
+
+
+
+ + {/* Decorative large glow */} +
+
+ ); +} diff --git a/frontend/src/components/landing/HowItWorks.tsx b/frontend/src/components/landing/HowItWorks.tsx new file mode 100644 index 0000000..ad48d16 --- /dev/null +++ b/frontend/src/components/landing/HowItWorks.tsx @@ -0,0 +1,224 @@ +import { useRef } from "react"; +import { motion, useScroll, useTransform } from "framer-motion"; +import { cn } from "@/lib/utils"; + +// Graphics for each step +const Graphics = { + Define: () => ( +
+
+
+
+
+
+
+
+
+ Find Series A Founders... + +
+
+
+ ), + Activate: () => ( +
+ {/* Radar circles */} +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ + {/* Central Node */} +
+
+
+ + {/* Emerging Agents */} + {[ + { t: '15%', l: '50%', d: 0 }, + { t: '60%', l: '85%', d: 1.2 }, + { t: '85%', l: '40%', d: 2.4 }, + { t: '40%', l: '15%', d: 3.6 }, + { t: '25%', l: '75%', d: 4.8 }, + ].map((pos, i) => ( + +
+ + ))} +
+ ), + Review: () => ( +
+
+
+
98% Match
+
+
+
+
+
+
+ +
+ +
+
+
+ ), + Grow: () => ( +
+ {[40, 65, 45, 80, 55, 95].map((h, i) => ( + +
+ {h}% +
+
+ ))} +
+ ) +}; + +const items = [ + { + step: "01", + title: "Define Your Mission", + description: "Tell our AI exactly who you want to target. Inputs like role, industry, or even natural language queries launch the hunt.", + Graphic: Graphics.Define + }, + { + step: "02", + title: "AI Agents Activate", + description: "Our diverse swarm of agents scans the web, verifying emails, analyzing company news, and building rich prospect profiles.", + Graphic: Graphics.Activate + }, + { + step: "03", + title: "Review & Approve", + description: "You're the pilot. Review generated drafts in your queue, tweak the strategy, and approve with a single click.", + Graphic: Graphics.Review + }, + { + step: "04", + title: "Watch It Grow", + description: "Track the performance of your campaigns. Optimize based on real-time data on open rates, relies, and conversions.", + Graphic: Graphics.Grow + } +]; + +export function HowItWorks() { + const containerRef = useRef(null); + const { scrollYProgress } = useScroll({ + target: containerRef, + offset: ["start start", "end end"] + }); + + return ( +
+
+
+ + How It Works + +

+ From input to inbox in four streamlined steps. +

+
+ +
+ {/* Connecting Beam (Desktop) */} +
+ + +
+ {items.map((item, index) => ( +
+ {/* Timeline Node (Desktop) */} +
+
+
+ + {/* Text Content */} + +
{item.step}
+

{item.title}

+

+ {item.description} +

+
+ + {/* Spacer for timeline column */} +
+ + {/* Graphic Card */} + +
+ + + {/* Glow on hover */} +
+
+ +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/launchpad/HeroInput.tsx b/frontend/src/components/launchpad/HeroInput.tsx index 5ed3b41..4750ca5 100644 --- a/frontend/src/components/launchpad/HeroInput.tsx +++ b/frontend/src/components/launchpad/HeroInput.tsx @@ -1,28 +1,151 @@ -import { useState } from "react"; -import { Sparkles, ArrowRight, Loader2, Paperclip } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; +import { Loader2, Mic, MicOff, Paperclip, ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useApi } from "@/lib/api"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +// Extend Window interface for Web Speech API +interface Asset { + id: string; + filename: string; + content_type?: string; + [key: string]: unknown; +} + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + SpeechRecognition: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + webkitSpeechRecognition: any; + } +} + export function HeroInput() { const [query, setQuery] = useState(""); const [isFocused, setIsFocused] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [isListening, setIsListening] = useState(false); + const [showAssetPicker, setShowAssetPicker] = useState(false); + const [availableAssets, setAvailableAssets] = useState([]); + const [selectedAttachments, setSelectedAttachments] = useState([]); + const recognitionRef = useRef(null); const api = useApi(); const navigate = useNavigate(); - // Asset picker state - const [showAssetPicker, setShowAssetPicker] = useState(false); - const [availableAssets, setAvailableAssets] = useState([]); - const [selectedAttachments, setSelectedAttachments] = useState([]); + // Initialize Speech Recognition + const shouldListenRef = useRef(false); + const transcriptRef = useRef(""); // Persist final text across restarts + + useEffect(() => { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (SpeechRecognition) { + const recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = "en-US"; + + recognition.onresult = (event) => { + let interimTranscript = ""; + let finalTranscript = ""; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result.isFinal) { + finalTranscript += result[0].transcript; + } else { + interimTranscript += result[0].transcript; + } + } + + // Add final transcript to ref + transcriptRef.current += finalTranscript; + + // Update input with stored final + current interim + setQuery(transcriptRef.current + interimTranscript); + }; + + recognition.onend = () => { + // Auto-restart if we should still be listening + if (shouldListenRef.current && recognitionRef.current) { + try { + recognitionRef.current.start(); + } catch (e) { + console.log("Could not restart recognition:", e); + } + } else { + setIsListening(false); + // Do NOT clear transcriptRef here + } + }; + + recognition.onerror = (event) => { + console.error("Speech recognition error:", event.error); + + // Stop on permission or network errors + if ( + event.error === "not-allowed" || + event.error === "audio-capture" || + event.error === "network" + ) { + shouldListenRef.current = false; + setIsListening(false); + + const errorMessage = event.error === "network" + ? "Network error: Speech recognition requires internet connection." + : "Microphone access denied. Please allow access in settings."; + + toast.error("Speech Recognition Failed", { + description: errorMessage, + }); + } + }; + + recognitionRef.current = recognition; + } + + return () => { + shouldListenRef.current = false; + if (recognitionRef.current) { + recognitionRef.current.abort(); + } + }; + }, []); + + const toggleListening = () => { + if (!recognitionRef.current) { + toast.error("Speech recognition not supported", { + description: "Your browser does not support speech recognition.", + }); + return; + } + + if (isListening) { + // Manual stop + shouldListenRef.current = false; + // Small delay to allow final result to flush + setTimeout(() => { + recognitionRef.current?.stop(); + setIsListening(false); + }, 150); + } else { + // Start fresh + setQuery(""); + transcriptRef.current = ""; // Clear previous recording + shouldListenRef.current = true; + recognitionRef.current.start(); + setIsListening(true); + } + }; const handleInputChange = async (e: React.ChangeEvent) => { const value = e.target.value; setQuery(value); - if (value.endsWith('#')) { + // Trigger asset picker when user types # + if (value.endsWith("#")) { try { const assets = await api.getAssets(); setAvailableAssets(assets || []); @@ -30,21 +153,21 @@ export function HeroInput() { } catch (err) { console.error("Failed to fetch assets", err); } - } else if (!value.includes('#')) { + } else if (!value.includes("#")) { setShowAssetPicker(false); } }; - const handleSelectAsset = (asset: any) => { - if (!selectedAttachments.find(a => a.id === asset.id)) { - setSelectedAttachments(prev => [...prev, asset]); + const handleSelectAsset = (asset: Asset) => { + if (!selectedAttachments.find((a) => a.id === asset.id)) { + setSelectedAttachments((prev) => [...prev, asset]); } - setQuery(prev => prev.replace(/#$/, '')); + setQuery((prev) => prev.replace(/#$/, "")); setShowAssetPicker(false); }; const handleRemoveAttachment = (id: string) => { - setSelectedAttachments(prev => prev.filter(a => a.id !== id)); + setSelectedAttachments((prev) => prev.filter((a) => a.id !== id)); }; const handleSubmit = async (e: React.FormEvent) => { @@ -55,7 +178,7 @@ export function HeroInput() { // Include attachment info in the objective let objective = query; if (selectedAttachments.length > 0) { - objective += ` [Attachments: ${selectedAttachments.map(a => a.filename).join(', ')}]`; + objective += ` [Attachments: ${selectedAttachments.map((a) => a.filename).join(", ")}]`; } const mission = await api.createMission(objective); @@ -77,26 +200,36 @@ export function HeroInput() { }; return ( -
- {/* Glow effect */} +
+ {/* Ambient glow */}
{/* Selected Attachments */} {selectedAttachments.length > 0 && ( -
- {selectedAttachments.map(att => ( -
+
+ {selectedAttachments.map((att) => ( +
{att.filename} - +
))}
@@ -105,11 +238,13 @@ export function HeroInput() {
{/* Asset Picker Dropdown */} {showAssetPicker && ( -
+
{availableAssets.length > 0 ? ( <> -
Select an attachment
- {availableAssets.map(asset => ( +
+ Select an attachment +
+ {availableAssets.map((asset) => ( + {/* Input Field */} setIsFocused(true)} onBlur={() => setTimeout(() => setIsFocused(false), 200)} onKeyDown={(e) => e.key === "Enter" && !showAssetPicker && handleSubmit(e)} - placeholder="Describe your outbound mission..." - className="flex-1 bg-transparent px-4 py-5 text-lg text-foreground placeholder:text-muted-foreground focus:outline-none" + placeholder={isListening ? "Listening..." : "Describe your mission... (type # for assets)"} + className={cn( + "flex-1 bg-transparent text-lg text-foreground placeholder:text-muted-foreground/70 focus:outline-none py-3", + isListening && "placeholder:text-red-400" + )} /> -
- -
+ {/* Launch Button */} +
- {/* Suggestions */} -
- Try: - - -
+ {/* Listening indicator */} + {isListening && ( +
+ + + + + Listening... +
+ )}
); } diff --git a/frontend/src/components/launchpad/LiveMissionCard.tsx b/frontend/src/components/launchpad/LiveMissionCard.tsx new file mode 100644 index 0000000..bc5350c --- /dev/null +++ b/frontend/src/components/launchpad/LiveMissionCard.tsx @@ -0,0 +1,172 @@ +import { cn } from "@/lib/utils"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Terminal, Users, Mail, MoreVertical, Square, Trash2, Activity, Clock, Linkedin, Twitter, MessageSquare } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useState, useEffect } from "react"; + +interface MissionCardProps { + id?: string; + name: string; + status: "running" | "paused" | "completed" | "error" | "stopped"; + stage: number; + totalStages: number; + prospectsFound: number; + emailsQueued: number; + startedAt: string; + onStop?: (id: string) => void; + onDelete?: (id: string) => void; +} + +const statusConfig = { + running: { label: "ACTIVE", color: "text-emerald-400", dotClass: "bg-emerald-400", glow: "shadow-[0_0_10px_rgba(52,211,153,0.5)]" }, + paused: { label: "PAUSED", color: "text-amber-400", dotClass: "bg-amber-400", glow: "" }, + completed: { label: "DONE", color: "text-blue-400", dotClass: "bg-blue-400", glow: "" }, + error: { label: "ERROR", color: "text-red-400", dotClass: "bg-red-400", glow: "" }, + stopped: { label: "STOPPED", color: "text-zinc-500", dotClass: "bg-zinc-500", glow: "" }, +}; + +// Mock logs for liveliness effect +const MOCK_LOGS = [ + "Scouting LinkedIn profiles...", + "Analyzing relevance...", + "Drafting outreach...", + "Verifying email integrity...", + "Context matching: High...", + "Rate limit check: OK...", +]; + +export function LiveMissionCard({ + id, + name, + status, + stage, + totalStages, + prospectsFound, + emailsQueued, + startedAt, + onStop, + onDelete, +}: MissionCardProps) { + const progress = (stage / totalStages) * 100; + const config = statusConfig[status] || statusConfig.running; + const navigate = useNavigate(); + const [logLine, setLogLine] = useState("Initializing..."); + + // Simulated "Live Brain" effect + useEffect(() => { + if (status !== 'running') return; + + const interval = setInterval(() => { + const randomLog = MOCK_LOGS[Math.floor(Math.random() * MOCK_LOGS.length)]; + setLogLine(`> ${randomLog} [${new Date().toLocaleTimeString()}]`); + }, 2500); + + return () => clearInterval(interval); + }, [status]); + + const handleCardClick = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('[data-no-navigate]')) return; + if (id) navigate(`/chat/${id}`); + }; + + return ( +
+ {/* Active Scanline Effect */} + {status === 'running' && ( +
+ )} + +
+
+
+
+ {(() => { + const lowerName = name.toLowerCase(); + if (lowerName.includes("linkedin")) return LinkedIn; + if (lowerName.includes("twitter")) return Twitter; + if (lowerName.includes("reddit")) return Reddit; + return Gmail; + })()} + + {(() => { + const lowerName = name.toLowerCase(); + if (lowerName.includes("linkedin")) return "LinkedIn Outreach"; + if (lowerName.includes("twitter")) return "Twitter Outreach"; + if (lowerName.includes("reddit")) return "Reddit Outreach"; + return "Email Outreach"; + })()} + +
+ + + + + + + {status === "running" && onStop && id && ( + { e.stopPropagation(); onStop(id); }} className="cursor-pointer hover:bg-zinc-800"> + Stop Mission + + )} + {onDelete && id && ( + { e.stopPropagation(); onDelete(id); }} className="cursor-pointer text-red-400 hover:bg-red-400/10 hover:text-red-300"> + Delete Data + + )} + + +
+

{name}

+
+
+ + {/* Stats Grid */} +
+
+
+ + Prospects +
+ {prospectsFound} +
+
+
+ + Drafts +
+ {emailsQueued} +
+
+ + {/* Live Terminal */} +
+
System ready.
+
+ + {logLine} +
+
+ +
+
+ + {startedAt} +
+ ID: {id?.slice(-4).toUpperCase()} +
+
+ ); +} diff --git a/frontend/src/components/launchpad/RecipeCard.tsx b/frontend/src/components/launchpad/RecipeCard.tsx index 6632d96..6780270 100644 --- a/frontend/src/components/launchpad/RecipeCard.tsx +++ b/frontend/src/components/launchpad/RecipeCard.tsx @@ -14,30 +14,38 @@ export function RecipeCard({ icon: Icon, title, description, gradient, onClick } ); } diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 800d965..37bab4a 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Menu, Brain } from "lucide-react"; import { useIsMobile } from "@/hooks/use-mobile"; import { OnboardingTour } from "@/components/OnboardingTour"; +import { Logo } from "@/components/ui/logo"; interface AppLayoutProps { children: React.ReactNode; @@ -20,7 +21,17 @@ export function AppLayout({ children }: AppLayoutProps) { if (isMobile) { return ( -
+
+
{tour} {/* Mobile Header */}
@@ -35,7 +46,10 @@ export function AppLayout({ children }: AppLayoutProps) { - OutboundAI +
+ + +
@@ -56,19 +70,35 @@ export function AppLayout({ children }: AppLayoutProps) { } return ( -
+
+
{tour} {/* Left Navigation */} - setNavCollapsed(!navCollapsed)} - /> +
+ setNavCollapsed(!navCollapsed)} + /> +
{/* Main Content */} -
{children}
+
{children}
{/* Right Brain Sidebar */} - setBrainOpen(!brainOpen)} /> +
+ setBrainOpen(!brainOpen)} /> +
); } diff --git a/frontend/src/components/layout/LiveBrainSidebar.tsx b/frontend/src/components/layout/LiveBrainSidebar.tsx index ab8d2fe..dcd2930 100644 --- a/frontend/src/components/layout/LiveBrainSidebar.tsx +++ b/frontend/src/components/layout/LiveBrainSidebar.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; -import { Brain, ChevronRight, ChevronLeft, Terminal } from "lucide-react"; +import { Brain, ChevronRight, ChevronLeft, Terminal, GripVertical } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -31,90 +31,147 @@ export function LiveBrainSidebar({ isOpen, onToggle }: LiveBrainSidebarProps) { const [stats, setStats] = useState({ active: 0, processed: 0, queue: 0 }); const { userId } = useAuth(); const wsRef = useRef(null); + + // Resizable width state + const [width, setWidth] = useState(320); // Default width + const [isResizing, setIsResizing] = useState(false); + const minWidth = 280; + const maxWidth = 600; + + // Handle resize + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + }, []); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing) return; + + // Calculate new width based on mouse position from right edge + const newWidth = window.innerWidth - e.clientX; + setWidth(Math.min(maxWidth, Math.max(minWidth, newWidth))); + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + if (isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [isResizing]); useEffect(() => { if (!userId) return; - // Connect to WebSocket - const ws = new WebSocket(`ws://localhost:8000/ws/brain/${userId}`); - wsRef.current = ws; - - ws.onopen = () => { - setLogs((prev) => [ - ...prev, - { - id: `sys-${Date.now()}`, - timestamp: new Date(), - type: "success", - message: "Connected to Live Brain", - agent: "System", - }, - ]); - }; + let ws: WebSocket | null = null; + let reconnectTimeout: NodeJS.Timeout; + + const connect = () => { + // Connect to WebSocket + ws = new WebSocket(`ws://localhost:8000/ws/brain/${userId}`); + wsRef.current = ws; + + ws.onopen = () => { + setLogs((prev) => [ + ...prev, + { + id: `sys-${Date.now()}`, + timestamp: new Date(), + type: "success", + message: "Connected to Live Brain", + agent: "System", + }, + ]); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // Handle stats update without adding to logs + if (data.type === 'stats_update' && data.stats) { + setStats(data.stats); + return; + } - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - setLogs((prev) => - [ - ...prev, - { - id: `log-${Date.now()}`, - timestamp: new Date(), - type: (data.type || "action") as LogEntry["type"], - message: data.message || event.data, - agent: data.agent || "Agent", - }, - ].slice(-30) - ); - if (data.stats) { - setStats(data.stats); + setLogs((prev) => + [ + ...prev, + { + id: `log-${Date.now()}`, + timestamp: new Date(), + type: (data.type || "action") as LogEntry["type"], + message: data.message || event.data, + agent: data.agent || "Agent", + }, + ].slice(-30) + ); + } catch { + // Plain text message + setLogs((prev) => + [ + ...prev, + { + id: `log-${Date.now()}`, + timestamp: new Date(), + type: "action" as LogEntry["type"], + message: event.data, + agent: "Agent", + }, + ].slice(-30) + ); } - } catch { - // Plain text message - setLogs((prev) => - [ - ...prev, - { - id: `log-${Date.now()}`, - timestamp: new Date(), - type: "action" as LogEntry["type"], - message: event.data, - agent: "Agent", - }, - ].slice(-30) - ); - } - }; + }; - ws.onerror = () => { - setLogs((prev) => [ - ...prev, - { - id: `err-${Date.now()}`, - timestamp: new Date(), - type: "error", - message: "WebSocket connection error", - agent: "System", - }, - ]); - }; + ws.onerror = () => { + setLogs((prev) => [ + ...prev, + { + id: `err-${Date.now()}`, + timestamp: new Date(), + type: "error", + message: "WebSocket connection error", + agent: "System", + }, + ]); + }; - ws.onclose = () => { - setLogs((prev) => [ - ...prev, - { - id: `close-${Date.now()}`, - timestamp: new Date(), - type: "thinking", - message: "Disconnected from Live Brain", - agent: "System", - }, - ]); + ws.onclose = () => { + setLogs((prev) => [ + ...prev, + { + id: `close-${Date.now()}`, + timestamp: new Date(), + type: "thinking", + message: "Disconnected. Reconnecting in 3s...", + agent: "System", + }, + ]); + + // Attempt reconnect after 3 seconds + reconnectTimeout = setTimeout(connect, 3000); + }; }; + connect(); + return () => { - ws.close(); + if (ws) { + ws.close(); + } + clearTimeout(reconnectTimeout); }; }, [userId]); @@ -135,10 +192,37 @@ export function LiveBrainSidebar({ isOpen, onToggle }: LiveBrainSidebarProps) { {/* Sidebar */}