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.drops import router as drops_router
|
||||
from app.api.llm import router as llm_router
|
||||
from app.api.llm_agent import router as llm_agent_router
|
||||
from app.api.llm_naming import router as llm_naming_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -48,7 +48,7 @@ api_router.include_router(hunt_router, prefix="/hunt", tags=["Hunt"])
|
||||
api_router.include_router(cfo_router, prefix="/cfo", tags=["CFO"])
|
||||
api_router.include_router(drops_router, tags=["Drops - Zone Files"])
|
||||
api_router.include_router(llm_router, tags=["LLM"])
|
||||
api_router.include_router(llm_agent_router, tags=["LLM"])
|
||||
api_router.include_router(llm_naming_router, tags=["LLM Naming"])
|
||||
|
||||
# Marketplace (For Sale) - from analysis_3.md
|
||||
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"])
|
||||
|
||||
@ -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 { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider'
|
||||
import { BetaBanner } from '@/components/BetaBanner'
|
||||
import { HunterCompanion } from '@/components/chat/HunterCompanion'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function TerminalLayout({
|
||||
@ -65,7 +64,6 @@ export default function TerminalLayout({
|
||||
<AnalyzePanelProvider>
|
||||
<BetaBanner />
|
||||
{children}
|
||||
<HunterCompanion />
|
||||
</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,
|
||||
Lightbulb,
|
||||
RefreshCw,
|
||||
Lock,
|
||||
Brain,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { useStore } from '@/lib/store'
|
||||
@ -68,6 +72,12 @@ const TLDS = [
|
||||
export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
const addDomain = useStore((s) => s.addDomain)
|
||||
const subscription = useStore((s) => s.subscription)
|
||||
const tier = (subscription?.tier || '').toLowerCase()
|
||||
const hasAI = tier === 'trader' || tier === 'tycoon'
|
||||
|
||||
// Mode: 'pattern' (classic) or 'ai' (concept-based)
|
||||
const [mode, setMode] = useState<'pattern' | 'ai'>('pattern')
|
||||
|
||||
// Config State
|
||||
const [pattern, setPattern] = useState('cvcvc')
|
||||
@ -75,6 +85,14 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
||||
const [limit, setLimit] = useState(30)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
|
||||
// AI State
|
||||
const [concept, setConcept] = useState('')
|
||||
const [conceptFocused, setConceptFocused] = useState(false)
|
||||
const [similarBrand, setSimilarBrand] = useState('')
|
||||
const [similarFocused, setSimilarFocused] = useState(false)
|
||||
const [aiNames, setAiNames] = useState<string[]>([])
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
|
||||
// Results State
|
||||
const [loading, setLoading] = useState(false)
|
||||
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)
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-6">
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
@ -160,7 +232,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">Brandable Forge</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -203,7 +275,150 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
||||
</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 */}
|
||||
{mode === 'pattern' && (
|
||||
<div className="p-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lightbulb className="w-3.5 h-3.5 text-white/30" />
|
||||
@ -256,6 +471,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TLD Selection */}
|
||||
<div className="p-4 border-b border-white/[0.08]">
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// TYPES & CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
interface SearchResult {
|
||||
@ -35,8 +35,29 @@ interface SearchResult {
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
loading: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface TldCheckResult {
|
||||
tld: string
|
||||
domain: string
|
||||
is_available: boolean | null
|
||||
loading: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Popular TLDs to check when user enters only a name without extension
|
||||
const POPULAR_TLDS = ['com', 'net', 'org', 'io', 'ch', 'de', 'app', 'dev', 'co', 'ai']
|
||||
|
||||
// Known valid TLDs (subset for quick validation)
|
||||
const KNOWN_TLDS = new Set([
|
||||
'com', 'net', 'org', 'io', 'ch', 'de', 'app', 'dev', 'co', 'ai', 'me', 'tv', 'cc',
|
||||
'xyz', 'info', 'biz', 'online', 'site', 'tech', 'store', 'club', 'shop', 'blog',
|
||||
'uk', 'fr', 'nl', 'eu', 'be', 'at', 'us', 'ca', 'au', 'li', 'it', 'es', 'pl',
|
||||
'pro', 'mobi', 'name', 'page', 'new', 'day', 'world', 'email', 'link', 'click',
|
||||
'digital', 'media', 'agency', 'studio', 'design', 'marketing', 'solutions',
|
||||
])
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENT
|
||||
// ============================================================================
|
||||
@ -51,9 +72,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResult, setSearchResult] = useState<SearchResult | null>(null)
|
||||
const [tldResults, setTldResults] = useState<TldCheckResult[]>([])
|
||||
const [addingToWatchlist, setAddingToWatchlist] = useState(false)
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
||||
const [searchMode, setSearchMode] = useState<'single' | 'multi'>('single')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Load recent searches from localStorage
|
||||
@ -78,30 +101,134 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Check if TLD is valid
|
||||
const isValidTld = useCallback((tld: string): boolean => {
|
||||
return KNOWN_TLDS.has(tld.toLowerCase())
|
||||
}, [])
|
||||
|
||||
// Check single domain
|
||||
const checkSingleDomain = useCallback(async (domain: string): Promise<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
|
||||
const handleSearch = useCallback(async (domainInput: string) => {
|
||||
if (!domainInput.trim()) {
|
||||
setSearchResult(null)
|
||||
setTldResults([])
|
||||
return
|
||||
}
|
||||
const cleanDomain = domainInput.trim().toLowerCase()
|
||||
setSearchResult({ domain: cleanDomain, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true })
|
||||
|
||||
try {
|
||||
const whoisResult = await api.checkDomain(cleanDomain).catch(() => null)
|
||||
setSearchResult({
|
||||
domain: whoisResult?.domain || cleanDomain,
|
||||
status: whoisResult?.status || 'unknown',
|
||||
is_available: whoisResult?.is_available ?? null,
|
||||
registrar: whoisResult?.registrar || null,
|
||||
expiration_date: whoisResult?.expiration_date || null,
|
||||
loading: false,
|
||||
})
|
||||
saveToRecent(cleanDomain)
|
||||
} catch {
|
||||
setSearchResult({ domain: cleanDomain, status: 'error', is_available: null, registrar: null, expiration_date: null, loading: false })
|
||||
const cleanInput = domainInput.trim().toLowerCase().replace(/\s+/g, '')
|
||||
|
||||
// Check if input contains a dot (has TLD)
|
||||
if (cleanInput.includes('.')) {
|
||||
// Single domain mode
|
||||
setSearchMode('single')
|
||||
setTldResults([])
|
||||
|
||||
const parts = cleanInput.split('.')
|
||||
const tld = parts[parts.length - 1]
|
||||
|
||||
// Check if TLD is valid
|
||||
if (!isValidTld(tld)) {
|
||||
setSearchResult({
|
||||
domain: cleanInput,
|
||||
status: 'invalid_tld',
|
||||
is_available: null,
|
||||
registrar: null,
|
||||
expiration_date: null,
|
||||
loading: false,
|
||||
error: `".${tld}" is not a valid domain extension`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSearchResult({ domain: cleanInput, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true })
|
||||
|
||||
const result = await checkSingleDomain(cleanInput)
|
||||
setSearchResult(result)
|
||||
if (!result.error) saveToRecent(cleanInput)
|
||||
} else {
|
||||
// Multi-TLD mode - check multiple extensions
|
||||
setSearchMode('multi')
|
||||
setSearchResult(null)
|
||||
|
||||
// Validate the name part
|
||||
if (cleanInput.length < 1 || cleanInput.length > 63) {
|
||||
setTldResults([])
|
||||
showToast('Domain name must be 1-63 characters', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(cleanInput) || cleanInput.startsWith('-') || cleanInput.endsWith('-')) {
|
||||
setTldResults([])
|
||||
showToast('Domain name contains invalid characters', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
await checkMultipleTlds(cleanInput)
|
||||
saveToRecent(cleanInput)
|
||||
}
|
||||
}, [saveToRecent])
|
||||
}, [saveToRecent, checkSingleDomain, checkMultipleTlds, isValidTld, showToast])
|
||||
|
||||
const handleAddToWatchlist = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
@ -119,8 +246,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (searchQuery.length > 3) handleSearch(searchQuery)
|
||||
else setSearchResult(null)
|
||||
if (searchQuery.length >= 2) handleSearch(searchQuery)
|
||||
else {
|
||||
setSearchResult(null)
|
||||
setTldResults([])
|
||||
}
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, handleSearch])
|
||||
@ -147,7 +277,7 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)}
|
||||
placeholder="example.com"
|
||||
placeholder="domain or name.tld"
|
||||
className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono"
|
||||
/>
|
||||
{searchQuery && (
|
||||
@ -180,14 +310,29 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search Result */}
|
||||
{searchResult && (
|
||||
{/* Single Domain Result */}
|
||||
{searchMode === 'single' && searchResult && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
{searchResult.loading ? (
|
||||
<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" />
|
||||
<span className="text-sm text-white/50 font-mono">Checking availability...</span>
|
||||
</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(
|
||||
"border-2 overflow-hidden bg-[#020202]",
|
||||
@ -294,6 +439,84 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
</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 */}
|
||||
{!searchResult && recentSearches.length > 0 && (
|
||||
<div className="border border-white/[0.08] bg-white/[0.02]">
|
||||
|
||||
@ -19,11 +19,14 @@ import {
|
||||
ShoppingCart,
|
||||
Flame,
|
||||
ArrowRight,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Wand2,
|
||||
Lock,
|
||||
} from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { useStore } from '@/lib/store'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & CONSTANTS
|
||||
@ -56,6 +59,9 @@ function normalizeKeyword(s: string) {
|
||||
export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
const addDomain = useStore((s) => s.addDomain)
|
||||
const subscription = useStore((s) => s.subscription)
|
||||
const tier = (subscription?.tier || '').toLowerCase()
|
||||
const hasAI = tier === 'trader' || tier === 'tycoon'
|
||||
|
||||
// Trends State
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -65,6 +71,11 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
|
||||
const [selected, setSelected] = useState<string>('')
|
||||
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
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
const [keywordFocused, setKeywordFocused] = useState(false)
|
||||
@ -82,6 +93,29 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
|
||||
const [tracking, setTracking] = useState<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) => {
|
||||
navigator.clipboard.writeText(domain)
|
||||
setCopied(domain)
|
||||
@ -304,6 +338,77 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
|
||||
@ -246,6 +246,35 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// LLM Naming (AI-powered suggestions for Trends & Forge)
|
||||
async expandTrendKeywords(trend: string, geo: string = 'US') {
|
||||
return this.request<{ keywords: string[]; trend: string }>('/naming/trends/expand', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ trend, geo }),
|
||||
})
|
||||
}
|
||||
|
||||
async analyzeTrend(trend: string, geo: string = 'US') {
|
||||
return this.request<{ analysis: string; trend: string }>('/naming/trends/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ trend, geo }),
|
||||
})
|
||||
}
|
||||
|
||||
async generateBrandableNames(concept: string, style?: string, count: number = 15) {
|
||||
return this.request<{ names: string[]; concept: string }>('/naming/forge/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ concept, style, count }),
|
||||
})
|
||||
}
|
||||
|
||||
async generateSimilarNames(brand: string, count: number = 12) {
|
||||
return this.request<{ names: string[]; brand: string }>('/naming/forge/similar', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ brand, count }),
|
||||
})
|
||||
}
|
||||
|
||||
// CFO (Alpha Terminal - Management)
|
||||
async getCfoSummary() {
|
||||
return this.request<{
|
||||
|
||||
Reference in New Issue
Block a user