diff --git a/backend/app/api/llm_agent.py b/backend/app/api/llm_agent.py index 89b519f..c130cc4 100644 --- a/backend/app/api/llm_agent.py +++ b/backend/app/api/llm_agent.py @@ -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} diff --git a/backend/app/services/hunter_companion.py b/backend/app/services/hunter_companion.py new file mode 100644 index 0000000..f3163e5 --- /dev/null +++ b/backend/app/services/hunter_companion.py @@ -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 + diff --git a/frontend/src/components/chat/HunterCompanion.tsx b/frontend/src/components/chat/HunterCompanion.tsx index b106cfe..d49e796 100644 --- a/frontend/src/components/chat/HunterCompanion.tsx +++ b/frontend/src/components/chat/HunterCompanion.tsx @@ -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 { 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([]) - const [sending, setSending] = useState(false) + const [loading, setLoading] = useState(false) - const listRef = useRef(null) - const inputRef = useRef(null) + const scrollRef = useRef(null) + const inputRef = useRef(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 */} - {/* Chat Panel */} + {/* Chat Modal */} {open && (
+ {/* Backdrop */}
setOpen(false)} /> -
+ {/* Panel */} +
{/* Header */} -
+
- {canChat ? ( - - ) : ( - - )} - + + Hunter Companion
-
+
{canChat && messages.length > 0 && ( - )} -
- {/* Body */} -
+ {/* Content */} +
{!canChat ? ( - // Scout Teaser + /* Scout - Locked */
-
- -
-

AI Trading Assistant

-

- Get instant domain analysis and trading recommendations. + +

Hunter Companion

+

+ AI-powered domain analysis, auction alerts, and portfolio insights.

-
- {TEASER_ITEMS.map((item, i) => ( -
- - {item} -
- ))} -
Upgrade to Trader
) : messages.length === 0 ? ( - // Empty state + /* Empty State */
- +

- Ask me about domains, auctions, or your portfolio. + Type a domain or ask a question

- {SUGGESTIONS.map((s, i) => ( + {QUICK_ACTIONS.map((qa, i) => ( ))}
) : ( - // Messages + /* Messages */
{messages.map((m) => (
- {m.content || (sending && m.role === 'assistant' ? '...' : '')} + {m.content || (loading ? '...' : '')}
))} @@ -328,7 +287,7 @@ export function HunterCompanion() { {/* Input */} {canChat && ( -
+