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

This commit is contained in:
2025-12-17 15:22:34 +01:00
parent 31a8d62b38
commit e75c9bc9ef
3 changed files with 692 additions and 161 deletions

View File

@ -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:]})

View 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

View File

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