From e135c3258b48cc94c6b15ed19dfe3dd681853a1d Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Wed, 17 Dec 2025 15:45:16 +0100 Subject: [PATCH] Remove chat companion, add LLM naming for Trends & Forge tabs (AI keyword expansion, concept generator) --- backend/app/api/__init__.py | 4 +- backend/app/api/llm_agent.py | 121 --- backend/app/api/llm_naming.py | 171 +++ backend/app/services/hunter_companion.py | 523 ---------- backend/app/services/llm_agent.py | 293 ------ backend/app/services/llm_naming.py | 179 ++++ backend/app/services/llm_tools.py | 981 ------------------ frontend/src/app/terminal/layout.tsx | 2 - .../src/components/chat/HunterCompanion.tsx | 321 ------ .../src/components/hunt/BrandableForgeTab.tsx | 218 +++- frontend/src/components/hunt/SearchTab.tsx | 269 ++++- .../src/components/hunt/TrendSurferTab.tsx | 107 +- frontend/src/lib/api.ts | 29 + 13 files changed, 950 insertions(+), 2268 deletions(-) delete mode 100644 backend/app/api/llm_agent.py create mode 100644 backend/app/api/llm_naming.py delete mode 100644 backend/app/services/hunter_companion.py delete mode 100644 backend/app/services/llm_agent.py create mode 100644 backend/app/services/llm_naming.py delete mode 100644 backend/app/services/llm_tools.py delete mode 100644 frontend/src/components/chat/HunterCompanion.tsx diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index de0b209..4830050 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -28,7 +28,7 @@ from app.api.hunt import router as hunt_router from app.api.cfo import router as cfo_router from app.api.drops import router as drops_router from app.api.llm import router as llm_router -from app.api.llm_agent import router as llm_agent_router +from app.api.llm_naming import router as llm_naming_router api_router = APIRouter() @@ -48,7 +48,7 @@ api_router.include_router(hunt_router, prefix="/hunt", tags=["Hunt"]) api_router.include_router(cfo_router, prefix="/cfo", tags=["CFO"]) api_router.include_router(drops_router, tags=["Drops - Zone Files"]) api_router.include_router(llm_router, tags=["LLM"]) -api_router.include_router(llm_agent_router, tags=["LLM"]) +api_router.include_router(llm_naming_router, tags=["LLM Naming"]) # Marketplace (For Sale) - from analysis_3.md api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"]) diff --git a/backend/app/api/llm_agent.py b/backend/app/api/llm_agent.py deleted file mode 100644 index c130cc4..0000000 --- a/backend/app/api/llm_agent.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Hunter Companion API Endpoint - -This is the main endpoint for the Hunter Companion chat. -Uses code-first architecture: intent detection via pattern matching, -tool execution, and template-based responses. LLM is NOT used for routing. -""" - -from __future__ import annotations - -import json -from typing import Any, Literal, Optional - -from fastapi import APIRouter, Depends, HTTPException, Request, status -from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.deps import get_current_user -from app.database import get_db -from app.models.subscription import Subscription -from app.models.user import User -from app.services.hunter_companion import process_message - - -router = APIRouter(prefix="/llm", tags=["LLM"]) - - -def _tier_level(tier: str) -> int: - t = (tier or "").lower() - if t == "tycoon": - return 3 - if t == "trader": - return 2 - return 1 - - -async def _get_user_tier(db: AsyncSession, user: User) -> str: - res = await db.execute(select(Subscription).where(Subscription.user_id == user.id)) - sub = res.scalar_one_or_none() - if not sub: - return "scout" - return sub.tier.value - - -class ChatMessage(BaseModel): - role: Literal["system", "user", "assistant"] - content: str - - -class AgentRequest(BaseModel): - messages: list[ChatMessage] = Field(default_factory=list, min_length=1) - path: str = Field(default="/terminal/hunt") - model: Optional[str] = None - temperature: float = Field(default=0.3, ge=0.0, le=2.0) - stream: bool = True - - -async def _generate_sse_response(content: str): - """Generate SSE-formatted response chunks.""" - # Split content into chunks for streaming effect - chunk_size = 20 - for i in range(0, len(content), chunk_size): - chunk = content[i:i + chunk_size] - data = {"choices": [{"delta": {"content": chunk}}]} - yield f"data: {json.dumps(data)}\n\n".encode() - yield b"data: [DONE]\n\n" - - -@router.post("/agent") -async def hunter_companion_chat( - payload: AgentRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - Hunter Companion Chat Endpoint - - - Trader/Tycoon: Full access to all features - - Scout: Blocked (403) - """ - # Check tier - tier = await _get_user_tier(db, current_user) - if _tier_level(tier) < 2: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Hunter Companion requires Trader or Tycoon plan." - ) - - # Get the last user message - user_messages = [m for m in payload.messages if m.role == "user"] - if not user_messages: - raise HTTPException(status_code=400, detail="No user message provided") - - last_message = user_messages[-1].content - - # Process the message (code-first, no LLM for routing) - try: - response = await process_message( - db=db, - user=current_user, - message=last_message, - path=payload.path, - ) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Processing failed: {type(e).__name__}: {e}" - ) - - # Return as SSE stream (for frontend compatibility) - if payload.stream: - return StreamingResponse( - _generate_sse_response(response), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, - ) - - # Non-stream response - return {"content": response} diff --git a/backend/app/api/llm_naming.py b/backend/app/api/llm_naming.py new file mode 100644 index 0000000..56aa4d3 --- /dev/null +++ b/backend/app/api/llm_naming.py @@ -0,0 +1,171 @@ +""" +API endpoints for LLM-powered naming features. +Used by Trends and Forge tabs in the Hunt page. +""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.database import get_db +from app.models.subscription import Subscription, SubscriptionTier +from app.models.user import User +from app.services.llm_naming import ( + expand_trend_keywords, + analyze_trend, + generate_brandable_names, + generate_similar_names, +) + + +router = APIRouter(prefix="/naming", tags=["LLM Naming"]) + + +def _tier_level(tier: str) -> int: + t = (tier or "").lower() + if t == "tycoon": + return 3 + if t == "trader": + return 2 + return 1 + + +async def _get_user_tier(db: AsyncSession, user: User) -> str: + res = await db.execute(select(Subscription).where(Subscription.user_id == user.id)) + sub = res.scalar_one_or_none() + if not sub: + return "scout" + return sub.tier.value + + +async def _require_trader_or_above(db: AsyncSession, user: User): + """Check that user has at least Trader tier.""" + tier = await _get_user_tier(db, user) + if _tier_level(tier) < 2: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="AI naming features require Trader or Tycoon plan." + ) + + +# ============================================================================ +# TRENDS TAB ENDPOINTS +# ============================================================================ + +class TrendExpandRequest(BaseModel): + trend: str = Field(..., min_length=1, max_length=100) + geo: str = Field(default="US", max_length=5) + + +class TrendExpandResponse(BaseModel): + keywords: list[str] + trend: str + + +@router.post("/trends/expand", response_model=TrendExpandResponse) +async def expand_trend( + request: TrendExpandRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Expand a trending topic into related domain-friendly keywords. + Requires Trader or Tycoon subscription. + """ + await _require_trader_or_above(db, current_user) + + keywords = await expand_trend_keywords(request.trend, request.geo) + return TrendExpandResponse(keywords=keywords, trend=request.trend) + + +class TrendAnalyzeRequest(BaseModel): + trend: str = Field(..., min_length=1, max_length=100) + geo: str = Field(default="US", max_length=5) + + +class TrendAnalyzeResponse(BaseModel): + analysis: str + trend: str + + +@router.post("/trends/analyze", response_model=TrendAnalyzeResponse) +async def analyze_trend_endpoint( + request: TrendAnalyzeRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Get AI analysis of a trending topic for domain investors. + Requires Trader or Tycoon subscription. + """ + await _require_trader_or_above(db, current_user) + + analysis = await analyze_trend(request.trend, request.geo) + return TrendAnalyzeResponse(analysis=analysis, trend=request.trend) + + +# ============================================================================ +# FORGE TAB ENDPOINTS +# ============================================================================ + +class BrandableGenerateRequest(BaseModel): + concept: str = Field(..., min_length=3, max_length=200) + style: Optional[str] = Field(default=None, max_length=50) + count: int = Field(default=15, ge=5, le=30) + + +class BrandableGenerateResponse(BaseModel): + names: list[str] + concept: str + + +@router.post("/forge/generate", response_model=BrandableGenerateResponse) +async def generate_brandables( + request: BrandableGenerateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Generate brandable domain names based on a concept description. + Requires Trader or Tycoon subscription. + """ + await _require_trader_or_above(db, current_user) + + names = await generate_brandable_names( + request.concept, + style=request.style, + count=request.count + ) + return BrandableGenerateResponse(names=names, concept=request.concept) + + +class SimilarNamesRequest(BaseModel): + brand: str = Field(..., min_length=2, max_length=50) + count: int = Field(default=12, ge=5, le=20) + + +class SimilarNamesResponse(BaseModel): + names: list[str] + brand: str + + +@router.post("/forge/similar", response_model=SimilarNamesResponse) +async def generate_similar( + request: SimilarNamesRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Generate names similar to an existing brand. + Requires Trader or Tycoon subscription. + """ + await _require_trader_or_above(db, current_user) + + names = await generate_similar_names(request.brand, count=request.count) + return SimilarNamesResponse(names=names, brand=request.brand) + diff --git a/backend/app/services/hunter_companion.py b/backend/app/services/hunter_companion.py deleted file mode 100644 index f3163e5..0000000 --- a/backend/app/services/hunter_companion.py +++ /dev/null @@ -1,523 +0,0 @@ -""" -Hunter Companion - Code-First Architecture - -This module handles the Hunter Companion chat WITHOUT relying on LLM for intent detection. -All routing is done via code (regex, keywords). LLM is only used as a last resort. -""" -from __future__ import annotations - -import re -from dataclasses import dataclass -from enum import Enum -from typing import Any, Optional - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.user import User -from app.services.llm_tools import execute_tool - - -class Intent(Enum): - GREETING = "greeting" - HELP = "help" - ANALYZE_DOMAIN = "analyze_domain" - WATCHLIST = "watchlist" - PORTFOLIO = "portfolio" - AUCTIONS = "auctions" - DROPS = "drops" - LISTINGS = "listings" - INBOX = "inbox" - SNIPER = "sniper" - YIELD = "yield" - UNCLEAR = "unclear" - - -@dataclass -class DetectedIntent: - intent: Intent - params: dict[str, Any] - confidence: float - - -# ============================================================================ -# INTENT DETECTION (Pure Code - No LLM) -# ============================================================================ - -def detect_intent(text: str) -> DetectedIntent: - """Detect user intent using pattern matching. No LLM involved.""" - t = (text or "").strip().lower() - - # 1. Greeting - if _is_greeting(t): - return DetectedIntent(Intent.GREETING, {}, 1.0) - - # 2. Help / Capabilities - if _is_help(t): - return DetectedIntent(Intent.HELP, {}, 1.0) - - # 3. Domain Analysis (detect domain in text) - domain = _extract_domain(text) - if domain: - return DetectedIntent(Intent.ANALYZE_DOMAIN, {"domain": domain}, 0.95) - - # 4. Watchlist queries - if _matches_keywords(t, ["watchlist", "tracking", "monitored", "watching"]): - return DetectedIntent(Intent.WATCHLIST, {}, 0.9) - - # 5. Portfolio queries - if _matches_keywords(t, ["portfolio", "my domains", "owned", "roi", "investment", "value"]): - return DetectedIntent(Intent.PORTFOLIO, {}, 0.9) - - # 6. Auction queries - if _matches_keywords(t, ["auction", "ending", "deals", "bid", "expir"]): - hours = _extract_hours(t) - return DetectedIntent(Intent.AUCTIONS, {"hours": hours}, 0.9) - - # 7. Drops - if _matches_keywords(t, ["drop", "dropped", "zone file", "expired"]): - return DetectedIntent(Intent.DROPS, {}, 0.9) - - # 8. Listings / For Sale - if _matches_keywords(t, ["listing", "for sale", "selling", "leads"]): - return DetectedIntent(Intent.LISTINGS, {}, 0.9) - - # 9. Inbox - if _matches_keywords(t, ["inbox", "message", "unread", "conversation"]): - return DetectedIntent(Intent.INBOX, {}, 0.9) - - # 10. Sniper alerts - if _matches_keywords(t, ["sniper", "alert", "notification", "filter"]): - return DetectedIntent(Intent.SNIPER, {}, 0.9) - - # 11. Yield - if _matches_keywords(t, ["yield", "earning", "revenue", "monetiz"]): - return DetectedIntent(Intent.YIELD, {}, 0.9) - - # Fallback: unclear intent - return DetectedIntent(Intent.UNCLEAR, {"original": text}, 0.3) - - -def _is_greeting(t: str) -> bool: - greetings = {"hi", "hello", "hey", "yo", "sup", "hola", "hallo", "moin", "servus"} - clean = re.sub(r'[!?.,]', '', t).strip() - return clean in greetings or (len(clean) <= 10 and any(g in clean for g in greetings)) - - -def _is_help(t: str) -> bool: - patterns = [ - "what can you do", - "what do you do", - "help", - "how can you help", - "capabilities", - "features", - "was kannst du", - "hilfe", - ] - return any(p in t for p in patterns) - - -def _matches_keywords(t: str, keywords: list[str]) -> bool: - return any(kw in t for kw in keywords) - - -def _extract_domain(text: str) -> Optional[str]: - """Extract a domain from text using regex.""" - # Match patterns like: example.com, sub.example.co.uk, etc. - pattern = r'\b([a-zA-Z0-9][-a-zA-Z0-9]*\.)+[a-zA-Z]{2,}\b' - matches = re.findall(pattern, text) - - if not matches: - return None - - # Reconstruct full domain from the match - full_match = re.search(pattern, text) - if full_match: - domain = full_match.group(0).lower() - # Filter out common non-domains - if domain not in {"example.com", "test.com", "domain.com", "e.g.", "i.e."}: - return domain - return None - - -def _extract_hours(t: str) -> int: - """Extract hours from text like '2 hours' or 'next 4h'.""" - match = re.search(r'(\d+)\s*(?:hour|h\b|hr)', t) - if match: - return min(int(match.group(1)), 168) # Max 1 week - return 24 # Default - - -# ============================================================================ -# RESPONSE HANDLERS (No LLM - Template Based) -# ============================================================================ - -RESPONSES = { - Intent.GREETING: """Hey! I'm your Hunter Companion. - -I can help you with: -• Analyze any domain - just type it -• Show auctions ending soon -• Check your watchlist or portfolio -• Find recently dropped domains - -What would you like to do?""", - - Intent.HELP: """Here's what I can do: - -• Domain Analysis - Type any domain (e.g., "startup.io") -• Auctions - "show auctions" or "ending soon" -• Watchlist - "show my watchlist" -• Portfolio - "portfolio summary" or "my ROI" -• Drops - "show drops" or "dropped domains" -• Listings - "my listings" or "leads" - -Just ask naturally or type a domain to analyze!""", - - Intent.UNCLEAR: """I'm not sure what you're looking for. - -Try one of these: -• Type a domain to analyze (e.g., "crypto.io") -• "show auctions ending soon" -• "my watchlist" -• "portfolio summary" - -What would you like to do?""", -} - - -async def handle_intent( - db: AsyncSession, - user: User, - detected: DetectedIntent, - path: str, -) -> str: - """Handle the detected intent and return a response.""" - - # Static responses - if detected.intent in RESPONSES: - return RESPONSES[detected.intent] - - # Dynamic responses (tool-based) - try: - if detected.intent == Intent.ANALYZE_DOMAIN: - return await _handle_analyze(db, user, detected.params.get("domain", ""), path) - - if detected.intent == Intent.WATCHLIST: - return await _handle_watchlist(db, user, path) - - if detected.intent == Intent.PORTFOLIO: - return await _handle_portfolio(db, user, path) - - if detected.intent == Intent.AUCTIONS: - return await _handle_auctions(db, user, detected.params.get("hours", 24), path) - - if detected.intent == Intent.DROPS: - return await _handle_drops(db, user, path) - - if detected.intent == Intent.LISTINGS: - return await _handle_listings(db, user, path) - - if detected.intent == Intent.INBOX: - return await _handle_inbox(db, user, path) - - if detected.intent == Intent.SNIPER: - return await _handle_sniper(db, user, path) - - if detected.intent == Intent.YIELD: - return await _handle_yield(db, user, path) - - except Exception as e: - return f"Sorry, something went wrong: {str(e)}" - - return RESPONSES[Intent.UNCLEAR] - - -async def _handle_analyze(db: AsyncSession, user: User, domain: str, path: str) -> str: - """Handle domain analysis.""" - if not domain: - return "Please provide a domain to analyze." - - result = await execute_tool(db, user, "analyze_domain", {"domain": domain}, path=path) - - if "error" in result: - return f"Could not analyze {domain}: {result['error']}" - - # Format the analysis result - score = result.get("pounce_score", 0) - - # Determine recommendation - if score >= 70: - rec = "BUY" - rec_emoji = "✓" - elif score >= 50: - rec = "CONSIDER" - rec_emoji = "~" - else: - rec = "SKIP" - rec_emoji = "✗" - - lines = [ - f"Analysis: {domain}", - f"", - f"Pounce Score: {score}/100", - f"Recommendation: {rec_emoji} {rec}", - ] - - # Availability - avail = result.get("availability", {}) - if avail: - status = "Available" if avail.get("is_available") else "Taken" - lines.append(f"Status: {status}") - - # Value estimate - value = result.get("value", {}) - if value and value.get("estimated_value"): - lines.append(f"Est. Value: ${value['estimated_value']:,.0f}") - - # Risk - risk = result.get("risk", {}) - if risk and risk.get("risk_level"): - lines.append(f"Risk: {risk['risk_level']}") - - # Radio test - radio = result.get("radio_test", {}) - if radio and radio.get("score") is not None: - lines.append(f"Radio Test: {radio['score']}/100") - - return "\n".join(lines) - - -async def _handle_watchlist(db: AsyncSession, user: User, path: str) -> str: - """Handle watchlist query.""" - result = await execute_tool(db, user, "list_watchlist", {"per_page": 10}, path=path) - - if "error" in result: - return f"Could not load watchlist: {result['error']}" - - domains = result.get("domains", []) - total = result.get("total", 0) - - if not domains: - return "Your watchlist is empty.\n\nAdd domains from the Hunt page to start tracking." - - lines = [f"Your Watchlist ({total} domains)", ""] - - for d in domains[:10]: - status = "✓" if d.get("is_available") else "•" - lines.append(f"{status} {d.get('name', 'unknown')}") - - if total > 10: - lines.append(f"\n... and {total - 10} more") - - return "\n".join(lines) - - -async def _handle_portfolio(db: AsyncSession, user: User, path: str) -> str: - """Handle portfolio query.""" - result = await execute_tool(db, user, "portfolio_summary", {}, path=path) - - if "error" in result: - return f"Could not load portfolio: {result['error']}" - - total = result.get("total_domains", 0) - - if total == 0: - return "Your portfolio is empty.\n\nAdd domains you own to track ROI and renewals." - - lines = [ - f"Portfolio Summary", - "", - f"Total Domains: {total}", - f"Active: {result.get('active_domains', 0)}", - f"Sold: {result.get('sold_domains', 0)}", - "", - f"Total Cost: ${result.get('total_cost', 0):,.0f}", - f"Est. Value: ${result.get('total_estimated_value', 0):,.0f}", - ] - - roi = result.get("overall_roi_percent") - if roi is not None: - sign = "+" if roi >= 0 else "" - lines.append(f"ROI: {sign}{roi:.1f}%") - - return "\n".join(lines) - - -async def _handle_auctions(db: AsyncSession, user: User, hours: int, path: str) -> str: - """Handle auctions query.""" - result = await execute_tool( - db, user, "market_feed", - {"ending_within_hours": hours, "limit": 10, "sort_by": "time"}, - path=path - ) - - if "error" in result: - return f"Could not load auctions: {result['error']}" - - items = result.get("items", []) - - if not items: - return f"No auctions ending in the next {hours} hours." - - lines = [f"Auctions Ending Soon ({len(items)} found)", ""] - - for a in items[:10]: - domain = a.get("domain", "unknown") - bid = a.get("current_bid", 0) - platform = a.get("platform", "") - lines.append(f"• {domain} - ${bid:.0f} ({platform})") - - return "\n".join(lines) - - -async def _handle_drops(db: AsyncSession, user: User, path: str) -> str: - """Handle drops query.""" - result = await execute_tool( - db, user, "get_drops", - {"hours": 24, "limit": 15}, - path=path - ) - - if "error" in result: - return f"Could not load drops: {result['error']}" - - domains = result.get("domains", []) - total = result.get("total", 0) - - if not domains: - return "No recently dropped domains found." - - lines = [f"Recently Dropped ({total} total)", ""] - - for d in domains[:15]: - name = d if isinstance(d, str) else d.get("domain", "unknown") - lines.append(f"• {name}") - - if total > 15: - lines.append(f"\n... and {total - 15} more") - - return "\n".join(lines) - - -async def _handle_listings(db: AsyncSession, user: User, path: str) -> str: - """Handle listings query.""" - result = await execute_tool(db, user, "list_my_listings", {"limit": 10}, path=path) - - if "error" in result: - return f"Could not load listings: {result['error']}" - - items = result.get("items", []) - - if not items: - return "You have no active listings.\n\nGo to For Sale to create your first listing." - - lines = [f"Your Listings ({len(items)})", ""] - - for l in items[:10]: - domain = l.get("domain", "unknown") - status = l.get("status", "") - price = l.get("asking_price") - price_str = f"${price:,.0f}" if price else "Make Offer" - lines.append(f"• {domain} - {price_str} [{status}]") - - return "\n".join(lines) - - -async def _handle_inbox(db: AsyncSession, user: User, path: str) -> str: - """Handle inbox query.""" - result = await execute_tool(db, user, "get_inbox_counts", {}, path=path) - - if "error" in result: - return f"Could not load inbox: {result['error']}" - - buyer = result.get("buyer_unread", 0) - seller = result.get("seller_unread", 0) - total = buyer + seller - - if total == 0: - return "No unread messages.\n\nYour inbox is all caught up!" - - lines = [ - f"Inbox Summary", - "", - f"Unread: {total}", - f"• As Buyer: {buyer}", - f"• As Seller: {seller}", - "", - "Go to Inbox to view conversations.", - ] - - return "\n".join(lines) - - -async def _handle_sniper(db: AsyncSession, user: User, path: str) -> str: - """Handle sniper alerts query.""" - result = await execute_tool(db, user, "list_sniper_alerts", {"limit": 10}, path=path) - - if "error" in result: - return f"Could not load alerts: {result['error']}" - - items = result.get("items", []) - - if not items: - return "No sniper alerts configured.\n\nGo to Sniper to create custom alerts." - - active = sum(1 for a in items if a.get("is_active")) - - lines = [f"Sniper Alerts ({active} active)", ""] - - for a in items[:10]: - name = a.get("name", "Unnamed") - status = "✓" if a.get("is_active") else "○" - lines.append(f"{status} {name}") - - return "\n".join(lines) - - -async def _handle_yield(db: AsyncSession, user: User, path: str) -> str: - """Handle yield query.""" - result = await execute_tool(db, user, "yield_dashboard", {}, path=path) - - if "error" in result: - return f"Could not load yield data: {result['error']}" - - stats = result.get("stats", {}) - - if stats.get("total_domains", 0) == 0: - return "No yield domains configured.\n\nGo to Yield to activate monetization." - - lines = [ - "Yield Summary", - "", - f"Active Domains: {stats.get('active_domains', 0)}", - f"Monthly Revenue: ${stats.get('monthly_revenue', 0):.2f}", - f"Monthly Clicks: {stats.get('monthly_clicks', 0)}", - f"Conversions: {stats.get('monthly_conversions', 0)}", - ] - - return "\n".join(lines) - - -# ============================================================================ -# MAIN ENTRY POINT -# ============================================================================ - -async def process_message( - db: AsyncSession, - user: User, - message: str, - path: str, -) -> str: - """ - Process a user message and return a response. - This is the main entry point for the Hunter Companion. - """ - # Step 1: Detect intent (pure code, no LLM) - detected = detect_intent(message) - - # Step 2: Handle the intent and generate response - response = await handle_intent(db, user, detected, path) - - return response - diff --git a/backend/app/services/llm_agent.py b/backend/app/services/llm_agent.py deleted file mode 100644 index dd745f8..0000000 --- a/backend/app/services/llm_agent.py +++ /dev/null @@ -1,293 +0,0 @@ -""" -Hunter Companion Agent - Simplified and Controlled -""" -from __future__ import annotations - -import json -import re -from typing import Any, AsyncIterator, Optional - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import get_settings -from app.models.subscription import Subscription -from app.models.user import User -from app.services.llm_gateway import chat_completions, chat_completions_stream -from app.services.llm_tools import execute_tool, tool_catalog_for_prompt - -settings = get_settings() - - -def _tier_level(tier: str) -> int: - t = (tier or "").lower() - if t == "tycoon": - return 3 - if t == "trader": - return 2 - return 1 - - -async def _get_user_tier(db: AsyncSession, user: User) -> str: - from sqlalchemy import select - res = await db.execute(select(Subscription).where(Subscription.user_id == user.id)) - sub = res.scalar_one_or_none() - if not sub: - return "scout" - return sub.tier.value - - -# Pre-defined responses for common queries (bypass LLM) -CANNED_RESPONSES = { - "greeting": ( - "Hey! I'm your Hunter Companion.\n\n" - "I can help you with:\n" - "- Analyzing domains (just tell me the domain)\n" - "- Showing auctions ending soon\n" - "- Checking your watchlist or portfolio\n" - "- Finding dropped domains\n\n" - "What would you like to do?" - ), - "capabilities": ( - "Here's what I can do:\n\n" - "- Analyze any domain (Pounce Score, risk, value)\n" - "- Show current auctions and deals\n" - "- List recently dropped domains\n" - "- Check your watchlist status\n" - "- Review your portfolio performance\n" - "- Show your listings and leads\n\n" - "Just ask! For domain analysis, simply type the domain name." - ), -} - - -def _is_greeting(text: str) -> bool: - t = (text or "").strip().lower() - greetings = {"hi", "hello", "hey", "yo", "sup", "hola", "hallo", "guten tag", "moin"} - clean = t.replace("!", "").replace(".", "").replace("?", "") - return clean in greetings or len(clean) <= 3 and clean in greetings - - -def _is_capabilities_question(text: str) -> bool: - t = (text or "").strip().lower() - patterns = [ - "what can you do", - "what do you do", - "help", - "how can you help", - "what are you", - "who are you", - "capabilities", - "features", - "was kannst du", - "was machst du", - "hilfe", - ] - return any(p in t for p in patterns) - - -def _extract_domain(text: str) -> Optional[str]: - """Extract a domain name from user text.""" - t = (text or "").strip().lower() - # Pattern: word.tld or word.word.tld - pattern = r'\b([a-z0-9][-a-z0-9]*\.)+[a-z]{2,}\b' - match = re.search(pattern, t) - if match: - domain = match.group(0) - # Filter out common non-domains - if domain not in {"example.com", "test.com", "domain.com"}: - return domain - return None - - -def _build_system_prompt(path: str) -> str: - tools = tool_catalog_for_prompt(path) - return ( - "You are a domain trading assistant. Be brief and helpful.\n\n" - "RULES:\n" - "- Give SHORT answers (2-3 sentences max)\n" - "- Do NOT make up data. Only state facts from tool results.\n" - "- Do NOT format with markdown (no ** or *)\n" - "- If unsure, ask the user to clarify\n\n" - "TOOLS (call with JSON):\n" - f"{json.dumps([{'name': t['name'], 'description': t['description']} for t in tools], indent=2)}\n\n" - "To call a tool: {\"tool_calls\":[{\"name\":\"...\",\"args\":{}}]}" - ) - - -def _try_parse_tool_calls(text: str) -> Optional[list[dict[str, Any]]]: - t = (text or "").strip() - if not (t.startswith("{") and "tool_calls" in t): - return None - try: - obj = json.loads(t) - except Exception: - return None - calls = obj.get("tool_calls") - if not isinstance(calls, list): - return None - out = [] - for c in calls: - if isinstance(c, dict) and isinstance(c.get("name"), str): - out.append({"name": c["name"], "args": c.get("args") or {}}) - return out or None - - -def _truncate_json(value: Any, max_chars: int = 6000) -> str: - s = json.dumps(value, ensure_ascii=False) - return s[:max_chars] if len(s) > max_chars else s - - -def _format_analysis_result(data: dict) -> str: - """Format domain analysis result into readable text.""" - if "error" in data: - return f"Could not analyze: {data['error']}" - - domain = data.get("domain", "unknown") - score = data.get("pounce_score", 0) - - # Determine recommendation - if score >= 70: - rec = "BUY" - rec_reason = "Strong domain with good fundamentals" - elif score >= 50: - rec = "CONSIDER" - rec_reason = "Decent potential, evaluate based on your needs" - else: - rec = "SKIP" - rec_reason = "Limited potential or high risk" - - lines = [ - f"Domain: {domain}", - f"Pounce Score: {score}/100", - f"Recommendation: {rec}", - "", - ] - - # Add key metrics - if data.get("availability"): - avail = data["availability"] - status = "Available" if avail.get("is_available") else "Taken" - lines.append(f"Status: {status}") - - if data.get("value"): - val = data["value"] - if val.get("estimated_value"): - lines.append(f"Est. Value: ${val['estimated_value']:,.0f}") - - if data.get("risk"): - risk = data["risk"] - risk_level = risk.get("risk_level", "unknown") - lines.append(f"Risk: {risk_level}") - - lines.append("") - lines.append(rec_reason) - - return "\n".join(lines) - - -async def run_agent( - db: AsyncSession, - user: User, - *, - messages: list[dict[str, Any]], - path: str, - model: Optional[str] = None, - temperature: float = 0.3, - max_steps: int = 4, -) -> list[dict[str, Any]]: - """Run the agent with simplified logic.""" - tier = await _get_user_tier(db, user) - if _tier_level(tier) < 2: - raise PermissionError("Hunter Companion requires Trader or Tycoon plan.") - - # Get last user message - last_user = next((m for m in reversed(messages or []) if m.get("role") == "user"), None) - user_text = str(last_user.get("content", "")) if last_user else "" - - base = [ - {"role": "system", "content": _build_system_prompt(path)}, - ] - - # Handle canned responses (bypass LLM entirely) - if _is_greeting(user_text): - return base + messages + [{"role": "assistant", "content": CANNED_RESPONSES["greeting"]}] - - if _is_capabilities_question(user_text): - return base + messages + [{"role": "assistant", "content": CANNED_RESPONSES["capabilities"]}] - - # Auto-detect domain and analyze - domain = _extract_domain(user_text) - if domain: - # Directly call analyze_domain tool - result = await execute_tool(db, user, "analyze_domain", {"domain": domain}, path=path) - formatted = _format_analysis_result(result) - return base + messages + [{"role": "assistant", "content": formatted}] - - # For other queries, use LLM with tool loop - convo = base + (messages or []) - - for _ in range(max_steps): - payload = { - "model": model or settings.llm_default_model, - "messages": convo, - "temperature": temperature, - "stream": False, - } - res = await chat_completions(payload) - content = (res.get("choices") or [{}])[0].get("message", {}).get("content", "") or "" - - tool_calls = _try_parse_tool_calls(content) - if not tool_calls: - convo.append({"role": "assistant", "content": content}) - return convo - - convo.append({"role": "assistant", "content": content}) - - for call in tool_calls[:3]: - name = call["name"] - args = call["args"] - result = await execute_tool(db, user, name, args, path=path) - - # Format specific tool results - if name == "analyze_domain": - formatted = _format_analysis_result(result) - convo.append({"role": "system", "content": f"Tool result:\n{formatted}"}) - else: - convo.append({"role": "system", "content": f"Tool {name} result: {_truncate_json(result)}"}) - - return convo - - -async def stream_final_answer( - convo: list[dict[str, Any]], - *, - model: Optional[str], - temperature: float -) -> AsyncIterator[bytes]: - """Stream the final answer.""" - # Check if last message is already a complete assistant response - if convo and convo[-1].get("role") == "assistant": - content = convo[-1].get("content", "") - if content and not content.strip().startswith("{"): - # Already have a good response, stream it directly - chunk = { - "choices": [{"delta": {"content": content}}] - } - yield f"data: {json.dumps(chunk)}\n\n".encode() - yield b"data: [DONE]\n\n" - return - - # Otherwise, ask LLM to summarize - payload = { - "model": model or settings.llm_default_model, - "messages": convo + [ - { - "role": "system", - "content": "Give a brief, helpful response based on the data. No markdown formatting. Keep it short.", - } - ], - "temperature": temperature, - "stream": True, - } - async for chunk in chat_completions_stream(payload): - yield chunk diff --git a/backend/app/services/llm_naming.py b/backend/app/services/llm_naming.py new file mode 100644 index 0000000..53cea36 --- /dev/null +++ b/backend/app/services/llm_naming.py @@ -0,0 +1,179 @@ +""" +LLM-powered naming suggestions for Trends and Forge tabs. +Uses simple prompts for focused tasks - no complex agent loop. +""" +from __future__ import annotations + +import json +import re +from typing import Optional + +from app.config import get_settings +from app.services.llm_gateway import chat_completions + +settings = get_settings() + + +async def expand_trend_keywords(trend: str, geo: str = "US") -> list[str]: + """ + Given a trending topic, generate related domain-friendly keywords. + Returns a list of 5-10 short, brandable keywords. + """ + prompt = f"""You are a domain naming expert. Given the trending topic "{trend}" (trending in {geo}), +suggest 8-10 short, memorable keywords that would make good domain names. + +Rules: +- Each keyword should be 4-10 characters +- No spaces, hyphens, or special characters +- Mix of: related words, abbreviations, creative variations +- Think like a domain investor looking for valuable names + +Return ONLY a JSON array of lowercase strings, nothing else. +Example: ["swiftie", "erastour", "taylormerch", "tswift"]""" + + try: + res = await chat_completions({ + "model": settings.llm_default_model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.8, + "stream": False, + }) + content = res.get("choices", [{}])[0].get("message", {}).get("content", "") + + # Extract JSON array from response + match = re.search(r'\[.*?\]', content, re.DOTALL) + if match: + keywords = json.loads(match.group(0)) + # Filter and clean + return [ + kw.lower().strip()[:15] + for kw in keywords + if isinstance(kw, str) and 3 <= len(kw.strip()) <= 15 and kw.isalnum() + ][:10] + except Exception as e: + print(f"LLM keyword expansion failed: {e}") + + return [] + + +async def analyze_trend(trend: str, geo: str = "US") -> str: + """ + Provide a brief analysis of why a trend is relevant for domain investors. + Returns 2-3 sentences max. + """ + prompt = f"""You are a domain investing analyst. The topic "{trend}" is currently trending in {geo}. + +In 2-3 short sentences, explain: +1. Why this is trending (if obvious) +2. What domain opportunity this presents + +Be concise and actionable. No fluff.""" + + try: + res = await chat_completions({ + "model": settings.llm_default_model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.5, + "stream": False, + }) + content = res.get("choices", [{}])[0].get("message", {}).get("content", "") + # Clean up and limit length + content = content.strip()[:500] + return content + except Exception as e: + print(f"LLM trend analysis failed: {e}") + + return "" + + +async def generate_brandable_names( + concept: str, + style: Optional[str] = None, + count: int = 15 +) -> list[str]: + """ + Generate brandable domain names based on a concept description. + + Args: + concept: Description like "AI startup for legal documents" + style: Optional style hint like "professional", "playful", "tech" + count: Number of names to generate + + Returns list of brandable name suggestions (without TLD). + """ + style_hint = f" The style should be {style}." if style else "" + + prompt = f"""You are an expert brand naming consultant. Generate {count} unique, brandable domain names for: "{concept}"{style_hint} + +Rules: +- Names must be 4-8 characters (shorter is better) +- Easy to spell and pronounce +- Memorable and unique +- No dictionary words (invented names only) +- Mix of patterns: CVCVC (Zalor), CVCCV (Bento), short words (Lyft) + +Return ONLY a JSON array of lowercase strings, nothing else. +Example: ["zenix", "klaro", "voxly", "nimbl", "brivv"]""" + + try: + res = await chat_completions({ + "model": settings.llm_default_model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.9, # Higher creativity + "stream": False, + }) + content = res.get("choices", [{}])[0].get("message", {}).get("content", "") + + # Extract JSON array + match = re.search(r'\[.*?\]', content, re.DOTALL) + if match: + names = json.loads(match.group(0)) + # Filter and clean + return [ + name.lower().strip() + for name in names + if isinstance(name, str) and 3 <= len(name.strip()) <= 12 and name.isalnum() + ][:count] + except Exception as e: + print(f"LLM brandable generation failed: {e}") + + return [] + + +async def generate_similar_names(brand: str, count: int = 12) -> list[str]: + """ + Generate names similar to an existing brand. + Useful for finding alternatives or inspired names. + """ + prompt = f"""You are a brand naming expert. Generate {count} new brandable names INSPIRED BY (but not copying) "{brand}". + +The names should: +- Have similar length and rhythm to "{brand}" +- Feel like they belong in the same industry +- Be completely original (not existing brands) +- Be 4-8 characters, easy to spell + +Return ONLY a JSON array of lowercase strings, nothing else.""" + + try: + res = await chat_completions({ + "model": settings.llm_default_model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.85, + "stream": False, + }) + content = res.get("choices", [{}])[0].get("message", {}).get("content", "") + + match = re.search(r'\[.*?\]', content, re.DOTALL) + if match: + names = json.loads(match.group(0)) + return [ + name.lower().strip() + for name in names + if isinstance(name, str) and 3 <= len(name.strip()) <= 12 and name.isalnum() + ][:count] + except Exception as e: + print(f"LLM similar names failed: {e}") + + return [] + diff --git a/backend/app/services/llm_tools.py b/backend/app/services/llm_tools.py deleted file mode 100644 index 4ace105..0000000 --- a/backend/app/services/llm_tools.py +++ /dev/null @@ -1,981 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass -from datetime import datetime -from decimal import Decimal -from typing import Any, Awaitable, Callable, Optional - -from sqlalchemy import and_, case, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.tld_prices import get_trending_tlds -from app.models.auction import DomainAuction -from app.models.domain import Domain -from app.models.listing import DomainListing, ListingStatus -from app.models.listing import ListingInquiry, ListingInquiryMessage -from app.models.portfolio import PortfolioDomain -from app.models.sniper_alert import SniperAlert -from app.models.subscription import Subscription, SubscriptionTier -from app.models.user import User -from app.models.yield_domain import YieldDomain, YieldTransaction -from app.services.analyze.service import get_domain_analysis -from app.services.domain_checker import domain_checker -from app.services.hunt.brandables import check_domains, generate_cvcvc, generate_cvccv, generate_human -from app.services.hunt.trends import fetch_google_trends_daily_rss -from app.services.hunt.typos import generate_typos -from app.services.zone_file import get_dropped_domains - - -ToolHandler = Callable[[AsyncSession, User, dict[str, Any]], Awaitable[dict[str, Any]]] - - -@dataclass(frozen=True) -class ToolDef: - name: str - description: str - json_schema: dict[str, Any] - min_tier: SubscriptionTier = SubscriptionTier.TRADER - handler: ToolHandler | None = None - - -def _tier_level(tier: SubscriptionTier) -> int: - if tier == SubscriptionTier.TYCOON: - return 3 - if tier == SubscriptionTier.TRADER: - return 2 - return 1 - - -async def _get_subscription(db: AsyncSession, user: User) -> Subscription | None: - res = await db.execute(select(Subscription).where(Subscription.user_id == user.id)) - return res.scalar_one_or_none() - - -def _require_tier(user_tier: SubscriptionTier, tool_tier: SubscriptionTier) -> None: - if _tier_level(user_tier) < _tier_level(tool_tier): - raise PermissionError(f"Tool requires {tool_tier.value} tier.") - - -def _clamp_int(value: Any, *, lo: int, hi: int, default: int) -> int: - try: - v = int(value) - except Exception: - return default - return max(lo, min(hi, v)) - - -def _clamp_float(value: Any, *, lo: float, hi: float, default: float) -> float: - try: - v = float(value) - except Exception: - return default - return max(lo, min(hi, v)) - - -# ============================================================================ -# TOOL IMPLEMENTATIONS (READ-ONLY) -# ============================================================================ - - -async def tool_get_subscription(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - sub = await _get_subscription(db, user) - if not sub: - return {"tier": "scout", "features": {}, "limits": {}} - cfg = sub.config - return { - "tier": sub.tier.value, - "tier_name": cfg.get("name"), - "features": cfg.get("features", {}), - "limits": { - "watchlist": sub.domain_limit, - "portfolio": cfg.get("portfolio_limit"), - "listings": cfg.get("listing_limit"), - "sniper": cfg.get("sniper_limit"), - "history_days": cfg.get("history_days"), - "check_frequency": cfg.get("check_frequency"), - }, - } - - -async def tool_get_dashboard_summary(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - # Similar to /dashboard/summary but kept lightweight and tool-friendly. - now = args.get("now") # ignored (server time used) - _ = now - from datetime import datetime, timedelta - - t = datetime.utcnow() - active = and_(DomainAuction.is_active == True, DomainAuction.end_time > t) - total_auctions = (await db.execute(select(func.count(DomainAuction.id)).where(active))).scalar() or 0 - - cutoff = t + timedelta(hours=24) - ending = and_(DomainAuction.is_active == True, DomainAuction.end_time > t, DomainAuction.end_time <= cutoff) - ending_soon_count = (await db.execute(select(func.count(DomainAuction.id)).where(ending))).scalar() or 0 - - ending_rows = ( - await db.execute(select(DomainAuction).where(ending).order_by(DomainAuction.end_time.asc()).limit(10)) - ).scalars().all() - - # Listings counts - listing_counts = ( - await db.execute( - select(DomainListing.status, func.count(DomainListing.id)) - .where(DomainListing.user_id == user.id) - .group_by(DomainListing.status) - ) - ).all() - by_status = {str(status): int(count) for status, count in listing_counts} - - trending = await get_trending_tlds(db) - - return { - "market": { - "total_auctions": total_auctions, - "ending_soon_24h": ending_soon_count, - "ending_soon_preview": [ - { - "domain": a.domain, - "current_bid": a.current_bid, - "platform": a.platform, - "end_time": a.end_time.isoformat() if a.end_time else None, - "auction_url": a.auction_url, - } - for a in ending_rows - ], - }, - "listings": { - "active": by_status.get(ListingStatus.ACTIVE.value, 0), - "sold": by_status.get(ListingStatus.SOLD.value, 0), - "draft": by_status.get(ListingStatus.DRAFT.value, 0), - "total": sum(by_status.values()), - }, - "tlds": trending, - "timestamp": t.isoformat(), - } - - -async def tool_hunt_trends(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - geo = (args.get("geo") or "US").strip().upper() - if len(geo) != 2: - geo = "US" - items_raw = await fetch_google_trends_daily_rss(geo=geo) - return { - "geo": geo, - "items": [ - { - "title": i["title"], - "approx_traffic": i.get("approx_traffic"), - "published_at": i.get("published_at"), - "link": i.get("link"), - } - for i in items_raw[:50] - ], - "timestamp": datetime.utcnow().isoformat(), - } - - -async def tool_hunt_brandables(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - pattern = (args.get("pattern") or "cvcvc").strip().lower() - if pattern not in ("cvcvc", "cvccv", "human"): - pattern = "cvcvc" - - tlds = args.get("tlds") or ["com"] - if not isinstance(tlds, list): - tlds = ["com"] - tlds = [str(t).lower().lstrip(".") for t in tlds if str(t).strip()] - if not tlds: - tlds = ["com"] - - max_checks = _clamp_int(args.get("max_checks"), lo=10, hi=80, default=40) - limit = _clamp_int(args.get("limit"), lo=1, hi=25, default=15) - - candidates: list[str] = [] - for _ in range(max_checks): - if pattern == "cvcvc": - sld = generate_cvcvc() - elif pattern == "cvccv": - sld = generate_cvccv() - else: - sld = generate_human() - for t in tlds: - candidates.append(f"{sld}.{t}") - - checked = await check_domains(candidates, concurrency=30) - available = [c for c in checked if c.status == "available"] - seen = set() - out = [] - for c in available: - if c.domain in seen: - continue - seen.add(c.domain) - out.append({"domain": c.domain, "status": c.status, "is_available": bool(c.is_available)}) - if len(out) >= limit: - break - - return {"pattern": pattern, "tlds": tlds, "items": out, "timestamp": datetime.utcnow().isoformat()} - - -async def tool_hunt_typos(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - brand = (args.get("brand") or "").strip() - if not brand or len(brand) < 2: - return {"error": "Missing brand"} - limit = _clamp_int(args.get("limit"), lo=1, hi=30, default=15) - tlds = args.get("tlds") or ["com"] - if not isinstance(tlds, list): - tlds = ["com"] - tlds = [str(t).lower().lstrip(".") for t in tlds if str(t).strip()] - if not tlds: - tlds = ["com"] - - # generate_typos returns SLD strings; we format domains for UX - slugs = generate_typos(brand, limit=limit) - items = [] - for sld in slugs: - for t in tlds[:3]: - items.append(f"{sld}.{t}") - if len(items) >= limit: - break - if len(items) >= limit: - break - - return {"brand": brand, "items": items, "timestamp": datetime.utcnow().isoformat()} - - -async def tool_keyword_availability(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - keyword = (args.get("keyword") or "").strip().lower() - if not keyword: - return {"error": "Missing keyword"} - tlds = args.get("tlds") or ["com"] - if not isinstance(tlds, list): - tlds = ["com"] - tlds = [str(t).lower().lstrip(".") for t in tlds if str(t).strip()] - if not tlds: - tlds = ["com"] - limit = _clamp_int(args.get("limit"), lo=1, hi=25, default=10) - - candidates = [f"{keyword}.{t}" for t in tlds][:limit] - results = [] - for d in candidates: - r = await domain_checker.check_domain(d) - results.append({"domain": d, "status": r.status, "is_available": bool(getattr(r, "is_available", False))}) - return {"keyword": keyword, "items": results, "timestamp": datetime.utcnow().isoformat()} - - -async def tool_portfolio_summary(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - rows = (await db.execute(select(PortfolioDomain).where(PortfolioDomain.user_id == user.id))).scalars().all() - total = len(rows) - active = sum(1 for d in rows if not d.is_sold and (d.status or "active") != "expired") - sold = sum(1 for d in rows if bool(d.is_sold)) - total_cost = float(sum(Decimal(str(d.purchase_price or 0)) for d in rows)) - total_value = float(sum(Decimal(str(d.estimated_value or 0)) for d in rows if not d.is_sold)) - roi = None - if total_cost > 0: - roi = round(((total_value - total_cost) / total_cost) * 100, 2) - return { - "total_domains": total, - "active_domains": active, - "sold_domains": sold, - "total_cost": round(total_cost, 2), - "total_estimated_value": round(total_value, 2), - "overall_roi_percent": roi, - "timestamp": datetime.utcnow().isoformat(), - } - - -async def tool_list_portfolio(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - limit = _clamp_int(args.get("limit"), lo=1, hi=50, default=25) - status = (args.get("status") or "").strip().lower() or None - q = select(PortfolioDomain).where(PortfolioDomain.user_id == user.id) - if status: - q = q.where(PortfolioDomain.status == status) - q = q.order_by(PortfolioDomain.created_at.desc()).limit(limit) - rows = (await db.execute(q)).scalars().all() - return { - "items": [ - { - "id": d.id, - "domain": d.domain, - "status": d.status, - "purchase_price": d.purchase_price, - "estimated_value": d.estimated_value, - "roi": d.roi, - "renewal_date": d.renewal_date.isoformat() if d.renewal_date else None, - "is_dns_verified": bool(getattr(d, "is_dns_verified", False) or False), - } - for d in rows - ], - "count": len(rows), - "timestamp": datetime.utcnow().isoformat(), - } - - -async def tool_list_sniper_alerts(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - limit = _clamp_int(args.get("limit"), lo=1, hi=50, default=25) - rows = ( - await db.execute( - select(SniperAlert) - .where(SniperAlert.user_id == user.id) - .order_by(SniperAlert.created_at.desc()) - .limit(limit) - ) - ).scalars().all() - return { - "items": [ - { - "id": a.id, - "name": a.name, - "description": a.description, - "is_active": bool(a.is_active), - "tlds": a.tlds, - "keywords": a.keywords, - "exclude_keywords": a.exclude_keywords, - "max_price": a.max_price, - "ending_within_hours": a.ending_within_hours, - "notify_email": bool(a.notify_email), - "notify_sms": bool(a.notify_sms), - "created_at": a.created_at.isoformat() if a.created_at else None, - } - for a in rows - ], - "count": len(rows), - "timestamp": datetime.utcnow().isoformat(), - } - - -async def tool_list_my_listings(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - limit = _clamp_int(args.get("limit"), lo=1, hi=50, default=25) - rows = ( - await db.execute( - select(DomainListing) - .where(DomainListing.user_id == user.id) - .order_by(DomainListing.updated_at.desc()) - .limit(limit) - ) - ).scalars().all() - return { - "items": [ - { - "id": l.id, - "domain": l.domain, - "status": l.status, - "asking_price": l.asking_price, - "min_offer": l.min_offer, - "currency": l.currency, - "slug": l.slug, - "verification_status": l.verification_status, - "view_count": l.view_count, - "inquiry_count": l.inquiry_count, - "updated_at": l.updated_at.isoformat() if l.updated_at else None, - } - for l in rows - ], - "count": len(rows), - "timestamp": datetime.utcnow().isoformat(), - } - - -async def tool_get_inbox_counts(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - buyer_inqs = (await db.execute(select(ListingInquiry.id).where(ListingInquiry.buyer_user_id == user.id))).scalars().all() - buyer_unread = 0 - for inq_id in list(buyer_inqs)[:200]: - msg = ( - await db.execute( - select(ListingInquiryMessage) - .where(ListingInquiryMessage.inquiry_id == inq_id) - .order_by(ListingInquiryMessage.created_at.desc()) - .limit(1) - ) - ).scalar_one_or_none() - if msg and msg.sender_user_id != user.id: - buyer_unread += 1 - - listing_ids = (await db.execute(select(DomainListing.id).where(DomainListing.user_id == user.id))).scalars().all() - seller_unread = 0 - if listing_ids: - new_count = ( - await db.execute( - select(func.count(ListingInquiry.id)).where( - and_(ListingInquiry.listing_id.in_(listing_ids), ListingInquiry.status == "new") - ) - ) - ).scalar() or 0 - seller_unread += int(new_count) - - inqs = ( - await db.execute( - select(ListingInquiry.id, ListingInquiry.status) - .where( - and_( - ListingInquiry.listing_id.in_(listing_ids), - ListingInquiry.status.notin_(["closed", "spam"]), - ) - ) - .order_by(ListingInquiry.created_at.desc()) - .limit(200) - ) - ).all() - for inq_id, st in inqs: - if st == "new": - continue - msg = ( - await db.execute( - select(ListingInquiryMessage) - .where(ListingInquiryMessage.inquiry_id == inq_id) - .order_by(ListingInquiryMessage.created_at.desc()) - .limit(1) - ) - ).scalar_one_or_none() - if msg and msg.sender_user_id != user.id: - seller_unread += 1 - - return {"buyer_unread": buyer_unread, "seller_unread": seller_unread, "total_unread": buyer_unread + seller_unread} - - -async def tool_get_seller_inbox(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - limit = _clamp_int(args.get("limit"), lo=1, hi=50, default=25) - status_filter = (args.get("status") or "all").strip().lower() - - listings = (await db.execute(select(DomainListing).where(DomainListing.user_id == user.id))).scalars().all() - listing_map = {l.id: l for l in listings} - if not listing_map: - return {"inquiries": [], "total": 0, "unread": 0} - - q = ( - select(ListingInquiry) - .where(ListingInquiry.listing_id.in_(list(listing_map.keys()))) - .order_by(ListingInquiry.created_at.desc()) - ) - if status_filter and status_filter != "all": - q = q.where(ListingInquiry.status == status_filter) - inqs = (await db.execute(q.limit(limit))).scalars().all() - - unread = sum(1 for i in inqs if i.status == "new" or not i.read_at) - items = [] - for inq in inqs: - listing = listing_map.get(inq.listing_id) - if not listing: - continue - latest_msg = ( - await db.execute( - select(ListingInquiryMessage) - .where(ListingInquiryMessage.inquiry_id == inq.id) - .order_by(ListingInquiryMessage.created_at.desc()) - .limit(1) - ) - ).scalar_one_or_none() - items.append( - { - "id": inq.id, - "domain": listing.domain, - "listing_id": listing.id, - "slug": listing.slug, - "status": inq.status, - "buyer_name": inq.name, - "offer_amount": inq.offer_amount, - "created_at": inq.created_at.isoformat() if inq.created_at else None, - "has_unread_reply": bool(latest_msg and latest_msg.sender_user_id != user.id and inq.status not in ["closed", "spam"]), - "last_message_preview": ( - (latest_msg.body[:100] + "..." if len(latest_msg.body) > 100 else latest_msg.body) - if latest_msg - else ((inq.message or "")[:100]) - ), - "last_message_at": latest_msg.created_at.isoformat() if latest_msg and latest_msg.created_at else (inq.created_at.isoformat() if inq.created_at else None), - } - ) - - return {"inquiries": items, "total": len(items), "unread": unread, "timestamp": datetime.utcnow().isoformat()} - - -async def tool_yield_dashboard(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - now = datetime.utcnow() - month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - - domains = ( - await db.execute( - select(YieldDomain) - .where(YieldDomain.user_id == user.id) - .order_by(YieldDomain.total_revenue.desc()) - ) - ).scalars().all() - domain_ids = [d.id for d in domains] - - monthly_revenue = Decimal("0") - monthly_clicks = 0 - monthly_conversions = 0 - if domain_ids: - monthly_stats = ( - await db.execute( - select( - func.coalesce( - func.sum( - case( - (YieldTransaction.status.in_(["confirmed", "paid"]), YieldTransaction.net_amount), - else_=0, - ) - ), - 0, - ).label("revenue"), - func.sum(case((YieldTransaction.event_type == "click", 1), else_=0)).label("clicks"), - func.sum( - case( - ( - and_( - YieldTransaction.event_type.in_(["lead", "sale"]), - YieldTransaction.status.in_(["confirmed", "paid"]), - ), - 1, - ), - else_=0, - ) - ).label("conversions"), - ).where(YieldTransaction.yield_domain_id.in_(domain_ids), YieldTransaction.created_at >= month_start) - ) - ).first() - if monthly_stats: - monthly_revenue = monthly_stats.revenue or Decimal("0") - monthly_clicks = int(monthly_stats.clicks or 0) - monthly_conversions = int(monthly_stats.conversions or 0) - - return { - "stats": { - "active_domains": sum(1 for d in domains if d.status == "active"), - "total_domains": len(domains), - "monthly_revenue": float(monthly_revenue), - "monthly_clicks": monthly_clicks, - "monthly_conversions": monthly_conversions, - }, - "top_domains": [ - { - "id": d.id, - "domain": d.domain, - "status": d.status, - "dns_verified": bool(d.dns_verified), - "total_revenue": float(d.total_revenue or 0), - "total_clicks": int(d.total_clicks or 0), - "total_conversions": int(d.total_conversions or 0), - "detected_intent": d.detected_intent, - } - for d in domains[:10] - ], - "timestamp": now.isoformat(), - } - - -async def tool_list_watchlist(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - page = _clamp_int(args.get("page"), lo=1, hi=50, default=1) - per_page = _clamp_int(args.get("per_page"), lo=1, hi=50, default=20) - offset = (page - 1) * per_page - total = (await db.execute(select(func.count(Domain.id)).where(Domain.user_id == user.id))).scalar() or 0 - rows = ( - await db.execute( - select(Domain) - .where(Domain.user_id == user.id) - .order_by(Domain.created_at.desc()) - .offset(offset) - .limit(per_page) - ) - ).scalars().all() - return { - "page": page, - "per_page": per_page, - "total": int(total), - "domains": [ - { - "id": d.id, - "name": d.name, - "status": getattr(d.status, "value", d.status), - "is_available": bool(d.is_available), - "registrar": d.registrar, - "created_at": d.created_at.isoformat() if d.created_at else None, - "updated_at": d.updated_at.isoformat() if d.updated_at else None, - } - for d in rows - ], - } - - -async def tool_analyze_domain(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - domain = (args.get("domain") or "").strip() - if not domain: - return {"error": "Missing domain"} - fast = bool(args.get("fast", False)) - refresh = bool(args.get("refresh", False)) - res = await get_domain_analysis(db, domain, fast=fast, refresh=refresh) - return res.model_dump(mode="json") - - -async def tool_market_feed(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - # Read-only query against DomainAuction similar to /auctions/feed; keep it capped. - from datetime import datetime, timedelta - - limit = _clamp_int(args.get("limit"), lo=1, hi=50, default=20) - source = (args.get("source") or "all").lower() - keyword = (args.get("keyword") or "").strip().lower() or None - tld = (args.get("tld") or "").strip().lower().lstrip(".") or None - sort_by = (args.get("sort_by") or "time").lower() - ending_within = args.get("ending_within_hours") - ending_within_h = _clamp_int(ending_within, lo=1, hi=168, default=0) if ending_within is not None else None - - now = datetime.utcnow() - q = select(DomainAuction).where(DomainAuction.is_active == True, DomainAuction.end_time > now) - - if source in ("pounce", "external"): - q = q.where(DomainAuction.source == source) - if keyword: - q = q.where(DomainAuction.domain.ilike(f"%{keyword}%")) - if tld: - q = q.where(DomainAuction.domain.ilike(f"%.{tld}")) - if ending_within_h: - q = q.where(DomainAuction.end_time <= (now + timedelta(hours=ending_within_h))) - - if sort_by == "score": - q = q.order_by(DomainAuction.score.desc().nullslast(), DomainAuction.end_time.asc()) - else: - q = q.order_by(DomainAuction.end_time.asc()) - - auctions = (await db.execute(q.limit(limit))).scalars().all() - return { - "items": [ - { - "domain": a.domain, - "current_bid": a.current_bid, - "platform": a.platform, - "end_time": a.end_time.isoformat() if a.end_time else None, - "bids": a.bids, - "score": a.score, - "auction_url": a.auction_url, - "source": a.source, - } - for a in auctions - ], - "count": len(auctions), - "timestamp": now.isoformat(), - } - - -async def tool_get_drops(db: AsyncSession, user: User, args: dict[str, Any]) -> dict[str, Any]: - tld = (args.get("tld") or None) - hours = _clamp_int(args.get("hours"), lo=1, hi=48, default=24) - limit = _clamp_int(args.get("limit"), lo=1, hi=100, default=50) - offset = _clamp_int(args.get("offset"), lo=0, hi=10000, default=0) - keyword = (args.get("keyword") or None) - min_length = args.get("min_length") - max_length = args.get("max_length") - exclude_numeric = bool(args.get("exclude_numeric", False)) - exclude_hyphen = bool(args.get("exclude_hyphen", False)) - - result = await get_dropped_domains( - db=db, - tld=(tld.lower().lstrip(".") if isinstance(tld, str) and tld.strip() else None), - hours=hours, - min_length=int(min_length) if min_length is not None else None, - max_length=int(max_length) if max_length is not None else None, - exclude_numeric=exclude_numeric, - exclude_hyphen=exclude_hyphen, - keyword=(str(keyword).strip() if keyword else None), - limit=limit, - offset=offset, - ) - return result - - -def get_tool_defs() -> list[ToolDef]: - return [ - ToolDef( - name="get_subscription", - description="Get current user's subscription tier, features and limits.", - json_schema={"type": "object", "properties": {}, "additionalProperties": False}, - min_tier=SubscriptionTier.TRADER, - handler=tool_get_subscription, - ), - ToolDef( - name="get_dashboard_summary", - description="Get a compact snapshot: ending auctions, listing stats, trending TLDs.", - json_schema={"type": "object", "properties": {}, "additionalProperties": False}, - min_tier=SubscriptionTier.TRADER, - handler=tool_get_dashboard_summary, - ), - ToolDef( - name="hunt_trends", - description="Get Google Trends daily RSS items (Hunt > Trends).", - json_schema={ - "type": "object", - "properties": {"geo": {"type": "string", "minLength": 2, "maxLength": 2}}, - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_hunt_trends, - ), - ToolDef( - name="hunt_brandables", - description="Generate brandable domains and check availability (Hunt > Forge).", - json_schema={ - "type": "object", - "properties": { - "pattern": {"type": "string", "enum": ["cvcvc", "cvccv", "human"]}, - "tlds": {"type": "array", "items": {"type": "string"}}, - "max_checks": {"type": "integer", "minimum": 10, "maximum": 80}, - "limit": {"type": "integer", "minimum": 1, "maximum": 25}, - }, - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_hunt_brandables, - ), - ToolDef( - name="hunt_typos", - description="Generate typo candidates for a brand (Hunt > Typos).", - json_schema={ - "type": "object", - "properties": { - "brand": {"type": "string"}, - "tlds": {"type": "array", "items": {"type": "string"}}, - "limit": {"type": "integer", "minimum": 1, "maximum": 30}, - }, - "required": ["brand"], - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_hunt_typos, - ), - ToolDef( - name="keyword_availability", - description="Check availability for keyword + TLD list (Hunt > Search).", - json_schema={ - "type": "object", - "properties": { - "keyword": {"type": "string"}, - "tlds": {"type": "array", "items": {"type": "string"}}, - "limit": {"type": "integer", "minimum": 1, "maximum": 25}, - }, - "required": ["keyword"], - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_keyword_availability, - ), - ToolDef( - name="list_watchlist", - description="List user's watchlist domains (monitored domains).", - json_schema={ - "type": "object", - "properties": { - "page": {"type": "integer", "minimum": 1, "maximum": 50}, - "per_page": {"type": "integer", "minimum": 1, "maximum": 50}, - }, - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_list_watchlist, - ), - ToolDef( - name="portfolio_summary", - description="Get portfolio summary (counts, total cost/value, ROI).", - json_schema={"type": "object", "properties": {}, "additionalProperties": False}, - min_tier=SubscriptionTier.TRADER, - handler=tool_portfolio_summary, - ), - ToolDef( - name="list_portfolio", - description="List portfolio domains (owned domains).", - json_schema={ - "type": "object", - "properties": { - "status": {"type": "string"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 50}, - }, - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_list_portfolio, - ), - ToolDef( - name="list_sniper_alerts", - description="List sniper alerts (user-defined filters).", - json_schema={ - "type": "object", - "properties": {"limit": {"type": "integer", "minimum": 1, "maximum": 50}}, - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_list_sniper_alerts, - ), - ToolDef( - name="list_my_listings", - description="List seller's Pounce Direct listings (For Sale).", - json_schema={ - "type": "object", - "properties": {"limit": {"type": "integer", "minimum": 1, "maximum": 50}}, - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_list_my_listings, - ), - ToolDef( - name="get_inbox_counts", - description="Get unified buyer/seller unread counts for inbox badge.", - json_schema={"type": "object", "properties": {}, "additionalProperties": False}, - min_tier=SubscriptionTier.TRADER, - handler=tool_get_inbox_counts, - ), - ToolDef( - name="get_seller_inbox", - description="Seller inbox threads across all listings (preview list).", - json_schema={ - "type": "object", - "properties": { - "status": {"type": "string", "enum": ["all", "new", "read", "replied", "closed", "spam"]}, - "limit": {"type": "integer", "minimum": 1, "maximum": 50}, - }, - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_get_seller_inbox, - ), - ToolDef( - name="analyze_domain", - description="Run Pounce domain analysis (Authority/Market/Risk/Value) for a given domain.", - json_schema={ - "type": "object", - "properties": { - "domain": {"type": "string"}, - "fast": {"type": "boolean"}, - "refresh": {"type": "boolean"}, - }, - "required": ["domain"], - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_analyze_domain, - ), - ToolDef( - name="market_feed", - description="Get current auction feed (filters: source, keyword, tld, ending window).", - json_schema={ - "type": "object", - "properties": { - "source": {"type": "string", "enum": ["all", "pounce", "external"]}, - "keyword": {"type": "string"}, - "tld": {"type": "string"}, - "sort_by": {"type": "string", "enum": ["time", "score"]}, - "ending_within_hours": {"type": "integer", "minimum": 1, "maximum": 168}, - "limit": {"type": "integer", "minimum": 1, "maximum": 50}, - }, - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_market_feed, - ), - ToolDef( - name="get_drops", - description="Get recently dropped domains from zone files (auth required).", - json_schema={ - "type": "object", - "properties": { - "tld": {"type": "string"}, - "hours": {"type": "integer", "minimum": 1, "maximum": 48}, - "min_length": {"type": "integer", "minimum": 1, "maximum": 63}, - "max_length": {"type": "integer", "minimum": 1, "maximum": 63}, - "exclude_numeric": {"type": "boolean"}, - "exclude_hyphen": {"type": "boolean"}, - "keyword": {"type": "string"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 100}, - "offset": {"type": "integer", "minimum": 0, "maximum": 10000}, - }, - "additionalProperties": False, - }, - min_tier=SubscriptionTier.TRADER, - handler=tool_get_drops, - ), - ToolDef( - name="yield_dashboard", - description="Get Yield dashboard stats and top earning domains.", - json_schema={"type": "object", "properties": {}, "additionalProperties": False}, - min_tier=SubscriptionTier.TRADER, - handler=tool_yield_dashboard, - ), - ] - - -def tools_for_path(path: str) -> list[str]: - """ - Limit the visible tool list depending on the current Terminal page. - This keeps prompts smaller and makes the model more decisive. - """ - p = (path or "").split("?")[0] - if p.startswith("/terminal/hunt"): - return [ - "get_subscription", - "get_dashboard_summary", - "market_feed", - "get_drops", - "hunt_trends", - "hunt_brandables", - "hunt_typos", - "keyword_availability", - "analyze_domain", - "list_watchlist", - ] - if p.startswith("/terminal/market"): - return ["get_subscription", "market_feed", "analyze_domain"] - if p.startswith("/terminal/watchlist"): - return ["get_subscription", "list_watchlist", "analyze_domain"] - if p.startswith("/terminal/portfolio"): - return ["get_subscription", "portfolio_summary", "list_portfolio", "analyze_domain"] - if p.startswith("/terminal/sniper"): - return ["get_subscription", "list_sniper_alerts", "market_feed", "analyze_domain"] - if p.startswith("/terminal/listing"): - return ["get_subscription", "list_my_listings", "get_seller_inbox"] - if p.startswith("/terminal/inbox"): - return ["get_subscription", "get_inbox_counts", "get_seller_inbox"] - if p.startswith("/terminal/yield"): - return ["get_subscription", "yield_dashboard"] - if p.startswith("/terminal/intel"): - return ["get_subscription", "analyze_domain", "market_feed", "get_drops"] - if p.startswith("/terminal/settings"): - return ["get_subscription"] - if p.startswith("/terminal/welcome"): - return ["get_subscription", "get_dashboard_summary"] - # default: allow a safe minimal set - return ["get_subscription", "get_dashboard_summary", "analyze_domain"] - - -async def execute_tool(db: AsyncSession, user: User, name: str, args: dict[str, Any], *, path: str) -> dict[str, Any]: - defs = {t.name: t for t in get_tool_defs()} - tool = defs.get(name) - if tool is None or tool.handler is None: - return {"error": f"Unknown tool: {name}"} - - # Enforce tool allowed on this page - allowed = set(tools_for_path(path)) - if name not in allowed: - return {"error": f"Tool not allowed for path: {name}"} - - sub = await _get_subscription(db, user) - user_tier = sub.tier if sub else SubscriptionTier.SCOUT - try: - _require_tier(user_tier, tool.min_tier) - except PermissionError as e: - return {"error": str(e)} - - try: - return await tool.handler(db, user, args or {}) - except Exception as e: - return {"error": f"{type(e).__name__}: {e}"} - - -def tool_catalog_for_prompt(path: str) -> list[dict[str, Any]]: - allowed = set(tools_for_path(path)) - out: list[dict[str, Any]] = [] - for t in get_tool_defs(): - if t.name in allowed: - out.append( - { - "name": t.name, - "description": t.description, - "schema": t.json_schema, - } - ) - return out - - diff --git a/frontend/src/app/terminal/layout.tsx b/frontend/src/app/terminal/layout.tsx index 7d6ed62..34d867e 100644 --- a/frontend/src/app/terminal/layout.tsx +++ b/frontend/src/app/terminal/layout.tsx @@ -5,7 +5,6 @@ import { useRouter, usePathname } from 'next/navigation' import { useStore } from '@/lib/store' import { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider' import { BetaBanner } from '@/components/BetaBanner' -import { HunterCompanion } from '@/components/chat/HunterCompanion' import { Loader2 } from 'lucide-react' export default function TerminalLayout({ @@ -65,7 +64,6 @@ export default function TerminalLayout({ {children} - ) } diff --git a/frontend/src/components/chat/HunterCompanion.tsx b/frontend/src/components/chat/HunterCompanion.tsx deleted file mode 100644 index d49e796..0000000 --- a/frontend/src/components/chat/HunterCompanion.tsx +++ /dev/null @@ -1,321 +0,0 @@ -'use client' - -import { useEffect, useMemo, useRef, useState } from 'react' -import clsx from 'clsx' -import { usePathname } from 'next/navigation' -import Link from 'next/link' -import { MessageSquare, X, Send, Sparkles, Loader2, Lock, Trash2 } from 'lucide-react' -import { useStore } from '@/lib/store' - -type ChatMessage = { - id: string - role: 'user' | 'assistant' - content: string -} - -function uid() { - return `${Date.now()}_${Math.random().toString(16).slice(2)}` -} - -function getApiBase(): string { - if (typeof window === 'undefined') return 'http://localhost:8000/api/v1' - const { protocol, hostname } = window.location - if (hostname === 'localhost' || hostname === '127.0.0.1') return 'http://localhost:8000/api/v1' - if (/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(hostname)) return `http://${hostname}:8000/api/v1` - return `${protocol}//${hostname}/api/v1` -} - -async function sendMessage(opts: { - message: string - path: string - onChunk: (text: string) => void -}): Promise { - const res = await fetch(`${getApiBase()}/llm/agent`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: [{ role: 'user', content: opts.message }], - path: opts.path, - stream: true, - }), - }) - - if (!res.ok) { - const data = await res.json().catch(() => null) - throw new Error(data?.detail || res.statusText || 'Request failed') - } - - const reader = res.body?.getReader() - if (!reader) throw new Error('Streaming not supported') - - const decoder = new TextDecoder() - let buffer = '' - - while (true) { - const { value, done } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const parts = buffer.split('\n\n') - buffer = parts.pop() || '' - - for (const part of parts) { - const line = part.split('\n').find(l => l.startsWith('data: ')) - if (!line) continue - - const payload = line.slice(6) - if (payload === '[DONE]') return - - try { - const json = JSON.parse(payload) - const content = json?.choices?.[0]?.delta?.content - if (content) opts.onChunk(content) - } catch {} - } - } -} - -function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' { - const t = (subscription?.tier || '').toLowerCase() - if (t === 'trader') return 'trader' - if (t === 'tycoon') return 'tycoon' - return 'scout' -} - -const QUICK_ACTIONS = [ - { label: 'Analyze domain', action: 'analyze ' }, - { label: 'Auctions', action: 'show auctions ending soon' }, - { label: 'Watchlist', action: 'show my watchlist' }, - { label: 'Portfolio', action: 'portfolio summary' }, -] - -export function HunterCompanion() { - const pathname = usePathname() - const { subscription, user } = useStore() - const tier = getTier(subscription) - const canChat = tier === 'trader' || tier === 'tycoon' - - const storageKey = useMemo(() => `pounce:hc:${user?.id || 'anon'}`, [user?.id]) - - const [open, setOpen] = useState(false) - const [input, setInput] = useState('') - const [messages, setMessages] = useState([]) - const [loading, setLoading] = useState(false) - - const scrollRef = useRef(null) - const inputRef = useRef(null) - - // Load saved messages - useEffect(() => { - if (!canChat) return - try { - const saved = localStorage.getItem(storageKey) - if (saved) setMessages(JSON.parse(saved)) - } catch {} - }, [storageKey, canChat]) - - // Save messages - useEffect(() => { - if (!canChat || !messages.length) return - try { - localStorage.setItem(storageKey, JSON.stringify(messages.slice(-30))) - } catch {} - }, [messages, storageKey, canChat]) - - // Auto scroll - useEffect(() => { - if (open && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight - } - }, [open, messages]) - - // Focus input - useEffect(() => { - if (open && canChat) { - setTimeout(() => inputRef.current?.focus(), 100) - } - }, [open, canChat]) - - // Only show in terminal - if (!pathname?.startsWith('/terminal')) return null - - const send = async (text?: string) => { - const msg = (text || input).trim() - if (!msg || loading || !canChat) return - - const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg } - const assistantId = uid() - - setMessages(prev => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '' }]) - setInput('') - setLoading(true) - - try { - await sendMessage({ - message: msg, - path: pathname || '/terminal/hunt', - onChunk: (chunk) => { - setMessages(prev => prev.map(m => - m.id === assistantId ? { ...m, content: m.content + chunk } : m - )) - }, - }) - } catch (e: any) { - setMessages(prev => prev.map(m => - m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Failed'}` } : m - )) - } finally { - setLoading(false) - inputRef.current?.focus() - } - } - - const clear = () => { - setMessages([]) - localStorage.removeItem(storageKey) - } - - return ( - <> - {/* FAB Button */} - - - {/* Chat Modal */} - {open && ( -
- {/* Backdrop */} -
setOpen(false)} /> - - {/* Panel */} -
- - {/* Header */} -
-
- - - Hunter Companion - -
-
- {canChat && messages.length > 0 && ( - - )} - -
-
- - {/* Content */} -
- {!canChat ? ( - /* Scout - Locked */ -
- -

Hunter Companion

-

- AI-powered domain analysis, auction alerts, and portfolio insights. -

- - Upgrade to Trader - -
- ) : messages.length === 0 ? ( - /* Empty State */ -
- -

- Type a domain or ask a question -

-
- {QUICK_ACTIONS.map((qa, i) => ( - - ))} -
-
- ) : ( - /* Messages */ -
- {messages.map((m) => ( -
-
- {m.content || (loading ? '...' : '')} -
-
- ))} -
- )} -
- - {/* Input */} - {canChat && ( -
-
- setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - send() - } - }} - placeholder="Type a domain or question..." - className="flex-1 px-3 py-2 bg-white/5 border border-white/10 text-white text-xs font-mono outline-none focus:border-accent/30 placeholder:text-white/25" - disabled={loading} - /> - -
-
- )} -
-
- )} - - ) -} diff --git a/frontend/src/components/hunt/BrandableForgeTab.tsx b/frontend/src/components/hunt/BrandableForgeTab.tsx index ef18cac..5745054 100644 --- a/frontend/src/components/hunt/BrandableForgeTab.tsx +++ b/frontend/src/components/hunt/BrandableForgeTab.tsx @@ -17,7 +17,11 @@ import { Star, Lightbulb, RefreshCw, + Lock, + Brain, + MessageSquare, } from 'lucide-react' +import Link from 'next/link' import { api } from '@/lib/api' import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useStore } from '@/lib/store' @@ -68,6 +72,12 @@ const TLDS = [ export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) { const openAnalyze = useAnalyzePanelStore((s) => s.open) const addDomain = useStore((s) => s.addDomain) + const subscription = useStore((s) => s.subscription) + const tier = (subscription?.tier || '').toLowerCase() + const hasAI = tier === 'trader' || tier === 'tycoon' + + // Mode: 'pattern' (classic) or 'ai' (concept-based) + const [mode, setMode] = useState<'pattern' | 'ai'>('pattern') // Config State const [pattern, setPattern] = useState('cvcvc') @@ -75,6 +85,14 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string, const [limit, setLimit] = useState(30) const [showConfig, setShowConfig] = useState(false) + // AI State + const [concept, setConcept] = useState('') + const [conceptFocused, setConceptFocused] = useState(false) + const [similarBrand, setSimilarBrand] = useState('') + const [similarFocused, setSimilarFocused] = useState(false) + const [aiNames, setAiNames] = useState([]) + const [aiLoading, setAiLoading] = useState(false) + // Results State const [loading, setLoading] = useState(false) const [items, setItems] = useState>([]) @@ -144,6 +162,60 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string, const currentPattern = PATTERNS.find(p => p.key === pattern) + // AI Generation + const generateFromConcept = useCallback(async () => { + if (!concept.trim() || !hasAI || aiLoading) return + setAiLoading(true) + setAiNames([]) + try { + const res = await api.generateBrandableNames(concept.trim(), undefined, 15) + setAiNames(res.names || []) + if (res.names?.length) { + showToast(`AI generated ${res.names.length} names!`, 'success') + } else { + showToast('No names generated. Try a different concept.', 'info') + } + } catch (e) { + showToast(e instanceof Error ? e.message : 'AI generation failed', 'error') + } finally { + setAiLoading(false) + } + }, [concept, hasAI, aiLoading, showToast]) + + const generateFromBrand = useCallback(async () => { + if (!similarBrand.trim() || !hasAI || aiLoading) return + setAiLoading(true) + setAiNames([]) + try { + const res = await api.generateSimilarNames(similarBrand.trim(), 12) + setAiNames(res.names || []) + if (res.names?.length) { + showToast(`AI found ${res.names.length} similar names!`, 'success') + } + } catch (e) { + showToast(e instanceof Error ? e.message : 'AI generation failed', 'error') + } finally { + setAiLoading(false) + } + }, [similarBrand, hasAI, aiLoading, showToast]) + + // Check AI-generated names for availability + const checkAiNames = useCallback(async () => { + if (aiNames.length === 0 || selectedTlds.length === 0) return + setLoading(true) + setItems([]) + try { + const res = await api.huntKeywords({ keywords: aiNames, tlds: selectedTlds }) + const available = res.items.filter(i => i.status === 'available') + setItems(available.map(i => ({ domain: i.domain, status: i.status }))) + showToast(`Found ${available.length} available domains!`, 'success') + } catch (e) { + showToast(e instanceof Error ? e.message : 'Availability check failed', 'error') + } finally { + setLoading(false) + } + }, [aiNames, selectedTlds, showToast]) + return (
{/* ═══════════════════════════════════════════════════════════════════════ */} @@ -160,7 +232,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,

Brandable Forge

- AI-powered brandable name generator + {mode === 'pattern' ? 'Pattern-based name generator' : 'AI-powered concept generator'}

@@ -203,7 +275,150 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
+ {/* Mode Toggle */} +
+
+ + + {!hasAI && ( + + Upgrade for AI + + )} +
+
+ + {/* AI Concept Mode */} + {mode === 'ai' && hasAI && ( +
+ {/* Concept Input */} +
+ +
+ + setConcept(e.target.value)} + onFocus={() => setConceptFocused(true)} + onBlur={() => setConceptFocused(false)} + onKeyDown={(e) => e.key === 'Enter' && generateFromConcept()} + placeholder="e.g., AI startup for legal documents, crypto wallet for teens..." + className="flex-1 bg-transparent py-3 pr-3 text-sm text-white placeholder:text-white/25 outline-none font-mono" + /> + +
+
+ + {/* Similar Brand Input */} +
+ +
+ + setSimilarBrand(e.target.value)} + onFocus={() => setSimilarFocused(true)} + onBlur={() => setSimilarFocused(false)} + onKeyDown={(e) => e.key === 'Enter' && generateFromBrand()} + placeholder="e.g., Stripe, Notion, Figma..." + className="flex-1 bg-transparent py-3 pr-3 text-sm text-white placeholder:text-white/25 outline-none font-mono" + /> + +
+
+ + {/* AI Generated Names */} + {aiNames.length > 0 && ( +
+
+ + AI Suggestions ({aiNames.length}) + + +
+
+ {aiNames.map((name) => ( + + {name} + + ))} +
+
+ )} +
+ )} + {/* Pattern Selection */} + {mode === 'pattern' && (
@@ -256,6 +471,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string, })}
+ )} {/* TLD Selection */}
diff --git a/frontend/src/components/hunt/SearchTab.tsx b/frontend/src/components/hunt/SearchTab.tsx index c1f19d9..a0384b9 100644 --- a/frontend/src/components/hunt/SearchTab.tsx +++ b/frontend/src/components/hunt/SearchTab.tsx @@ -25,7 +25,7 @@ import { import clsx from 'clsx' // ============================================================================ -// TYPES +// TYPES & CONSTANTS // ============================================================================ interface SearchResult { @@ -35,8 +35,29 @@ interface SearchResult { registrar: string | null expiration_date: string | null loading: boolean + error?: string } +interface TldCheckResult { + tld: string + domain: string + is_available: boolean | null + loading: boolean + error?: string +} + +// Popular TLDs to check when user enters only a name without extension +const POPULAR_TLDS = ['com', 'net', 'org', 'io', 'ch', 'de', 'app', 'dev', 'co', 'ai'] + +// Known valid TLDs (subset for quick validation) +const KNOWN_TLDS = new Set([ + 'com', 'net', 'org', 'io', 'ch', 'de', 'app', 'dev', 'co', 'ai', 'me', 'tv', 'cc', + 'xyz', 'info', 'biz', 'online', 'site', 'tech', 'store', 'club', 'shop', 'blog', + 'uk', 'fr', 'nl', 'eu', 'be', 'at', 'us', 'ca', 'au', 'li', 'it', 'es', 'pl', + 'pro', 'mobi', 'name', 'page', 'new', 'day', 'world', 'email', 'link', 'click', + 'digital', 'media', 'agency', 'studio', 'design', 'marketing', 'solutions', +]) + // ============================================================================ // COMPONENT // ============================================================================ @@ -51,9 +72,11 @@ export function SearchTab({ showToast }: SearchTabProps) { const [searchQuery, setSearchQuery] = useState('') const [searchResult, setSearchResult] = useState(null) + const [tldResults, setTldResults] = useState([]) const [addingToWatchlist, setAddingToWatchlist] = useState(false) const [searchFocused, setSearchFocused] = useState(false) const [recentSearches, setRecentSearches] = useState([]) + const [searchMode, setSearchMode] = useState<'single' | 'multi'>('single') const searchInputRef = useRef(null) // Load recent searches from localStorage @@ -78,30 +101,134 @@ export function SearchTab({ showToast }: SearchTabProps) { }) }, []) + // Check if TLD is valid + const isValidTld = useCallback((tld: string): boolean => { + return KNOWN_TLDS.has(tld.toLowerCase()) + }, []) + + // Check single domain + const checkSingleDomain = useCallback(async (domain: string): Promise => { + try { + const result = await api.checkDomain(domain) + return { + domain: result.domain, + status: result.status, + is_available: result.is_available, + registrar: result.registrar, + expiration_date: result.expiration_date, + loading: false, + } + } catch (err: any) { + return { + domain, + status: 'error', + is_available: null, + registrar: null, + expiration_date: null, + loading: false, + error: err?.message || 'Check failed', + } + } + }, []) + + // Check multiple TLDs for a name + const checkMultipleTlds = useCallback(async (name: string) => { + // Initialize results with loading state + const initialResults: TldCheckResult[] = POPULAR_TLDS.map(tld => ({ + tld, + domain: `${name}.${tld}`, + is_available: null, + loading: true, + })) + setTldResults(initialResults) + + // Check each TLD in parallel + const results = await Promise.all( + POPULAR_TLDS.map(async (tld): Promise => { + const domain = `${name}.${tld}` + try { + const result = await api.checkDomain(domain) + return { + tld, + domain, + is_available: result.is_available, + loading: false, + } + } catch { + return { + tld, + domain, + is_available: null, + loading: false, + error: 'Check failed', + } + } + }) + ) + + setTldResults(results) + }, []) + // Search Handler const handleSearch = useCallback(async (domainInput: string) => { if (!domainInput.trim()) { setSearchResult(null) + setTldResults([]) return } - const cleanDomain = domainInput.trim().toLowerCase() - setSearchResult({ domain: cleanDomain, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true }) - - try { - const whoisResult = await api.checkDomain(cleanDomain).catch(() => null) - setSearchResult({ - domain: whoisResult?.domain || cleanDomain, - status: whoisResult?.status || 'unknown', - is_available: whoisResult?.is_available ?? null, - registrar: whoisResult?.registrar || null, - expiration_date: whoisResult?.expiration_date || null, - loading: false, - }) - saveToRecent(cleanDomain) - } catch { - setSearchResult({ domain: cleanDomain, status: 'error', is_available: null, registrar: null, expiration_date: null, loading: false }) + + const cleanInput = domainInput.trim().toLowerCase().replace(/\s+/g, '') + + // Check if input contains a dot (has TLD) + if (cleanInput.includes('.')) { + // Single domain mode + setSearchMode('single') + setTldResults([]) + + const parts = cleanInput.split('.') + const tld = parts[parts.length - 1] + + // Check if TLD is valid + if (!isValidTld(tld)) { + setSearchResult({ + domain: cleanInput, + status: 'invalid_tld', + is_available: null, + registrar: null, + expiration_date: null, + loading: false, + error: `".${tld}" is not a valid domain extension`, + }) + return + } + + setSearchResult({ domain: cleanInput, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true }) + + const result = await checkSingleDomain(cleanInput) + setSearchResult(result) + if (!result.error) saveToRecent(cleanInput) + } else { + // Multi-TLD mode - check multiple extensions + setSearchMode('multi') + setSearchResult(null) + + // Validate the name part + if (cleanInput.length < 1 || cleanInput.length > 63) { + setTldResults([]) + showToast('Domain name must be 1-63 characters', 'error') + return + } + + if (!/^[a-z0-9-]+$/.test(cleanInput) || cleanInput.startsWith('-') || cleanInput.endsWith('-')) { + setTldResults([]) + showToast('Domain name contains invalid characters', 'error') + return + } + + await checkMultipleTlds(cleanInput) + saveToRecent(cleanInput) } - }, [saveToRecent]) + }, [saveToRecent, checkSingleDomain, checkMultipleTlds, isValidTld, showToast]) const handleAddToWatchlist = useCallback(async () => { if (!searchQuery.trim()) return @@ -119,8 +246,11 @@ export function SearchTab({ showToast }: SearchTabProps) { // Debounced search useEffect(() => { const timer = setTimeout(() => { - if (searchQuery.length > 3) handleSearch(searchQuery) - else setSearchResult(null) + if (searchQuery.length >= 2) handleSearch(searchQuery) + else { + setSearchResult(null) + setTldResults([]) + } }, 500) return () => clearTimeout(timer) }, [searchQuery, handleSearch]) @@ -147,7 +277,7 @@ export function SearchTab({ showToast }: SearchTabProps) { onFocus={() => setSearchFocused(true)} onBlur={() => setSearchFocused(false)} onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)} - placeholder="example.com" + placeholder="domain or name.tld" className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono" /> {searchQuery && ( @@ -180,14 +310,29 @@ export function SearchTab({ showToast }: SearchTabProps) {
- {/* Search Result */} - {searchResult && ( + {/* Single Domain Result */} + {searchMode === 'single' && searchResult && (
{searchResult.loading ? (
Checking availability...
+ ) : searchResult.error ? ( + // Error state (invalid TLD, check failed, etc.) +
+
+
+
+ +
+
+
{searchResult.domain}
+
{searchResult.error}
+
+
+
+
) : (
)} + {/* Multi-TLD Results */} + {searchMode === 'multi' && tldResults.length > 0 && ( +
+
+ {/* Header */} +
+
+ + + Checking {POPULAR_TLDS.length} extensions for "{searchQuery.toLowerCase().replace(/\s+/g, '')}" + +
+ + {tldResults.filter(r => r.is_available === true).length} available + +
+ + {/* TLD Grid */} +
+ {tldResults.map((result) => ( +
{ + if (result.is_available && !result.loading) { + setSearchQuery(result.domain) + setSearchMode('single') + setTldResults([]) + handleSearch(result.domain) + } + }} + > + {result.loading ? ( +
+ +
+ ) : ( + <> +
+ + .{result.tld} + + {result.is_available ? ( + + ) : ( + + )} +
+
+ {result.is_available ? 'Available' : 'Taken'} +
+ + )} +
+ ))} +
+ + {/* Footer hint */} +
+ Click an available extension to see details • Add ".tld" to your search for specific extension +
+
+
+ )} + {/* Recent Searches */} {!searchResult && recentSearches.length > 0 && (
diff --git a/frontend/src/components/hunt/TrendSurferTab.tsx b/frontend/src/components/hunt/TrendSurferTab.tsx index 0ab9f26..44737cc 100644 --- a/frontend/src/components/hunt/TrendSurferTab.tsx +++ b/frontend/src/components/hunt/TrendSurferTab.tsx @@ -19,11 +19,14 @@ import { ShoppingCart, Flame, ArrowRight, - AlertCircle + AlertCircle, + Wand2, + Lock, } from 'lucide-react' import { api } from '@/lib/api' import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useStore } from '@/lib/store' +import Link from 'next/link' // ============================================================================ // TYPES & CONSTANTS @@ -56,6 +59,9 @@ function normalizeKeyword(s: string) { export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) { const openAnalyze = useAnalyzePanelStore((s) => s.open) const addDomain = useStore((s) => s.addDomain) + const subscription = useStore((s) => s.subscription) + const tier = (subscription?.tier || '').toLowerCase() + const hasAI = tier === 'trader' || tier === 'tycoon' // Trends State const [loading, setLoading] = useState(true) @@ -65,6 +71,11 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ const [selected, setSelected] = useState('') const [refreshing, setRefreshing] = useState(false) + // AI Expansion State + const [aiKeywords, setAiKeywords] = useState([]) + const [aiLoading, setAiLoading] = useState(false) + const [aiAnalysis, setAiAnalysis] = useState('') + // Keyword Check State const [keywordInput, setKeywordInput] = useState('') const [keywordFocused, setKeywordFocused] = useState(false) @@ -82,6 +93,29 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ const [tracking, setTracking] = useState(null) const [copied, setCopied] = useState(null) + // AI Keyword Expansion + const expandWithAI = useCallback(async () => { + if (!selected || !hasAI || aiLoading) return + setAiLoading(true) + setAiKeywords([]) + setAiAnalysis('') + try { + const [expandRes, analyzeRes] = await Promise.all([ + api.expandTrendKeywords(selected, geo), + api.analyzeTrend(selected, geo), + ]) + setAiKeywords(expandRes.keywords || []) + setAiAnalysis(analyzeRes.analysis || '') + if (expandRes.keywords?.length) { + showToast(`AI found ${expandRes.keywords.length} related keywords!`, 'success') + } + } catch (e) { + showToast(e instanceof Error ? e.message : 'AI expansion failed', 'error') + } finally { + setAiLoading(false) + } + }, [selected, geo, hasAI, aiLoading, showToast]) + const copyDomain = useCallback((domain: string) => { navigator.clipboard.writeText(domain) setCopied(domain) @@ -304,6 +338,77 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ )}
)} + + {/* AI Expansion Section */} + {selected && ( +
+
+
+ + AI Keyword Expansion + {!hasAI && ( + + TRADER+ + + )} +
+ {hasAI ? ( + + ) : ( + + + Upgrade + + )} +
+ + {/* AI Analysis */} + {aiAnalysis && ( +
+

{aiAnalysis}

+
+ )} + + {/* AI Keywords */} + {aiKeywords.length > 0 && ( +
+ {aiKeywords.map((kw) => ( + + ))} +
+ )} + + {!aiKeywords.length && !aiLoading && hasAI && ( +

+ Click "Expand with AI" to find related keywords for "{selected}" +

+ )} +
+ )}
{/* ═══════════════════════════════════════════════════════════════════════ */} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2b762a8..7ede93c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -246,6 +246,35 @@ class ApiClient { }) } + // LLM Naming (AI-powered suggestions for Trends & Forge) + async expandTrendKeywords(trend: string, geo: string = 'US') { + return this.request<{ keywords: string[]; trend: string }>('/naming/trends/expand', { + method: 'POST', + body: JSON.stringify({ trend, geo }), + }) + } + + async analyzeTrend(trend: string, geo: string = 'US') { + return this.request<{ analysis: string; trend: string }>('/naming/trends/analyze', { + method: 'POST', + body: JSON.stringify({ trend, geo }), + }) + } + + async generateBrandableNames(concept: string, style?: string, count: number = 15) { + return this.request<{ names: string[]; concept: string }>('/naming/forge/generate', { + method: 'POST', + body: JSON.stringify({ concept, style, count }), + }) + } + + async generateSimilarNames(brand: string, count: number = 12) { + return this.request<{ names: string[]; brand: string }>('/naming/forge/similar', { + method: 'POST', + body: JSON.stringify({ brand, count }), + }) + } + // CFO (Alpha Terminal - Management) async getCfoSummary() { return this.request<{