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

This commit is contained in:
2025-12-17 15:45:16 +01:00
parent e75c9bc9ef
commit e135c3258b
13 changed files with 950 additions and 2268 deletions

View File

@ -28,7 +28,7 @@ from app.api.hunt import router as hunt_router
from app.api.cfo import router as cfo_router from app.api.cfo import router as cfo_router
from app.api.drops import router as drops_router from app.api.drops import router as drops_router
from app.api.llm import router as llm_router from app.api.llm import router as llm_router
from app.api.llm_agent import router as llm_agent_router from app.api.llm_naming import router as llm_naming_router
api_router = APIRouter() api_router = APIRouter()
@ -48,7 +48,7 @@ api_router.include_router(hunt_router, prefix="/hunt", tags=["Hunt"])
api_router.include_router(cfo_router, prefix="/cfo", tags=["CFO"]) api_router.include_router(cfo_router, prefix="/cfo", tags=["CFO"])
api_router.include_router(drops_router, tags=["Drops - Zone Files"]) api_router.include_router(drops_router, tags=["Drops - Zone Files"])
api_router.include_router(llm_router, tags=["LLM"]) api_router.include_router(llm_router, tags=["LLM"])
api_router.include_router(llm_agent_router, tags=["LLM"]) api_router.include_router(llm_naming_router, tags=["LLM Naming"])
# Marketplace (For Sale) - from analysis_3.md # Marketplace (For Sale) - from analysis_3.md
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"]) api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"])

View File

@ -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}

View 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)

View File

@ -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

View File

@ -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

View 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 []

View File

@ -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

View File

@ -5,7 +5,6 @@ import { useRouter, usePathname } from 'next/navigation'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider' import { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider'
import { BetaBanner } from '@/components/BetaBanner' import { BetaBanner } from '@/components/BetaBanner'
import { HunterCompanion } from '@/components/chat/HunterCompanion'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
export default function TerminalLayout({ export default function TerminalLayout({
@ -65,7 +64,6 @@ export default function TerminalLayout({
<AnalyzePanelProvider> <AnalyzePanelProvider>
<BetaBanner /> <BetaBanner />
{children} {children}
<HunterCompanion />
</AnalyzePanelProvider> </AnalyzePanelProvider>
) )
} }

View File

@ -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>
)}
</>
)
}

View File

