Hunter Companion v4: Code-First Architecture - no LLM for routing, pure pattern matching
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
This commit is contained in:
@ -1,30 +1,49 @@
|
|||||||
"""
|
"""
|
||||||
LLM Agent endpoint (Tool Calling, Trader/Tycoon).
|
Hunter Companion API Endpoint
|
||||||
|
|
||||||
This endpoint runs a small tool loop:
|
This is the main endpoint for the Hunter Companion chat.
|
||||||
- LLM requests tools via strict JSON
|
Uses code-first architecture: intent detection via pattern matching,
|
||||||
- Backend executes read-only tools against live DB/API helpers
|
tool execution, and template-based responses. LLM is NOT used for routing.
|
||||||
- Final answer is streamed to the client (SSE) using the gateway
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.models.subscription import Subscription
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.llm_agent import run_agent, stream_final_answer
|
from app.services.hunter_companion import process_message
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/llm", tags=["LLM"])
|
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):
|
class ChatMessage(BaseModel):
|
||||||
role: Literal["system", "user", "assistant"]
|
role: Literal["system", "user", "assistant"]
|
||||||
content: str
|
content: str
|
||||||
@ -34,39 +53,69 @@ class AgentRequest(BaseModel):
|
|||||||
messages: list[ChatMessage] = Field(default_factory=list, min_length=1)
|
messages: list[ChatMessage] = Field(default_factory=list, min_length=1)
|
||||||
path: str = Field(default="/terminal/hunt")
|
path: str = Field(default="/terminal/hunt")
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
temperature: float = Field(default=0.3, ge=0.0, le=2.0)
|
||||||
stream: bool = True
|
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")
|
@router.post("/agent")
|
||||||
async def llm_agent(
|
async def hunter_companion_chat(
|
||||||
payload: AgentRequest,
|
payload: AgentRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
try:
|
"""
|
||||||
convo = await run_agent(
|
Hunter Companion Chat Endpoint
|
||||||
db,
|
|
||||||
current_user,
|
- Trader/Tycoon: Full access to all features
|
||||||
messages=[m.model_dump() for m in payload.messages],
|
- Scout: Blocked (403)
|
||||||
path=payload.path,
|
"""
|
||||||
model=payload.model,
|
# Check tier
|
||||||
temperature=payload.temperature,
|
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 PermissionError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Agent failed: {type(e).__name__}: {e}")
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Processing failed: {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return as SSE stream (for frontend compatibility)
|
||||||
if payload.stream:
|
if payload.stream:
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream_final_answer(convo, model=payload.model, temperature=payload.temperature),
|
_generate_sse_response(response),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Non-stream fallback: produce final answer in one shot by asking the model again.
|
# Non-stream response
|
||||||
# (kept simple; streaming path is preferred)
|
return {"content": response}
|
||||||
return JSONResponse({"ok": True, "messages": convo[-8:]})
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
523
backend/app/services/hunter_companion.py
Normal file
523
backend/app/services/hunter_companion.py
Normal file
@ -0,0 +1,523 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
@ -4,22 +4,13 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import { MessageSquare, X, Send, Sparkles, Loader2, Lock, Trash2 } from 'lucide-react'
|
||||||
MessageSquare,
|
|
||||||
X,
|
|
||||||
Send,
|
|
||||||
Sparkles,
|
|
||||||
Loader2,
|
|
||||||
Lock,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
|
|
||||||
type ChatMessage = {
|
type ChatMessage = {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
createdAt: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function uid() {
|
function uid() {
|
||||||
@ -34,20 +25,19 @@ function getApiBase(): string {
|
|||||||
return `${protocol}//${hostname}/api/v1`
|
return `${protocol}//${hostname}/api/v1`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function streamChat(opts: {
|
async function sendMessage(opts: {
|
||||||
messages: Array<{ role: string; content: string }>
|
message: string
|
||||||
path: string
|
path: string
|
||||||
onDelta: (delta: string) => void
|
onChunk: (text: string) => void
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const res = await fetch(`${getApiBase()}/llm/agent`, {
|
const res = await fetch(`${getApiBase()}/llm/agent`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
messages: opts.messages,
|
messages: [{ role: 'user', content: opts.message }],
|
||||||
temperature: 0.3,
|
|
||||||
stream: true,
|
|
||||||
path: opts.path,
|
path: opts.path,
|
||||||
|
stream: true,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -59,29 +49,29 @@ async function streamChat(opts: {
|
|||||||
const reader = res.body?.getReader()
|
const reader = res.body?.getReader()
|
||||||
if (!reader) throw new Error('Streaming not supported')
|
if (!reader) throw new Error('Streaming not supported')
|
||||||
|
|
||||||
const decoder = new TextDecoder('utf-8')
|
const decoder = new TextDecoder()
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
|
|
||||||
for (;;) {
|
while (true) {
|
||||||
const { value, done } = await reader.read()
|
const { value, done } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
const parts = buffer.split('\n\n')
|
const parts = buffer.split('\n\n')
|
||||||
buffer = parts.pop() || ''
|
buffer = parts.pop() || ''
|
||||||
|
|
||||||
for (const p of parts) {
|
for (const part of parts) {
|
||||||
const line = p.split('\n').find((l) => l.startsWith('data: '))
|
const line = part.split('\n').find(l => l.startsWith('data: '))
|
||||||
if (!line) continue
|
if (!line) continue
|
||||||
const payload = line.replace(/^data:\s*/, '')
|
|
||||||
|
const payload = line.slice(6)
|
||||||
if (payload === '[DONE]') return
|
if (payload === '[DONE]') return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(payload)
|
const json = JSON.parse(payload)
|
||||||
const delta = json?.choices?.[0]?.delta?.content
|
const content = json?.choices?.[0]?.delta?.content
|
||||||
if (typeof delta === 'string' && delta.length) opts.onDelta(delta)
|
if (content) opts.onChunk(content)
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore parse errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,20 +83,11 @@ function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' {
|
|||||||
return 'scout'
|
return 'scout'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick action suggestions
|
const QUICK_ACTIONS = [
|
||||||
const SUGGESTIONS = [
|
{ label: 'Analyze domain', action: 'analyze ' },
|
||||||
{ label: 'Analyze a domain', prompt: 'analyze ' },
|
{ label: 'Auctions', action: 'show auctions ending soon' },
|
||||||
{ label: 'Ending soon', prompt: 'Show auctions ending in 2 hours' },
|
{ label: 'Watchlist', action: 'show my watchlist' },
|
||||||
{ label: 'My watchlist', prompt: 'Show my watchlist' },
|
{ label: 'Portfolio', action: 'portfolio summary' },
|
||||||
{ label: 'Portfolio stats', prompt: 'Show my portfolio summary' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Teaser for Scout users
|
|
||||||
const TEASER_ITEMS = [
|
|
||||||
'Analyze any domain instantly',
|
|
||||||
'Get BUY / SKIP recommendations',
|
|
||||||
'Track auctions and drops',
|
|
||||||
'Monitor your portfolio',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function HunterCompanion() {
|
export function HunterCompanion() {
|
||||||
@ -115,43 +96,37 @@ export function HunterCompanion() {
|
|||||||
const tier = getTier(subscription)
|
const tier = getTier(subscription)
|
||||||
const canChat = tier === 'trader' || tier === 'tycoon'
|
const canChat = tier === 'trader' || tier === 'tycoon'
|
||||||
|
|
||||||
const storageKey = useMemo(() => {
|
const storageKey = useMemo(() => `pounce:hc:${user?.id || 'anon'}`, [user?.id])
|
||||||
const uidPart = user?.id ? String(user.id) : 'anon'
|
|
||||||
return `pounce:hc:v3:${uidPart}`
|
|
||||||
}, [user?.id])
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [sending, setSending] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const listRef = useRef<HTMLDivElement | null>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Load messages
|
// Load saved messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canChat) return
|
if (!canChat) return
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(storageKey)
|
const saved = localStorage.getItem(storageKey)
|
||||||
if (raw) {
|
if (saved) setMessages(JSON.parse(saved))
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (Array.isArray(parsed)) setMessages(parsed)
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [storageKey, canChat])
|
}, [storageKey, canChat])
|
||||||
|
|
||||||
// Save messages
|
// Save messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canChat || messages.length === 0) return
|
if (!canChat || !messages.length) return
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-50)))
|
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-30)))
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [messages, storageKey, canChat])
|
}, [messages, storageKey, canChat])
|
||||||
|
|
||||||
// Auto-scroll
|
// Auto scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && listRef.current) {
|
if (open && scrollRef.current) {
|
||||||
listRef.current.scrollTop = listRef.current.scrollHeight
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
}
|
}
|
||||||
}, [open, messages])
|
}, [open, messages])
|
||||||
|
|
||||||
@ -167,158 +142,142 @@ export function HunterCompanion() {
|
|||||||
|
|
||||||
const send = async (text?: string) => {
|
const send = async (text?: string) => {
|
||||||
const msg = (text || input).trim()
|
const msg = (text || input).trim()
|
||||||
if (!msg || sending || !canChat) return
|
if (!msg || loading || !canChat) return
|
||||||
|
|
||||||
const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg, createdAt: Date.now() }
|
const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg }
|
||||||
const assistantId = uid()
|
const assistantId = uid()
|
||||||
|
|
||||||
setMessages((prev) => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '', createdAt: Date.now() }])
|
|
||||||
setInput('')
|
|
||||||
setSending(true)
|
|
||||||
|
|
||||||
const history = messages.slice(-10).map((m) => ({ role: m.role, content: m.content }))
|
setMessages(prev => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '' }])
|
||||||
|
setInput('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await streamChat({
|
await sendMessage({
|
||||||
messages: [...history, { role: 'user', content: msg }],
|
message: msg,
|
||||||
path: pathname || '/terminal/hunt',
|
path: pathname || '/terminal/hunt',
|
||||||
onDelta: (delta) => {
|
onChunk: (chunk) => {
|
||||||
setMessages((prev) =>
|
setMessages(prev => prev.map(m =>
|
||||||
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + delta } : m))
|
m.id === assistantId ? { ...m, content: m.content + chunk } : m
|
||||||
)
|
))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setMessages((prev) =>
|
setMessages(prev => prev.map(m =>
|
||||||
prev.map((m) =>
|
m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Failed'}` } : m
|
||||||
m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Failed'}` } : m
|
))
|
||||||
)
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setLoading(false)
|
||||||
setTimeout(() => inputRef.current?.focus(), 0)
|
inputRef.current?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
setMessages([])
|
setMessages([])
|
||||||
try { localStorage.removeItem(storageKey) } catch {}
|
localStorage.removeItem(storageKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Floating Button */}
|
{/* FAB Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fixed bottom-5 right-5 z-[150] w-14 h-14 flex items-center justify-center',
|
'fixed bottom-5 right-5 z-[150] w-14 h-14 flex items-center justify-center',
|
||||||
'bg-black/90 backdrop-blur border transition-transform hover:scale-105',
|
'bg-[#0a0a0a] border transition-transform hover:scale-105',
|
||||||
canChat ? 'border-accent/40 text-accent' : 'border-white/10 text-white/40'
|
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 animate-pulse" />}
|
{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 text-white/40" />}
|
{!canChat && <Lock className="absolute -top-1 -right-1 w-3 h-3" />}
|
||||||
<MessageSquare className="w-6 h-6" />
|
<MessageSquare className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Chat Panel */}
|
{/* Chat Modal */}
|
||||||
{open && (
|
{open && (
|
||||||
<div className="fixed inset-0 z-[160]">
|
<div className="fixed inset-0 z-[160]">
|
||||||
|
{/* Backdrop */}
|
||||||
<div className="absolute inset-0 bg-black/80" onClick={() => setOpen(false)} />
|
<div className="absolute inset-0 bg-black/80" onClick={() => setOpen(false)} />
|
||||||
|
|
||||||
<div className="absolute bottom-4 right-4 w-[90vw] max-w-[400px] h-[70vh] max-h-[600px] bg-[#0a0a0a] border border-white/10 flex flex-col overflow-hidden">
|
{/* 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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-black/50">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{canChat ? (
|
<span className={clsx('w-2 h-2', canChat ? 'bg-accent' : 'bg-white/20')} />
|
||||||
<span className="w-2 h-2 bg-accent animate-pulse" />
|
<span className="text-xs font-mono text-accent tracking-wider uppercase">
|
||||||
) : (
|
|
||||||
<Lock className="w-3.5 h-3.5 text-white/40" />
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-mono text-accent uppercase tracking-wider">
|
|
||||||
Hunter Companion
|
Hunter Companion
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex gap-1">
|
||||||
{canChat && messages.length > 0 && (
|
{canChat && messages.length > 0 && (
|
||||||
<button onClick={clear} className="p-2 text-white/40 hover:text-white/70">
|
<button onClick={clear} className="p-2 text-white/40 hover:text-white">
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setOpen(false)} className="p-2 text-white/40 hover:text-white/70">
|
<button onClick={() => setOpen(false)} className="p-2 text-white/40 hover:text-white">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Content */}
|
||||||
<div ref={listRef} className="flex-1 overflow-y-auto p-4">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
|
||||||
{!canChat ? (
|
{!canChat ? (
|
||||||
// Scout Teaser
|
/* Scout - Locked */
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-4">
|
<div className="h-full flex flex-col items-center justify-center text-center px-4">
|
||||||
<div className="w-14 h-14 border border-accent/20 bg-accent/5 flex items-center justify-center mb-4">
|
<Lock className="w-10 h-10 text-white/20 mb-4" />
|
||||||
<Sparkles className="w-7 h-7 text-accent" />
|
<h3 className="text-sm font-medium text-white mb-2">Hunter Companion</h3>
|
||||||
</div>
|
<p className="text-xs text-white/50 mb-6 leading-relaxed">
|
||||||
<h3 className="text-sm font-medium text-white mb-2">AI Trading Assistant</h3>
|
AI-powered domain analysis, auction alerts, and portfolio insights.
|
||||||
<p className="text-xs text-white/50 mb-4">
|
|
||||||
Get instant domain analysis and trading recommendations.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2 mb-6 w-full max-w-[250px]">
|
|
||||||
{TEASER_ITEMS.map((item, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2 text-xs text-white/40">
|
|
||||||
<span className="text-accent">•</span>
|
|
||||||
<span>{item}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className="px-5 py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider"
|
className="px-5 py-2 bg-accent text-black text-xs font-bold uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Upgrade to Trader
|
Upgrade to Trader
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
// Empty state
|
/* Empty State */
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center">
|
<div className="h-full flex flex-col items-center justify-center text-center">
|
||||||
<Sparkles className="w-8 h-8 text-accent/60 mb-3" />
|
<Sparkles className="w-8 h-8 text-accent/50 mb-3" />
|
||||||
<p className="text-xs text-white/50 mb-4">
|
<p className="text-xs text-white/50 mb-4">
|
||||||
Ask me about domains, auctions, or your portfolio.
|
Type a domain or ask a question
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{SUGGESTIONS.map((s, i) => (
|
{QUICK_ACTIONS.map((qa, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (s.prompt.endsWith(' ')) {
|
if (qa.action.endsWith(' ')) {
|
||||||
setInput(s.prompt)
|
setInput(qa.action)
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
} else {
|
} else {
|
||||||
send(s.prompt)
|
send(qa.action)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-[10px] font-mono border border-accent/20 text-accent hover:bg-accent/10"
|
className="px-3 py-1.5 text-[10px] font-mono border border-accent/20 text-accent hover:bg-accent/10"
|
||||||
>
|
>
|
||||||
{s.label}
|
{qa.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Messages
|
/* Messages */
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{messages.map((m) => (
|
{messages.map((m) => (
|
||||||
<div key={m.id} className={clsx('flex', m.role === 'user' ? 'justify-end' : 'justify-start')}>
|
<div key={m.id} className={clsx('flex', m.role === 'user' ? 'justify-end' : 'justify-start')}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'max-w-[85%] px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap',
|
'max-w-[85%] px-3 py-2 text-xs whitespace-pre-wrap leading-relaxed',
|
||||||
m.role === 'user'
|
m.role === 'user'
|
||||||
? 'bg-accent/10 border border-accent/20 text-accent font-mono'
|
? 'bg-accent/10 border border-accent/20 text-accent font-mono'
|
||||||
: 'bg-white/5 border border-white/10 text-white/80'
|
: 'bg-white/5 border border-white/10 text-white/80'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{m.content || (sending && m.role === 'assistant' ? '...' : '')}
|
{m.content || (loading ? '...' : '')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -328,7 +287,7 @@ export function HunterCompanion() {
|
|||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
{canChat && (
|
{canChat && (
|
||||||
<div className="p-3 border-t border-white/10 bg-black/50">
|
<div className="p-3 border-t border-white/10">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@ -340,16 +299,16 @@ export function HunterCompanion() {
|
|||||||
send()
|
send()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Type a domain or ask a question..."
|
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/30"
|
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={sending}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => send()}
|
onClick={() => send()}
|
||||||
disabled={sending || !input.trim()}
|
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"
|
className="w-10 h-10 flex items-center justify-center border border-accent/30 bg-accent/10 text-accent disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user