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:
- LLM requests tools via strict JSON
- Backend executes read-only tools against live DB/API helpers
- Final answer is streamed to the client (SSE) using the gateway
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 JSONResponse, StreamingResponse
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.llm_agent import run_agent, stream_final_answer
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
@ -34,39 +53,69 @@ 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.7, ge=0.0, le=2.0)
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 llm_agent(
async def hunter_companion_chat(
payload: AgentRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
convo = await run_agent(
db,
current_user,
messages=[m.model_dump() for m in payload.messages],
path=payload.path,
model=payload.model,
temperature=payload.temperature,
"""
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 PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(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:
return StreamingResponse(
stream_final_answer(convo, model=payload.model, temperature=payload.temperature),
_generate_sse_response(response),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
# Non-stream fallback: produce final answer in one shot by asking the model again.
# (kept simple; streaming path is preferred)
return JSONResponse({"ok": True, "messages": convo[-8:]})
# Non-stream response
return {"content": response}

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 { usePathname } from 'next/navigation'
import Link from 'next/link'
import {
MessageSquare,
X,
Send,
Sparkles,
Loader2,
Lock,
Trash2,
} from 'lucide-react'
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
createdAt: number
}
function uid() {
@ -34,20 +25,19 @@ function getApiBase(): string {
return `${protocol}//${hostname}/api/v1`
}
async function streamChat(opts: {
messages: Array<{ role: string; content: string }>
async function sendMessage(opts: {
message: string
path: string
onDelta: (delta: string) => void
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: opts.messages,
temperature: 0.3,
stream: true,
messages: [{ role: 'user', content: opts.message }],
path: opts.path,
stream: true,
}),
})
@ -59,29 +49,29 @@ async function streamChat(opts: {
const reader = res.body?.getReader()
if (!reader) throw new Error('Streaming not supported')
const decoder = new TextDecoder('utf-8')
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
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 p of parts) {
const line = p.split('\n').find((l) => l.startsWith('data: '))
for (const part of parts) {
const line = part.split('\n').find(l => l.startsWith('data: '))
if (!line) continue
const payload = line.replace(/^data:\s*/, '')
const payload = line.slice(6)
if (payload === '[DONE]') return
try {
const json = JSON.parse(payload)
const delta = json?.choices?.[0]?.delta?.content
if (typeof delta === 'string' && delta.length) opts.onDelta(delta)
} catch {
// ignore parse errors
}
const content = json?.choices?.[0]?.delta?.content
if (content) opts.onChunk(content)
} catch {}
}
}
}
@ -93,20 +83,11 @@ function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' {
return 'scout'
}
// Quick action suggestions
const SUGGESTIONS = [
{ label: 'Analyze a domain', prompt: 'analyze ' },
{ label: 'Ending soon', prompt: 'Show auctions ending in 2 hours' },
{ label: 'My watchlist', prompt: 'Show my watchlist' },
{ 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',
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() {
@ -115,43 +96,37 @@ export function HunterCompanion() {
const tier = getTier(subscription)
const canChat = tier === 'trader' || tier === 'tycoon'
const storageKey = useMemo(() => {
const uidPart = user?.id ? String(user.id) : 'anon'
return `pounce:hc:v3:${uidPart}`
}, [user?.id])
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 [sending, setSending] = useState(false)
const [loading, setLoading] = useState(false)
const listRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Load messages
// Load saved messages
useEffect(() => {
if (!canChat) return
try {
const raw = localStorage.getItem(storageKey)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) setMessages(parsed)
}
const saved = localStorage.getItem(storageKey)
if (saved) setMessages(JSON.parse(saved))
} catch {}
}, [storageKey, canChat])
// Save messages
useEffect(() => {
if (!canChat || messages.length === 0) return
if (!canChat || !messages.length) return
try {
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-50)))
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-30)))
} catch {}
}, [messages, storageKey, canChat])
// Auto-scroll
// Auto scroll
useEffect(() => {
if (open && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight
if (open && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [open, messages])
@ -167,158 +142,142 @@ export function HunterCompanion() {
const send = async (text?: string) => {
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()
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 {
await streamChat({
messages: [...history, { role: 'user', content: msg }],
await sendMessage({
message: msg,
path: pathname || '/terminal/hunt',
onDelta: (delta) => {
setMessages((prev) =>
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + delta } : m))
)
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
)
)
setMessages(prev => prev.map(m =>
m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Failed'}` } : m
))
} finally {
setSending(false)
setTimeout(() => inputRef.current?.focus(), 0)
setLoading(false)
inputRef.current?.focus()
}
}
const clear = () => {
setMessages([])
try { localStorage.removeItem(storageKey) } catch {}
localStorage.removeItem(storageKey)
}
return (
<>
{/* Floating Button */}
{/* 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-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 && <span className="absolute -top-1 -left-1 w-2 h-2 bg-accent animate-pulse" />}
{!canChat && <Lock className="absolute -top-1 -right-1 w-3 h-3 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 Panel */}
{/* Chat Modal */}
{open && (
<div className="fixed inset-0 z-[160]">
{/* Backdrop */}
<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 */}
<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">
{canChat ? (
<span className="w-2 h-2 bg-accent animate-pulse" />
) : (
<Lock className="w-3.5 h-3.5 text-white/40" />
)}
<span className="text-xs font-mono text-accent uppercase tracking-wider">
<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 items-center gap-1">
<div className="flex gap-1">
{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" />
</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" />
</button>
</div>
</div>
{/* Body */}
<div ref={listRef} className="flex-1 overflow-y-auto p-4">
{/* Content */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
{!canChat ? (
// Scout Teaser
/* Scout - Locked */
<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">
<Sparkles className="w-7 h-7 text-accent" />
</div>
<h3 className="text-sm font-medium text-white mb-2">AI Trading Assistant</h3>
<p className="text-xs text-white/50 mb-4">
Get instant domain analysis and trading recommendations.
<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>
<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
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
</Link>
</div>
) : messages.length === 0 ? (
// Empty state
/* Empty State */
<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">
Ask me about domains, auctions, or your portfolio.
Type a domain or ask a question
</p>
<div className="flex flex-wrap justify-center gap-2">
{SUGGESTIONS.map((s, i) => (
{QUICK_ACTIONS.map((qa, i) => (
<button
key={i}
onClick={() => {
if (s.prompt.endsWith(' ')) {
setInput(s.prompt)
if (qa.action.endsWith(' ')) {
setInput(qa.action)
inputRef.current?.focus()
} 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"
>
{s.label}
{qa.label}
</button>
))}
</div>
</div>
) : (
// Messages
/* 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 leading-relaxed whitespace-pre-wrap',
'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 || (sending && m.role === 'assistant' ? '...' : '')}
{m.content || (loading ? '...' : '')}
</div>
</div>
))}
@ -328,7 +287,7 @@ export function HunterCompanion() {
{/* Input */}
{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">
<input
ref={inputRef}
@ -340,16 +299,16 @@ export function HunterCompanion() {
send()
}
}}
placeholder="Type a domain or ask a 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"
disabled={sending}
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={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"
>
{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>
</div>
</div>