@ -17,7 +17,11 @@ import {
Star, Star,
Lightbulb, Lightbulb,
RefreshCw, RefreshCw,
Lock,
Brain,
MessageSquare,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
@ -68,6 +72,12 @@ const TLDS = [
export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) { export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
const openAnalyze = useAnalyzePanelStore((s) => s.open) const openAnalyze = useAnalyzePanelStore((s) => s.open)
const addDomain = useStore((s) => s.addDomain) const addDomain = useStore((s) => s.addDomain)
const subscription = useStore((s) => s.subscription)
const tier = (subscription?.tier || '').toLowerCase()
const hasAI = tier === 'trader' || tier === 'tycoon'
// Mode: 'pattern' (classic) or 'ai' (concept-based)
const [mode, setMode] = useState<'pattern' | 'ai'>('pattern')
// Config State // Config State
const [pattern, setPattern] = useState('cvcvc') const [pattern, setPattern] = useState('cvcvc')
@ -75,6 +85,14 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
const [limit, setLimit] = useState(30) const [limit, setLimit] = useState(30)
const [showConfig, setShowConfig] = useState(false) const [showConfig, setShowConfig] = useState(false)
// AI State
const [concept, setConcept] = useState('')
const [conceptFocused, setConceptFocused] = useState(false)
const [similarBrand, setSimilarBrand] = useState('')
const [similarFocused, setSimilarFocused] = useState(false)
const [aiNames, setAiNames] = useState<string[]>([])
const [aiLoading, setAiLoading] = useState(false)
// Results State // Results State
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [items, setItems] = useState<Array<{ domain: string; status: string }>>([]) const [items, setItems] = useState<Array<{ domain: string; status: string }>>([])
@ -144,6 +162,60 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
const currentPattern = PATTERNS.find(p => p.key === pattern) const currentPattern = PATTERNS.find(p => p.key === pattern)
// AI Generation
const generateFromConcept = useCallback(async () => {
if (!concept.trim() || !hasAI || aiLoading) return
setAiLoading(true)
setAiNames([])
try {
const res = await api.generateBrandableNames(concept.trim(), undefined, 15)
setAiNames(res.names || [])
if (res.names?.length) {
showToast(`AI generated ${res.names.length} names!`, 'success')
} else {
showToast('No names generated. Try a different concept.', 'info')
}
} catch (e) {
showToast(e instanceof Error ? e.message : 'AI generation failed', 'error')
} finally {
setAiLoading(false)
}
}, [concept, hasAI, aiLoading, showToast])
const generateFromBrand = useCallback(async () => {
if (!similarBrand.trim() || !hasAI || aiLoading) return
setAiLoading(true)
setAiNames([])
try {
const res = await api.generateSimilarNames(similarBrand.trim(), 12)
setAiNames(res.names || [])
if (res.names?.length) {
showToast(`AI found ${res.names.length} similar names!`, 'success')
}
} catch (e) {
showToast(e instanceof Error ? e.message : 'AI generation failed', 'error')
} finally {
setAiLoading(false)
}
}, [similarBrand, hasAI, aiLoading, showToast])
// Check AI-generated names for availability
const checkAiNames = useCallback(async () => {
if (aiNames.length === 0 || selectedTlds.length === 0) return
setLoading(true)
setItems([])
try {
const res = await api.huntKeywords({ keywords: aiNames, tlds: selectedTlds })
const available = res.items.filter(i => i.status === 'available')
setItems(available.map(i => ({ domain: i.domain, status: i.status })))
showToast(`Found ${available.length} available domains!`, 'success')
} catch (e) {
showToast(e instanceof Error ? e.message : 'Availability check failed', 'error')
} finally {
setLoading(false)
}
}, [aiNames, selectedTlds, showToast])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ═══════════════════════════════════════════════════════════════════════ */} {/* ═══════════════════════════════════════════════════════════════════════ */}
@ -160,7 +232,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
<div> <div>
<h3 className="text-base font-bold text-white">Brandable Forge</h3> <h3 className="text-base font-bold text-white">Brandable Forge</h3>
<p className="text-[11px] font-mono text-white/40"> <p className="text-[11px] font-mono text-white/40">
AI-powered brandable name generator {mode === 'pattern' ? 'Pattern-based name generator' : 'AI-powered concept generator'}
</p> </p>
</div> </div>
</div> </div>
@ -203,7 +275,150 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
</div> </div>
</div> </div>
{/* Mode Toggle */}
<div className="px-4 py-3 border-b border-white/[0.08] bg-white/[0.01]">
<div className="flex items-center gap-2">
<button
onClick={() => setMode('pattern')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-all",
mode === 'pattern'
? "border-accent bg-accent/10 text-accent"
: "border-white/10 text-white/40 hover:text-white hover:border-white/20"
)}
>
<Lightbulb className="w-3.5 h-3.5" />
Patterns
</button>
<button
onClick={() => hasAI && setMode('ai')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider border transition-all",
!hasAI && "opacity-50 cursor-not-allowed",
mode === 'ai'
? "border-purple-500 bg-purple-500/10 text-purple-400"
: "border-white/10 text-white/40 hover:text-white hover:border-white/20"
)}
disabled={!hasAI}
>
<Brain className="w-3.5 h-3.5" />
AI Concept
{!hasAI && <Lock className="w-3 h-3 ml-1" />}
</button>
{!hasAI && (
<Link href="/pricing" className="ml-auto text-[10px] font-mono text-accent hover:underline">
Upgrade for AI
</Link>
)}
</div>
</div>
{/* AI Concept Mode */}
{mode === 'ai' && hasAI && (
<div className="p-4 border-b border-white/[0.08] space-y-4">
{/* Concept Input */}
<div>
<label className="block text-[10px] font-mono text-white/40 mb-2 uppercase tracking-wider">
Describe your brand concept
</label>
<div className={clsx(
"flex border-2 transition-all",
conceptFocused ? "border-purple-400/50 bg-purple-400/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
)}>
<MessageSquare className={clsx("w-4 h-4 m-3.5 transition-colors", conceptFocused ? "text-purple-400" : "text-white/30")} />
<input
value={concept}
onChange={(e) => setConcept(e.target.value)}
onFocus={() => setConceptFocused(true)}
onBlur={() => setConceptFocused(false)}
onKeyDown={(e) => e.key === 'Enter' && generateFromConcept()}
placeholder="e.g., AI startup for legal documents, crypto wallet for teens..."
className="flex-1 bg-transparent py-3 pr-3 text-sm text-white placeholder:text-white/25 outline-none font-mono"
/>
<button
onClick={generateFromConcept}
disabled={!concept.trim() || aiLoading}
className={clsx(
"px-4 m-1.5 text-[10px] font-bold uppercase flex items-center gap-1.5 transition-all",
!concept.trim() || aiLoading
? "bg-white/5 text-white/20"
: "bg-purple-500 text-white hover:bg-purple-400"
)}
>
{aiLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
Generate
</button>
</div>
</div>
{/* Similar Brand Input */}
<div>
<label className="block text-[10px] font-mono text-white/40 mb-2 uppercase tracking-wider">
Or find names similar to a brand
</label>
<div className={clsx(
"flex border-2 transition-all",
similarFocused ? "border-purple-400/50 bg-purple-400/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
)}>
<Star className={clsx("w-4 h-4 m-3.5 transition-colors", similarFocused ? "text-purple-400" : "text-white/30")} />
<input
value={similarBrand}
onChange={(e) => setSimilarBrand(e.target.value)}
onFocus={() => setSimilarFocused(true)}
onBlur={() => setSimilarFocused(false)}
onKeyDown={(e) => e.key === 'Enter' && generateFromBrand()}
placeholder="e.g., Stripe, Notion, Figma..."
className="flex-1 bg-transparent py-3 pr-3 text-sm text-white placeholder:text-white/25 outline-none font-mono"
/>
<button
onClick={generateFromBrand}
disabled={!similarBrand.trim() || aiLoading}
className={clsx(
"px-4 m-1.5 text-[10px] font-bold uppercase flex items-center gap-1.5 transition-all",
!similarBrand.trim() || aiLoading
? "bg-white/5 text-white/20"
: "bg-purple-500 text-white hover:bg-purple-400"
)}
>
{aiLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
Find Similar
</button>
</div>
</div>
{/* AI Generated Names */}
{aiNames.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
AI Suggestions ({aiNames.length})
</span>
<button
onClick={checkAiNames}
disabled={loading || selectedTlds.length === 0}
className="flex items-center gap-1.5 text-[10px] font-mono text-purple-400 hover:text-purple-300 transition-colors"
>
<Zap className="w-3 h-3" />
Check Availability
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{aiNames.map((name) => (
<span
key={name}
className="px-3 py-1.5 text-[11px] font-mono text-purple-400 bg-purple-500/10 border border-purple-500/20"
>
{name}
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Pattern Selection */} {/* Pattern Selection */}
{mode === 'pattern' && (
<div className="p-4 border-b border-white/[0.08]"> <div className="p-4 border-b border-white/[0.08]">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Lightbulb className="w-3.5 h-3.5 text-white/30" /> <Lightbulb className="w-3.5 h-3.5 text-white/30" />
@ -256,6 +471,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
})} })}
</div> </div>
</div> </div>
)}
{/* TLD Selection */} {/* TLD Selection */}
<div className="p-4 border-b border-white/[0.08]"> <div className="p-4 border-b border-white/[0.08]">

View File

@ -25,7 +25,7 @@ import {
import clsx from 'clsx' import clsx from 'clsx'
// ============================================================================ // ============================================================================
// TYPES // TYPES & CONSTANTS
// ============================================================================ // ============================================================================
interface SearchResult { interface SearchResult {
@ -35,8 +35,29 @@ interface SearchResult {
registrar: string | null registrar: string | null
expiration_date: string | null expiration_date: string | null
loading: boolean loading: boolean
error?: string
} }
interface TldCheckResult {
tld: string
domain: string
is_available: boolean | null
loading: boolean
error?: string
}
// Popular TLDs to check when user enters only a name without extension
const POPULAR_TLDS = ['com', 'net', 'org', 'io', 'ch', 'de', 'app', 'dev', 'co', 'ai']
// Known valid TLDs (subset for quick validation)
const KNOWN_TLDS = new Set([
'com', 'net', 'org', 'io', 'ch', 'de', 'app', 'dev', 'co', 'ai', 'me', 'tv', 'cc',
'xyz', 'info', 'biz', 'online', 'site', 'tech', 'store', 'club', 'shop', 'blog',
'uk', 'fr', 'nl', 'eu', 'be', 'at', 'us', 'ca', 'au', 'li', 'it', 'es', 'pl',
'pro', 'mobi', 'name', 'page', 'new', 'day', 'world', 'email', 'link', 'click',
'digital', 'media', 'agency', 'studio', 'design', 'marketing', 'solutions',
])
// ============================================================================ // ============================================================================
// COMPONENT // COMPONENT
// ============================================================================ // ============================================================================
@ -51,9 +72,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [searchResult, setSearchResult] = useState<SearchResult | null>(null) const [searchResult, setSearchResult] = useState<SearchResult | null>(null)
const [tldResults, setTldResults] = useState<TldCheckResult[]>([])
const [addingToWatchlist, setAddingToWatchlist] = useState(false) const [addingToWatchlist, setAddingToWatchlist] = useState(false)
const [searchFocused, setSearchFocused] = useState(false) const [searchFocused, setSearchFocused] = useState(false)
const [recentSearches, setRecentSearches] = useState<string[]>([]) const [recentSearches, setRecentSearches] = useState<string[]>([])
const [searchMode, setSearchMode] = useState<'single' | 'multi'>('single')
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
// Load recent searches from localStorage // Load recent searches from localStorage
@ -78,30 +101,134 @@ export function SearchTab({ showToast }: SearchTabProps) {
}) })
}, []) }, [])
// Check if TLD is valid
const isValidTld = useCallback((tld: string): boolean => {
return KNOWN_TLDS.has(tld.toLowerCase())
}, [])
// Check single domain
const checkSingleDomain = useCallback(async (domain: string): Promise<SearchResult> => {
try {
const result = await api.checkDomain(domain)
return {
domain: result.domain,
status: result.status,
is_available: result.is_available,
registrar: result.registrar,
expiration_date: result.expiration_date,
loading: false,
}
} catch (err: any) {
return {
domain,
status: 'error',
is_available: null,
registrar: null,
expiration_date: null,
loading: false,
error: err?.message || 'Check failed',
}
}
}, [])
// Check multiple TLDs for a name
const checkMultipleTlds = useCallback(async (name: string) => {
// Initialize results with loading state
const initialResults: TldCheckResult[] = POPULAR_TLDS.map(tld => ({
tld,
domain: `${name}.${tld}`,
is_available: null,
loading: true,
}))
setTldResults(initialResults)
// Check each TLD in parallel
const results = await Promise.all(
POPULAR_TLDS.map(async (tld): Promise<TldCheckResult> => {
const domain = `${name}.${tld}`
try {
const result = await api.checkDomain(domain)
return {
tld,
domain,
is_available: result.is_available,
loading: false,
}
} catch {
return {
tld,
domain,
is_available: null,
loading: false,
error: 'Check failed',
}
}
})
)
setTldResults(results)
}, [])
// Search Handler // Search Handler
const handleSearch = useCallback(async (domainInput: string) => { const handleSearch = useCallback(async (domainInput: string) => {
if (!domainInput.trim()) { if (!domainInput.trim()) {
setSearchResult(null) setSearchResult(null)
setTldResults([])
return return
} }
const cleanDomain = domainInput.trim().toLowerCase()
setSearchResult({ domain: cleanDomain, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true }) const cleanInput = domainInput.trim().toLowerCase().replace(/\s+/g, '')
try { // Check if input contains a dot (has TLD)
const whoisResult = await api.checkDomain(cleanDomain).catch(() => null) if (cleanInput.includes('.')) {
setSearchResult({ // Single domain mode
domain: whoisResult?.domain || cleanDomain, setSearchMode('single')
status: whoisResult?.status || 'unknown', setTldResults([])
is_available: whoisResult?.is_available ?? null,
registrar: whoisResult?.registrar || null, const parts = cleanInput.split('.')
expiration_date: whoisResult?.expiration_date || null, const tld = parts[parts.length - 1]
loading: false,
}) // Check if TLD is valid
saveToRecent(cleanDomain) if (!isValidTld(tld)) {
} catch { setSearchResult({
setSearchResult({ domain: cleanDomain, status: 'error', is_available: null, registrar: null, expiration_date: null, loading: false }) domain: cleanInput,
status: 'invalid_tld',
is_available: null,
registrar: null,
expiration_date: null,
loading: false,
error: `".${tld}" is not a valid domain extension`,
})
return
}
setSearchResult({ domain: cleanInput, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true })
const result = await checkSingleDomain(cleanInput)
setSearchResult(result)
if (!result.error) saveToRecent(cleanInput)
} else {
// Multi-TLD mode - check multiple extensions
setSearchMode('multi')
setSearchResult(null)
// Validate the name part
if (cleanInput.length < 1 || cleanInput.length > 63) {
setTldResults([])
showToast('Domain name must be 1-63 characters', 'error')
return
}
if (!/^[a-z0-9-]+$/.test(cleanInput) || cleanInput.startsWith('-') || cleanInput.endsWith('-')) {
setTldResults([])
showToast('Domain name contains invalid characters', 'error')
return
}
await checkMultipleTlds(cleanInput)
saveToRecent(cleanInput)
} }
}, [saveToRecent]) }, [saveToRecent, checkSingleDomain, checkMultipleTlds, isValidTld, showToast])
const handleAddToWatchlist = useCallback(async () => { const handleAddToWatchlist = useCallback(async () => {
if (!searchQuery.trim()) return if (!searchQuery.trim()) return
@ -119,8 +246,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
// Debounced search // Debounced search
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (searchQuery.length > 3) handleSearch(searchQuery) if (searchQuery.length >= 2) handleSearch(searchQuery)
else setSearchResult(null) else {
setSearchResult(null)
setTldResults([])
}
}, 500) }, 500)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [searchQuery, handleSearch]) }, [searchQuery, handleSearch])
@ -147,7 +277,7 @@ export function SearchTab({ showToast }: SearchTabProps) {
onFocus={() => setSearchFocused(true)} onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)} onBlur={() => setSearchFocused(false)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)} onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)}
placeholder="example.com" placeholder="domain or name.tld"
className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono" className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono"
/> />
{searchQuery && ( {searchQuery && (
@ -180,14 +310,29 @@ export function SearchTab({ showToast }: SearchTabProps) {
</span> </span>
</div> </div>
{/* Search Result */} {/* Single Domain Result */}
{searchResult && ( {searchMode === 'single' && searchResult && (
<div className="animate-in fade-in slide-in-from-bottom-2 duration-200"> <div className="animate-in fade-in slide-in-from-bottom-2 duration-200">
{searchResult.loading ? ( {searchResult.loading ? (
<div className="flex items-center justify-center gap-3 py-12 border border-white/[0.08] bg-white/[0.02]"> <div className="flex items-center justify-center gap-3 py-12 border border-white/[0.08] bg-white/[0.02]">
<Loader2 className="w-6 h-6 animate-spin text-accent" /> <Loader2 className="w-6 h-6 animate-spin text-accent" />
<span className="text-sm text-white/50 font-mono">Checking availability...</span> <span className="text-sm text-white/50 font-mono">Checking availability...</span>
</div> </div>
) : searchResult.error ? (
// Error state (invalid TLD, check failed, etc.)
<div className="border-2 border-rose-500/30 overflow-hidden bg-[#020202]">
<div className="p-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 flex items-center justify-center border border-rose-500/30 bg-rose-500/10 shrink-0">
<XCircle className="w-6 h-6 text-rose-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-lg font-bold text-white font-mono truncate">{searchResult.domain}</div>
<div className="text-sm text-rose-400 mt-1">{searchResult.error}</div>
</div>
</div>
</div>
</div>
) : ( ) : (
<div className={clsx( <div className={clsx(
"border-2 overflow-hidden bg-[#020202]", "border-2 overflow-hidden bg-[#020202]",
@ -294,6 +439,84 @@ export function SearchTab({ showToast }: SearchTabProps) {
</div> </div>
)} )}
{/* Multi-TLD Results */}
{searchMode === 'multi' && tldResults.length > 0 && (
<div className="animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="border border-white/[0.08] bg-[#020202] overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-accent" />
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
Checking {POPULAR_TLDS.length} extensions for "{searchQuery.toLowerCase().replace(/\s+/g, '')}"
</span>
</div>
<span className="text-[10px] font-mono text-accent">
{tldResults.filter(r => r.is_available === true).length} available
</span>
</div>
{/* TLD Grid */}
<div className="p-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{tldResults.map((result) => (
<div
key={result.tld}
className={clsx(
"p-3 border transition-all",
result.loading
? "border-white/[0.08] bg-white/[0.02]"
: result.is_available
? "border-accent/40 bg-accent/[0.05] hover:bg-accent/10 cursor-pointer"
: "border-white/[0.06] bg-white/[0.01]"
)}
onClick={() => {
if (result.is_available && !result.loading) {
setSearchQuery(result.domain)
setSearchMode('single')
setTldResults([])
handleSearch(result.domain)
}
}}
>
{result.loading ? (
<div className="flex items-center justify-center py-1">
<Loader2 className="w-4 h-4 animate-spin text-white/30" />
</div>
) : (
<>
<div className="flex items-center justify-between mb-1">
<span className={clsx(
"text-xs font-mono font-bold",
result.is_available ? "text-accent" : "text-white/30"
)}>
.{result.tld}
</span>
{result.is_available ? (
<CheckCircle2 className="w-3.5 h-3.5 text-accent" />
) : (
<XCircle className="w-3.5 h-3.5 text-white/20" />
)}
</div>
<div className={clsx(
"text-[9px] font-mono uppercase",
result.is_available ? "text-accent/60" : "text-white/20"
)}>
{result.is_available ? 'Available' : 'Taken'}
</div>
</>
)}
</div>
))}
</div>
{/* Footer hint */}
<div className="px-4 py-2 border-t border-white/[0.06] text-[9px] font-mono text-white/30 text-center">
Click an available extension to see details Add ".tld" to your search for specific extension
</div>
</div>
</div>
)}
{/* Recent Searches */} {/* Recent Searches */}
{!searchResult && recentSearches.length > 0 && ( {!searchResult && recentSearches.length > 0 && (
<div className="border border-white/[0.08] bg-white/[0.02]"> <div className="border border-white/[0.08] bg-white/[0.02]">

View File

@ -19,11 +19,14 @@ import {
ShoppingCart, ShoppingCart,
Flame, Flame,
ArrowRight, ArrowRight,
AlertCircle AlertCircle,
Wand2,
Lock,
} from 'lucide-react' } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import Link from 'next/link'
// ============================================================================ // ============================================================================
// TYPES & CONSTANTS // TYPES & CONSTANTS
@ -56,6 +59,9 @@ function normalizeKeyword(s: string) {
export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) { export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
const openAnalyze = useAnalyzePanelStore((s) => s.open) const openAnalyze = useAnalyzePanelStore((s) => s.open)
const addDomain = useStore((s) => s.addDomain) const addDomain = useStore((s) => s.addDomain)
const subscription = useStore((s) => s.subscription)
const tier = (subscription?.tier || '').toLowerCase()
const hasAI = tier === 'trader' || tier === 'tycoon'
// Trends State // Trends State
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -65,6 +71,11 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
const [selected, setSelected] = useState<string>('') const [selected, setSelected] = useState<string>('')
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
// AI Expansion State
const [aiKeywords, setAiKeywords] = useState<string[]>([])
const [aiLoading, setAiLoading] = useState(false)
const [aiAnalysis, setAiAnalysis] = useState<string>('')
// Keyword Check State // Keyword Check State
const [keywordInput, setKeywordInput] = useState('') const [keywordInput, setKeywordInput] = useState('')
const [keywordFocused, setKeywordFocused] = useState(false) const [keywordFocused, setKeywordFocused] = useState(false)
@ -82,6 +93,29 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
const [tracking, setTracking] = useState<string | null>(null) const [tracking, setTracking] = useState<string | null>(null)
const [copied, setCopied] = useState<string | null>(null) const [copied, setCopied] = useState<string | null>(null)
// AI Keyword Expansion
const expandWithAI = useCallback(async () => {
if (!selected || !hasAI || aiLoading) return
setAiLoading(true)
setAiKeywords([])
setAiAnalysis('')
try {
const [expandRes, analyzeRes] = await Promise.all([
api.expandTrendKeywords(selected, geo),
api.analyzeTrend(selected, geo),
])
setAiKeywords(expandRes.keywords || [])
setAiAnalysis(analyzeRes.analysis || '')
if (expandRes.keywords?.length) {
showToast(`AI found ${expandRes.keywords.length} related keywords!`, 'success')
}
} catch (e) {
showToast(e instanceof Error ? e.message : 'AI expansion failed', 'error')
} finally {
setAiLoading(false)
}
}, [selected, geo, hasAI, aiLoading, showToast])
const copyDomain = useCallback((domain: string) => { const copyDomain = useCallback((domain: string) => {
navigator.clipboard.writeText(domain) navigator.clipboard.writeText(domain)
setCopied(domain) setCopied(domain)
@ -304,6 +338,77 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
)} )}
</div> </div>
)} )}
{/* AI Expansion Section */}
{selected && (
<div className="px-4 pb-4 border-t border-white/[0.08] pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Wand2 className="w-4 h-4 text-purple-400" />
<span className="text-xs font-mono text-white/60">AI Keyword Expansion</span>
{!hasAI && (
<span className="text-[9px] font-mono text-white/30 bg-white/5 px-1.5 py-0.5 border border-white/10">
TRADER+
</span>
)}
</div>
{hasAI ? (
<button
onClick={expandWithAI}
disabled={aiLoading}
className={clsx(
"flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider transition-all",
aiLoading
? "bg-purple-500/20 text-purple-300 cursor-wait"
: "bg-purple-500/20 text-purple-400 hover:bg-purple-500/30 border border-purple-500/30"
)}
>
{aiLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
{aiLoading ? 'Expanding...' : 'Expand with AI'}
</button>
) : (
<Link
href="/pricing"
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-mono text-white/40 border border-white/10 hover:border-white/20 hover:text-white/60 transition-all"
>
<Lock className="w-3 h-3" />
Upgrade
</Link>
)}
</div>
{/* AI Analysis */}
{aiAnalysis && (
<div className="p-3 bg-purple-500/5 border border-purple-500/20 mb-3">
<p className="text-xs text-white/70 leading-relaxed">{aiAnalysis}</p>
</div>
)}
{/* AI Keywords */}
{aiKeywords.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{aiKeywords.map((kw) => (
<button
key={kw}
onClick={() => {
setKeywordInput(kw)
setAvailability([])
}}
className="px-3 py-1.5 text-[11px] font-mono text-purple-400 bg-purple-500/10 border border-purple-500/20 hover:bg-purple-500/20 transition-all"
>
{kw}
</button>
))}
</div>
)}
{!aiKeywords.length && !aiLoading && hasAI && (
<p className="text-[10px] font-mono text-white/30 text-center py-2">
Click "Expand with AI" to find related keywords for "{selected}"
</p>
)}
</div>
)}
</div> </div>
{/* ═══════════════════════════════════════════════════════════════════════ */} {/* ═══════════════════════════════════════════════════════════════════════ */}

View File

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