Remove chat companion, add LLM naming for Trends & Forge tabs (AI keyword expansion, concept generator)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
This commit is contained in:
@ -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.cfo import router as cfo_router
|
||||||
from app.api.drops import router as drops_router
|
from app.api.drops import router as drops_router
|
||||||
from app.api.llm import router as llm_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()
|
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(cfo_router, prefix="/cfo", tags=["CFO"])
|
||||||
api_router.include_router(drops_router, tags=["Drops - Zone Files"])
|
api_router.include_router(drops_router, tags=["Drops - Zone Files"])
|
||||||
api_router.include_router(llm_router, tags=["LLM"])
|
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
|
# Marketplace (For Sale) - from analysis_3.md
|
||||||
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"])
|
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"])
|
||||||
|
|||||||
@ -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}
|
|
||||||
171
backend/app/api/llm_naming.py
Normal file
171
backend/app/api/llm_naming.py
Normal file
@ -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)
|
||||||
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
@ -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
|
|
||||||
179
backend/app/services/llm_naming.py
Normal file
179
backend/app/services/llm_naming.py
Normal file
@ -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 []
|
||||||
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
|
|
||||||
@ -5,7 +5,6 @@ import { useRouter, usePathname } from 'next/navigation'
|
|||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider'
|
import { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider'
|
||||||
import { BetaBanner } from '@/components/BetaBanner'
|
import { BetaBanner } from '@/components/BetaBanner'
|
||||||
import { HunterCompanion } from '@/components/chat/HunterCompanion'
|
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
export default function TerminalLayout({
|
export default function TerminalLayout({
|
||||||
@ -65,7 +64,6 @@ export default function TerminalLayout({
|
|||||||
<AnalyzePanelProvider>
|
<AnalyzePanelProvider>
|
||||||
<BetaBanner />
|
<BetaBanner />
|
||||||
{children}
|
{children}
|
||||||
<HunterCompanion />
|
|
||||||
</AnalyzePanelProvider>
|
</AnalyzePanelProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<void> {
|
|
||||||
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<ChatMessage[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const inputRef = useRef<HTMLInputElement>(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 */}
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className={clsx(
|
|
||||||
'fixed bottom-5 right-5 z-[150] w-14 h-14 flex items-center justify-center',
|
|
||||||
'bg-[#0a0a0a] border transition-transform hover:scale-105',
|
|
||||||
canChat ? 'border-accent/40 text-accent' : 'border-white/10 text-white/40'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{canChat && <span className="absolute -top-1 -left-1 w-2 h-2 bg-accent" />}
|
|
||||||
{!canChat && <Lock className="absolute -top-1 -right-1 w-3 h-3" />}
|
|
||||||
<MessageSquare className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Chat Modal */}
|
|
||||||
{open && (
|
|
||||||
<div className="fixed inset-0 z-[160]">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div className="absolute inset-0 bg-black/80" onClick={() => setOpen(false)} />
|
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<div className="absolute bottom-4 right-4 w-[90vw] max-w-[380px] h-[65vh] max-h-[550px] bg-[#0a0a0a] border border-white/10 flex flex-col">
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={clsx('w-2 h-2', canChat ? 'bg-accent' : 'bg-white/20')} />
|
|
||||||
<span className="text-xs font-mono text-accent tracking-wider uppercase">
|
|
||||||
Hunter Companion
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{canChat && messages.length > 0 && (
|
|
||||||
<button onClick={clear} className="p-2 text-white/40 hover:text-white">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={() => setOpen(false)} className="p-2 text-white/40 hover:text-white">
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
|
|
||||||
{!canChat ? (
|
|
||||||
/* Scout - Locked */
|
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-4">
|
|
||||||
<Lock className="w-10 h-10 text-white/20 mb-4" />
|
|
||||||
<h3 className="text-sm font-medium text-white mb-2">Hunter Companion</h3>
|
|
||||||
<p className="text-xs text-white/50 mb-6 leading-relaxed">
|
|
||||||
AI-powered domain analysis, auction alerts, and portfolio insights.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
className="px-5 py-2 bg-accent text-black text-xs font-bold uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Upgrade to Trader
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : messages.length === 0 ? (
|
|
||||||
/* Empty State */
|
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center">
|
|
||||||
<Sparkles className="w-8 h-8 text-accent/50 mb-3" />
|
|
||||||
<p className="text-xs text-white/50 mb-4">
|
|
||||||
Type a domain or ask a question
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
|
||||||
{QUICK_ACTIONS.map((qa, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => {
|
|
||||||
if (qa.action.endsWith(' ')) {
|
|
||||||
setInput(qa.action)
|
|
||||||
inputRef.current?.focus()
|
|
||||||
} else {
|
|
||||||
send(qa.action)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 text-[10px] font-mono border border-accent/20 text-accent hover:bg-accent/10"
|
|
||||||
>
|
|
||||||
{qa.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Messages */
|
|
||||||
<div className="space-y-3">
|
|
||||||
{messages.map((m) => (
|
|
||||||
<div key={m.id} className={clsx('flex', m.role === 'user' ? 'justify-end' : 'justify-start')}>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'max-w-[85%] px-3 py-2 text-xs whitespace-pre-wrap leading-relaxed',
|
|
||||||
m.role === 'user'
|
|
||||||
? 'bg-accent/10 border border-accent/20 text-accent font-mono'
|
|
||||||
: 'bg-white/5 border border-white/10 text-white/80'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{m.content || (loading ? '...' : '')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
{canChat && (
|
|
||||||
<div className="p-3 border-t border-white/10">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => 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}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => send()}
|
|
||||||
disabled={loading || !input.trim()}
|
|
||||||
className="w-10 h-10 flex items-center justify-center border border-accent/30 bg-accent/10 text-accent disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -17,7 +17,11 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Lock,
|
||||||
|
Brain,
|
||||||
|
MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
@ -68,6 +72,12 @@ const TLDS = [
|
|||||||
export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
|
export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
|
||||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||||
const addDomain = useStore((s) => s.addDomain)
|
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
|
// Config State
|
||||||
const [pattern, setPattern] = useState('cvcvc')
|
const [pattern, setPattern] = useState('cvcvc')
|
||||||
@ -75,6 +85,14 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
|||||||
const [limit, setLimit] = useState(30)
|
const [limit, setLimit] = useState(30)
|
||||||
const [showConfig, setShowConfig] = useState(false)
|
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<string[]>([])
|
||||||
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
|
|
||||||
// Results State
|
// Results State
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [items, setItems] = useState<Array<{ domain: string; status: string }>>([])
|
const [items, setItems] = useState<Array<{ domain: string; status: string }>>([])
|
||||||
@ -144,6 +162,60 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
|||||||
|
|
||||||
const currentPattern = PATTERNS.find(p => p.key === pattern)
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
@ -160,7 +232,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-white">Brandable Forge</h3>
|
<h3 className="text-base font-bold text-white">Brandable Forge</h3>
|
||||||
<p className="text-[11px] font-mono text-white/40">
|
<p className="text-[11px] font-mono text-white/40">
|
||||||
AI-powered brandable name generator
|
{mode === 'pattern' ? 'Pattern-based name generator' : 'AI-powered concept generator'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -203,7 +275,150 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="px-4 py-3 border-b border-white/[0.08] bg-white/[0.01]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('pattern')}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-all",
|
||||||
|
mode === 'pattern'
|
||||||
|
? "border-accent bg-accent/10 text-accent"
|
||||||
|
: "border-white/10 text-white/40 hover:text-white hover:border-white/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Lightbulb className="w-3.5 h-3.5" />
|
||||||
|
Patterns
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => hasAI && setMode('ai')}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-all",
|
||||||
|
!hasAI && "opacity-50 cursor-not-allowed",
|
||||||
|
mode === 'ai'
|
||||||
|
? "border-purple-500 bg-purple-500/10 text-purple-400"
|
||||||
|
: "border-white/10 text-white/40 hover:text-white hover:border-white/20"
|
||||||
|
)}
|
||||||
|
disabled={!hasAI}
|
||||||
|
>
|
||||||
|
<Brain className="w-3.5 h-3.5" />
|
||||||
|
AI Concept
|
||||||
|
{!hasAI && <Lock className="w-3 h-3 ml-1" />}
|
||||||
|
</button>
|
||||||
|
{!hasAI && (
|
||||||
|
<Link href="/pricing" className="ml-auto text-[10px] font-mono text-accent hover:underline">
|
||||||
|
Upgrade for AI
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Concept Mode */}
|
||||||
|
{mode === 'ai' && hasAI && (
|
||||||
|
<div className="p-4 border-b border-white/[0.08] space-y-4">
|
||||||
|
{/* Concept Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-mono text-white/40 mb-2 uppercase tracking-wider">
|
||||||
|
Describe your brand concept
|
||||||
|
</label>
|
||||||
|
<div className={clsx(
|
||||||
|
"flex border-2 transition-all",
|
||||||
|
conceptFocused ? "border-purple-400/50 bg-purple-400/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
|
||||||
|
)}>
|
||||||
|
<MessageSquare className={clsx("w-4 h-4 m-3.5 transition-colors", conceptFocused ? "text-purple-400" : "text-white/30")} />
|
||||||
|
<input
|
||||||
|
value={concept}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={generateFromConcept}
|
||||||
|
disabled={!concept.trim() || aiLoading}
|
||||||
|
className={clsx(
|
||||||
|
"px-4 m-1.5 text-[10px] font-bold uppercase flex items-center gap-1.5 transition-all",
|
||||||
|
!concept.trim() || aiLoading
|
||||||
|
? "bg-white/5 text-white/20"
|
||||||
|
: "bg-purple-500 text-white hover:bg-purple-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{aiLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Similar Brand Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-mono text-white/40 mb-2 uppercase tracking-wider">
|
||||||
|
Or find names similar to a brand
|
||||||
|
</label>
|
||||||
|
<div className={clsx(
|
||||||
|
"flex border-2 transition-all",
|
||||||
|
similarFocused ? "border-purple-400/50 bg-purple-400/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
|
||||||
|
)}>
|
||||||
|
<Star className={clsx("w-4 h-4 m-3.5 transition-colors", similarFocused ? "text-purple-400" : "text-white/30")} />
|
||||||
|
<input
|
||||||
|
value={similarBrand}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={generateFromBrand}
|
||||||
|
disabled={!similarBrand.trim() || aiLoading}
|
||||||
|
className={clsx(
|
||||||
|
"px-4 m-1.5 text-[10px] font-bold uppercase flex items-center gap-1.5 transition-all",
|
||||||
|
!similarBrand.trim() || aiLoading
|
||||||
|
? "bg-white/5 text-white/20"
|
||||||
|
: "bg-purple-500 text-white hover:bg-purple-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{aiLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
|
||||||
|
Find Similar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Generated Names */}
|
||||||
|
{aiNames.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
|
||||||
|
AI Suggestions ({aiNames.length})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={checkAiNames}
|
||||||
|
disabled={loading || selectedTlds.length === 0}
|
||||||
|
className="flex items-center gap-1.5 text-[10px] font-mono text-purple-400 hover:text-purple-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Check Availability
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{aiNames.map((name) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
className="px-3 py-1.5 text-[11px] font-mono text-purple-400 bg-purple-500/10 border border-purple-500/20"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pattern Selection */}
|
{/* Pattern Selection */}
|
||||||
|
{mode === 'pattern' && (
|
||||||
<div className="p-4 border-b border-white/[0.08]">
|
<div className="p-4 border-b border-white/[0.08]">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Lightbulb className="w-3.5 h-3.5 text-white/30" />
|
<Lightbulb className="w-3.5 h-3.5 text-white/30" />
|
||||||
@ -256,6 +471,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* TLD Selection */}
|
{/* TLD Selection */}
|
||||||
<div className="p-4 border-b border-white/[0.08]">
|
<div className="p-4 border-b border-white/[0.08]">
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import {
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES & CONSTANTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
@ -35,8 +35,29 @@ interface SearchResult {
|
|||||||
registrar: string | null
|
registrar: string | null
|
||||||
expiration_date: string | null
|
expiration_date: string | null
|
||||||
loading: boolean
|
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
|
// COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -51,9 +72,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
|||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [searchResult, setSearchResult] = useState<SearchResult | null>(null)
|
const [searchResult, setSearchResult] = useState<SearchResult | null>(null)
|
||||||
|
const [tldResults, setTldResults] = useState<TldCheckResult[]>([])
|
||||||
const [addingToWatchlist, setAddingToWatchlist] = useState(false)
|
const [addingToWatchlist, setAddingToWatchlist] = useState(false)
|
||||||
const [searchFocused, setSearchFocused] = useState(false)
|
const [searchFocused, setSearchFocused] = useState(false)
|
||||||
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
||||||
|
const [searchMode, setSearchMode] = useState<'single' | 'multi'>('single')
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Load recent searches from localStorage
|
// 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<SearchResult> => {
|
||||||
|
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<TldCheckResult> => {
|
||||||
|
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
|
// Search Handler
|
||||||
const handleSearch = useCallback(async (domainInput: string) => {
|
const handleSearch = useCallback(async (domainInput: string) => {
|
||||||
if (!domainInput.trim()) {
|
if (!domainInput.trim()) {
|
||||||
setSearchResult(null)
|
setSearchResult(null)
|
||||||
|
setTldResults([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cleanDomain = domainInput.trim().toLowerCase()
|
|
||||||
setSearchResult({ domain: cleanDomain, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true })
|
const cleanInput = domainInput.trim().toLowerCase().replace(/\s+/g, '')
|
||||||
|
|
||||||
try {
|
// Check if input contains a dot (has TLD)
|
||||||
const whoisResult = await api.checkDomain(cleanDomain).catch(() => null)
|
if (cleanInput.includes('.')) {
|
||||||
setSearchResult({
|
// Single domain mode
|
||||||
domain: whoisResult?.domain || cleanDomain,
|
setSearchMode('single')
|
||||||
status: whoisResult?.status || 'unknown',
|
setTldResults([])
|
||||||
is_available: whoisResult?.is_available ?? null,
|
|
||||||
registrar: whoisResult?.registrar || null,
|
const parts = cleanInput.split('.')
|
||||||
expiration_date: whoisResult?.expiration_date || null,
|
const tld = parts[parts.length - 1]
|
||||||
loading: false,
|
|
||||||
})
|
// Check if TLD is valid
|
||||||
saveToRecent(cleanDomain)
|
if (!isValidTld(tld)) {
|
||||||
} catch {
|
setSearchResult({
|
||||||
setSearchResult({ domain: cleanDomain, status: 'error', is_available: null, registrar: null, expiration_date: null, loading: false })
|
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 () => {
|
const handleAddToWatchlist = useCallback(async () => {
|
||||||
if (!searchQuery.trim()) return
|
if (!searchQuery.trim()) return
|
||||||
@ -119,8 +246,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
|||||||
// Debounced search
|
// Debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (searchQuery.length > 3) handleSearch(searchQuery)
|
if (searchQuery.length >= 2) handleSearch(searchQuery)
|
||||||
else setSearchResult(null)
|
else {
|
||||||
|
setSearchResult(null)
|
||||||
|
setTldResults([])
|
||||||
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [searchQuery, handleSearch])
|
}, [searchQuery, handleSearch])
|
||||||
@ -147,7 +277,7 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
|||||||
onFocus={() => setSearchFocused(true)}
|
onFocus={() => setSearchFocused(true)}
|
||||||
onBlur={() => setSearchFocused(false)}
|
onBlur={() => setSearchFocused(false)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)}
|
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"
|
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 && (
|
{searchQuery && (
|
||||||
@ -180,14 +310,29 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Result */}
|
{/* Single Domain Result */}
|
||||||
{searchResult && (
|
{searchMode === 'single' && searchResult && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-200">
|
<div className="animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||||
{searchResult.loading ? (
|
{searchResult.loading ? (
|
||||||
<div className="flex items-center justify-center gap-3 py-12 border border-white/[0.08] bg-white/[0.02]">
|
<div className="flex items-center justify-center gap-3 py-12 border border-white/[0.08] bg-white/[0.02]">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-accent" />
|
<Loader2 className="w-6 h-6 animate-spin text-accent" />
|
||||||
<span className="text-sm text-white/50 font-mono">Checking availability...</span>
|
<span className="text-sm text-white/50 font-mono">Checking availability...</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : searchResult.error ? (
|
||||||
|
// Error state (invalid TLD, check failed, etc.)
|
||||||
|
<div className="border-2 border-rose-500/30 overflow-hidden bg-[#020202]">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 flex items-center justify-center border border-rose-500/30 bg-rose-500/10 shrink-0">
|
||||||
|
<XCircle className="w-6 h-6 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-lg font-bold text-white font-mono truncate">{searchResult.domain}</div>
|
||||||
|
<div className="text-sm text-rose-400 mt-1">{searchResult.error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"border-2 overflow-hidden bg-[#020202]",
|
"border-2 overflow-hidden bg-[#020202]",
|
||||||
@ -294,6 +439,84 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Multi-TLD Results */}
|
||||||
|
{searchMode === 'multi' && tldResults.length > 0 && (
|
||||||
|
<div className="animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||||
|
<div className="border border-white/[0.08] bg-[#020202] overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
|
||||||
|
Checking {POPULAR_TLDS.length} extensions for "{searchQuery.toLowerCase().replace(/\s+/g, '')}"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-accent">
|
||||||
|
{tldResults.filter(r => r.is_available === true).length} available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TLD Grid */}
|
||||||
|
<div className="p-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||||
|
{tldResults.map((result) => (
|
||||||
|
<div
|
||||||
|
key={result.tld}
|
||||||
|
className={clsx(
|
||||||
|
"p-3 border transition-all",
|
||||||
|
result.loading
|
||||||
|
? "border-white/[0.08] bg-white/[0.02]"
|
||||||
|
: result.is_available
|
||||||
|
? "border-accent/40 bg-accent/[0.05] hover:bg-accent/10 cursor-pointer"
|
||||||
|
: "border-white/[0.06] bg-white/[0.01]"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (result.is_available && !result.loading) {
|
||||||
|
setSearchQuery(result.domain)
|
||||||
|
setSearchMode('single')
|
||||||
|
setTldResults([])
|
||||||
|
handleSearch(result.domain)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.loading ? (
|
||||||
|
<div className="flex items-center justify-center py-1">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-white/30" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs font-mono font-bold",
|
||||||
|
result.is_available ? "text-accent" : "text-white/30"
|
||||||
|
)}>
|
||||||
|
.{result.tld}
|
||||||
|
</span>
|
||||||
|
{result.is_available ? (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-accent" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-3.5 h-3.5 text-white/20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={clsx(
|
||||||
|
"text-[9px] font-mono uppercase",
|
||||||
|
result.is_available ? "text-accent/60" : "text-white/20"
|
||||||
|
)}>
|
||||||
|
{result.is_available ? 'Available' : 'Taken'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<div className="px-4 py-2 border-t border-white/[0.06] text-[9px] font-mono text-white/30 text-center">
|
||||||
|
Click an available extension to see details • Add ".tld" to your search for specific extension
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recent Searches */}
|
{/* Recent Searches */}
|
||||||
{!searchResult && recentSearches.length > 0 && (
|
{!searchResult && recentSearches.length > 0 && (
|
||||||
<div className="border border-white/[0.08] bg-white/[0.02]">
|
<div className="border border-white/[0.08] bg-white/[0.02]">
|
||||||
|
|||||||
@ -19,11 +19,14 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Flame,
|
Flame,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
AlertCircle
|
AlertCircle,
|
||||||
|
Wand2,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES & CONSTANTS
|
// TYPES & CONSTANTS
|
||||||
@ -56,6 +59,9 @@ function normalizeKeyword(s: string) {
|
|||||||
export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
|
export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
|
||||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||||
const addDomain = useStore((s) => s.addDomain)
|
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
|
// Trends State
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -65,6 +71,11 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
|
|||||||
const [selected, setSelected] = useState<string>('')
|
const [selected, setSelected] = useState<string>('')
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
|
// AI Expansion State
|
||||||
|
const [aiKeywords, setAiKeywords] = useState<string[]>([])
|
||||||
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
|
const [aiAnalysis, setAiAnalysis] = useState<string>('')
|
||||||
|
|
||||||
// Keyword Check State
|
// Keyword Check State
|
||||||
const [keywordInput, setKeywordInput] = useState('')
|
const [keywordInput, setKeywordInput] = useState('')
|
||||||
const [keywordFocused, setKeywordFocused] = useState(false)
|
const [keywordFocused, setKeywordFocused] = useState(false)
|
||||||
@ -82,6 +93,29 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
|
|||||||
const [tracking, setTracking] = useState<string | null>(null)
|
const [tracking, setTracking] = useState<string | null>(null)
|
||||||
const [copied, setCopied] = useState<string | null>(null)
|
const [copied, setCopied] = useState<string | null>(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) => {
|
const copyDomain = useCallback((domain: string) => {
|
||||||
navigator.clipboard.writeText(domain)
|
navigator.clipboard.writeText(domain)
|
||||||
setCopied(domain)
|
setCopied(domain)
|
||||||
@ -304,6 +338,77 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Expansion Section */}
|
||||||
|
{selected && (
|
||||||
|
<div className="px-4 pb-4 border-t border-white/[0.08] pt-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wand2 className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-xs font-mono text-white/60">AI Keyword Expansion</span>
|
||||||
|
{!hasAI && (
|
||||||
|
<span className="text-[9px] font-mono text-white/30 bg-white/5 px-1.5 py-0.5 border border-white/10">
|
||||||
|
TRADER+
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasAI ? (
|
||||||
|
<button
|
||||||
|
onClick={expandWithAI}
|
||||||
|
disabled={aiLoading}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider transition-all",
|
||||||
|
aiLoading
|
||||||
|
? "bg-purple-500/20 text-purple-300 cursor-wait"
|
||||||
|
: "bg-purple-500/20 text-purple-400 hover:bg-purple-500/30 border border-purple-500/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{aiLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
||||||
|
{aiLoading ? 'Expanding...' : 'Expand with AI'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-mono text-white/40 border border-white/10 hover:border-white/20 hover:text-white/60 transition-all"
|
||||||
|
>
|
||||||
|
<Lock className="w-3 h-3" />
|
||||||
|
Upgrade
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Analysis */}
|
||||||
|
{aiAnalysis && (
|
||||||
|
<div className="p-3 bg-purple-500/5 border border-purple-500/20 mb-3">
|
||||||
|
<p className="text-xs text-white/70 leading-relaxed">{aiAnalysis}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Keywords */}
|
||||||
|
{aiKeywords.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{aiKeywords.map((kw) => (
|
||||||
|
<button
|
||||||
|
key={kw}
|
||||||
|
onClick={() => {
|
||||||
|
setKeywordInput(kw)
|
||||||
|
setAvailability([])
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-[11px] font-mono text-purple-400 bg-purple-500/10 border border-purple-500/20 hover:bg-purple-500/20 transition-all"
|
||||||
|
>
|
||||||
|
{kw}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!aiKeywords.length && !aiLoading && hasAI && (
|
||||||
|
<p className="text-[10px] font-mono text-white/30 text-center py-2">
|
||||||
|
Click "Expand with AI" to find related keywords for "{selected}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
|
|||||||
@ -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)
|
// CFO (Alpha Terminal - Management)
|
||||||
async getCfoSummary() {
|
async getCfoSummary() {
|
||||||
return this.request<{
|
return this.request<{
|
||||||
|
|||||||
Reference in New Issue
Block a user