Hunter Companion v3: completely rebuilt - canned responses, auto domain detection, controlled output
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:02:54 +01:00
parent 442c1db580
commit 31a8d62b38
2 changed files with 283 additions and 406 deletions

View File

@ -1,46 +1,23 @@
"""
Hunter Companion Agent - Simplified and Controlled
"""
from __future__ import annotations
import json
import re
from typing import Any, AsyncIterator, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.subscription import Subscription, SubscriptionTier
from app.models.subscription import Subscription
from app.models.user import User
from app.services.llm_gateway import chat_completions, chat_completions_stream
from app.services.llm_tools import execute_tool, tool_catalog_for_prompt
settings = get_settings()
def _is_greeting(text: str) -> bool:
t = (text or "").strip().lower()
if not t:
return False
# common minimal greetings
greetings = {
"hi",
"hello",
"hey",
"yo",
"sup",
"hola",
"hallo",
"guten tag",
"good morning",
"good evening",
"good afternoon",
}
if t in greetings:
return True
# very short greeting-like messages
if len(t) <= 6 and t.replace("!", "").replace(".", "") in greetings:
return True
return False
def _tier_level(tier: str) -> int:
t = (tier or "").lower()
if t == "tycoon":
@ -52,7 +29,6 @@ def _tier_level(tier: str) -> int:
async def _get_user_tier(db: AsyncSession, user: User) -> str:
from sqlalchemy import select
res = await db.execute(select(Subscription).where(Subscription.user_id == user.id))
sub = res.scalar_one_or_none()
if not sub:
@ -60,29 +36,81 @@ async def _get_user_tier(db: AsyncSession, user: User) -> str:
return sub.tier.value
# Pre-defined responses for common queries (bypass LLM)
CANNED_RESPONSES = {
"greeting": (
"Hey! I'm your Hunter Companion.\n\n"
"I can help you with:\n"
"- Analyzing domains (just tell me the domain)\n"
"- Showing auctions ending soon\n"
"- Checking your watchlist or portfolio\n"
"- Finding dropped domains\n\n"
"What would you like to do?"
),
"capabilities": (
"Here's what I can do:\n\n"
"- Analyze any domain (Pounce Score, risk, value)\n"
"- Show current auctions and deals\n"
"- List recently dropped domains\n"
"- Check your watchlist status\n"
"- Review your portfolio performance\n"
"- Show your listings and leads\n\n"
"Just ask! For domain analysis, simply type the domain name."
),
}
def _is_greeting(text: str) -> bool:
t = (text or "").strip().lower()
greetings = {"hi", "hello", "hey", "yo", "sup", "hola", "hallo", "guten tag", "moin"}
clean = t.replace("!", "").replace(".", "").replace("?", "")
return clean in greetings or len(clean) <= 3 and clean in greetings
def _is_capabilities_question(text: str) -> bool:
t = (text or "").strip().lower()
patterns = [
"what can you do",
"what do you do",
"help",
"how can you help",
"what are you",
"who are you",
"capabilities",
"features",
"was kannst du",
"was machst du",
"hilfe",
]
return any(p in t for p in patterns)
def _extract_domain(text: str) -> Optional[str]:
"""Extract a domain name from user text."""
t = (text or "").strip().lower()
# Pattern: word.tld or word.word.tld
pattern = r'\b([a-z0-9][-a-z0-9]*\.)+[a-z]{2,}\b'
match = re.search(pattern, t)
if match:
domain = match.group(0)
# Filter out common non-domains
if domain not in {"example.com", "test.com", "domain.com"}:
return domain
return None
def _build_system_prompt(path: str) -> str:
tools = tool_catalog_for_prompt(path)
return (
"You are the Pounce Hunter Companion, a domain trading expert. Always respond in English.\n\n"
"CRITICAL RULES:\n"
"1. NEVER invent or hallucinate data. You do NOT have access to SEMrush, Estibot, GoDaddy sales, or external databases.\n"
"2. If you don't have data, say so honestly. Only use data from tools you actually called.\n"
"3. Keep responses SHORT: 2-3 sentences max, then bullets if needed.\n"
"4. NO markdown: no ** or *, no code blocks, no headers with #.\n"
"5. Use dashes (-) for bullet points.\n\n"
"WHAT YOU CAN DO:\n"
"- Analyze domains using the analyze_domain tool (gives Pounce Score, risk, value estimate)\n"
"- Show user's watchlist, portfolio, listings, inbox, yield data\n"
"- Search auctions and drops\n"
"- Generate brandable names\n\n"
"WHAT YOU CANNOT DO:\n"
"- Access external sales databases or SEO tools\n"
"- Look up real-time WHOIS or DNS (unless via tool)\n"
"- Make up sales history or traffic stats\n\n"
"TOOL USAGE:\n"
"- To call a tool, respond with ONLY: {\"tool_calls\":[{\"name\":\"...\",\"args\":{...}}]}\n"
"- After tool results, summarize briefly without mentioning tools.\n\n"
f"TOOLS:\n{json.dumps(tools, ensure_ascii=False)}\n"
"You are a domain trading assistant. Be brief and helpful.\n\n"
"RULES:\n"
"- Give SHORT answers (2-3 sentences max)\n"
"- Do NOT make up data. Only state facts from tool results.\n"
"- Do NOT format with markdown (no ** or *)\n"
"- If unsure, ask the user to clarify\n\n"
"TOOLS (call with JSON):\n"
f"{json.dumps([{'name': t['name'], 'description': t['description']} for t in tools], indent=2)}\n\n"
"To call a tool: {\"tool_calls\":[{\"name\":\"...\",\"args\":{}}]}"
)
@ -97,22 +125,64 @@ def _try_parse_tool_calls(text: str) -> Optional[list[dict[str, Any]]]:
calls = obj.get("tool_calls")
if not isinstance(calls, list):
return None
out: list[dict[str, Any]] = []
out = []
for c in calls:
if not isinstance(c, dict):
continue
name = c.get("name")
args = c.get("args") or {}
if isinstance(name, str) and isinstance(args, dict):
out.append({"name": name, "args": args})
if isinstance(c, dict) and isinstance(c.get("name"), str):
out.append({"name": c["name"], "args": c.get("args") or {}})
return out or None
def _truncate_json(value: Any, max_chars: int = 8000) -> str:
def _truncate_json(value: Any, max_chars: int = 6000) -> str:
s = json.dumps(value, ensure_ascii=False)
if len(s) <= max_chars:
return s
return s[: max_chars - 3] + "..."
return s[:max_chars] if len(s) > max_chars else s
def _format_analysis_result(data: dict) -> str:
"""Format domain analysis result into readable text."""
if "error" in data:
return f"Could not analyze: {data['error']}"
domain = data.get("domain", "unknown")
score = data.get("pounce_score", 0)
# Determine recommendation
if score >= 70:
rec = "BUY"
rec_reason = "Strong domain with good fundamentals"
elif score >= 50:
rec = "CONSIDER"
rec_reason = "Decent potential, evaluate based on your needs"
else:
rec = "SKIP"
rec_reason = "Limited potential or high risk"
lines = [
f"Domain: {domain}",
f"Pounce Score: {score}/100",
f"Recommendation: {rec}",
"",
]
# Add key metrics
if data.get("availability"):
avail = data["availability"]
status = "Available" if avail.get("is_available") else "Taken"
lines.append(f"Status: {status}")
if data.get("value"):
val = data["value"]
if val.get("estimated_value"):
lines.append(f"Est. Value: ${val['estimated_value']:,.0f}")
if data.get("risk"):
risk = data["risk"]
risk_level = risk.get("risk_level", "unknown")
lines.append(f"Risk: {risk_level}")
lines.append("")
lines.append(rec_reason)
return "\n".join(lines)
async def run_agent(
@ -122,37 +192,40 @@ async def run_agent(
messages: list[dict[str, Any]],
path: str,
model: Optional[str] = None,
temperature: float = 0.7,
max_steps: int = 6,
temperature: float = 0.3,
max_steps: int = 4,
) -> list[dict[str, Any]]:
"""
Runs a small tool loop to augment context, returning final messages to be used
for the final answer generation (optionally streamed).
"""
"""Run the agent with simplified logic."""
tier = await _get_user_tier(db, user)
if _tier_level(tier) < 2:
raise PermissionError("Chat is available on Trader and Tycoon plans. Upgrade to unlock.")
raise PermissionError("Hunter Companion requires Trader or Tycoon plan.")
# Get last user message
last_user = next((m for m in reversed(messages or []) if m.get("role") == "user"), None)
user_text = str(last_user.get("content", "")) if last_user else ""
base = [
{"role": "system", "content": _build_system_prompt(path)},
{"role": "system", "content": f"Context: current_terminal_path={path}; tier={tier}."},
]
# Handle canned responses (bypass LLM entirely)
if _is_greeting(user_text):
return base + messages + [{"role": "assistant", "content": CANNED_RESPONSES["greeting"]}]
if _is_capabilities_question(user_text):
return base + messages + [{"role": "assistant", "content": CANNED_RESPONSES["capabilities"]}]
# Auto-detect domain and analyze
domain = _extract_domain(user_text)
if domain:
# Directly call analyze_domain tool
result = await execute_tool(db, user, "analyze_domain", {"domain": domain}, path=path)
formatted = _format_analysis_result(result)
return base + messages + [{"role": "assistant", "content": formatted}]
# For other queries, use LLM with tool loop
convo = base + (messages or [])
# If the user just greets, answer naturally without tool-looping.
last_user = next((m for m in reversed(messages or []) if m.get("role") == "user"), None)
if last_user and _is_greeting(str(last_user.get("content") or "")):
convo.append(
{
"role": "assistant",
"content": (
"Hey! What can I help you with?\n\n"
"Give me a domain to analyze, or ask about your watchlist, portfolio, or current auctions."
),
}
)
return convo
for _ in range(max_steps):
payload = {
"model": model or settings.llm_default_model,
@ -165,51 +238,52 @@ async def run_agent(
tool_calls = _try_parse_tool_calls(content)
if not tool_calls:
# append assistant and stop
convo.append({"role": "assistant", "content": content})
return convo
# append the tool request as assistant message (so model can see its own plan)
convo.append({"role": "assistant", "content": content})
for call in tool_calls[:5]: # cap per step
for call in tool_calls[:3]:
name = call["name"]
args = call["args"]
result = await execute_tool(db, user, name, args, path=path)
convo.append(
{
"role": "system",
"content": (
f"TOOL_RESULT_INTERNAL name={name} json={_truncate_json(result)}. "
"This is internal context. Do NOT quote or display this to the user."
),
}
)
# Format specific tool results
if name == "analyze_domain":
formatted = _format_analysis_result(result)
convo.append({"role": "system", "content": f"Tool result:\n{formatted}"})
else:
convo.append({"role": "system", "content": f"Tool {name} result: {_truncate_json(result)}"})
# Fallback: force final answer even if tool loop didn't converge
convo.append(
{
"role": "system",
"content": "Now answer the user with the best possible answer using the tool results. Do NOT request tools.",
}
)
return convo
async def stream_final_answer(convo: list[dict[str, Any]], *, model: Optional[str], temperature: float) -> AsyncIterator[bytes]:
async def stream_final_answer(
convo: list[dict[str, Any]],
*,
model: Optional[str],
temperature: float
) -> AsyncIterator[bytes]:
"""Stream the final answer."""
# Check if last message is already a complete assistant response
if convo and convo[-1].get("role") == "assistant":
content = convo[-1].get("content", "")
if content and not content.strip().startswith("{"):
# Already have a good response, stream it directly
chunk = {
"choices": [{"delta": {"content": content}}]
}
yield f"data: {json.dumps(chunk)}\n\n".encode()
yield b"data: [DONE]\n\n"
return
# Otherwise, ask LLM to summarize
payload = {
"model": model or settings.llm_default_model,
"messages": convo
+ [
"messages": convo + [
{
"role": "system",
"content": (
"Respond now. Rules:\n"
"- NEVER invent data. Only use data from tools you called.\n"
"- Keep it SHORT: 2-3 sentences, then bullet points if needed.\n"
"- NO markdown (no ** or *), just plain text with dashes for bullets.\n"
"- Do NOT mention tools or JSON."
),
"content": "Give a brief, helpful response based on the data. No markdown formatting. Keep it short.",
}
],
"temperature": temperature,
@ -217,5 +291,3 @@ async def stream_final_answer(convo: list[dict[str, Any]], *, model: Optional[st
}
async for chunk in chat_completions_stream(payload):
yield chunk

View File

@ -12,19 +12,12 @@ import {
Loader2,
Lock,
Trash2,
TrendingUp,
Search,
Briefcase,
Target,
Zap,
} from 'lucide-react'
import { useStore } from '@/lib/store'
type Role = 'system' | 'user' | 'assistant'
type ChatMessage = {
id: string
role: Exclude<Role, 'system'>
role: 'user' | 'assistant'
content: string
createdAt: number
}
@ -42,7 +35,7 @@ function getApiBase(): string {
}
async function streamChat(opts: {
messages: Array<{ role: Role; content: string }>
messages: Array<{ role: string; content: string }>
path: string
onDelta: (delta: string) => void
}): Promise<void> {
@ -52,7 +45,7 @@ async function streamChat(opts: {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: opts.messages,
temperature: 0.5,
temperature: 0.3,
stream: true,
path: opts.path,
}),
@ -68,6 +61,7 @@ async function streamChat(opts: {
const decoder = new TextDecoder('utf-8')
let buffer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
@ -75,6 +69,7 @@ async function streamChat(opts: {
const parts = buffer.split('\n\n')
buffer = parts.pop() || ''
for (const p of parts) {
const line = p.split('\n').find((l) => l.startsWith('data: '))
if (!line) continue
@ -85,7 +80,7 @@ async function streamChat(opts: {
const delta = json?.choices?.[0]?.delta?.content
if (typeof delta === 'string' && delta.length) opts.onDelta(delta)
} catch {
// ignore
// ignore parse errors
}
}
}
@ -98,122 +93,20 @@ function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' {
return 'scout'
}
// Format message text to clean HTML with proper spacing
function formatMessage(text: string): string {
if (!text) return ''
// Escape HTML first
let html = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Remove markdown formatting (** and * for bold/italic)
html = html
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/__(.+?)__/g, '$1')
.replace(/_([^_]+)_/g, '$1')
// Split into paragraphs (double newline = paragraph break)
const paragraphs = html.split(/\n\n+/)
const formatted = paragraphs.map(para => {
// Check if this paragraph is a list (starts with - or number.)
const lines = para.split('\n')
const isList = lines.every(line => {
const trimmed = line.trim()
return trimmed === '' || trimmed.startsWith('-') || trimmed.startsWith('•') || /^\d+\./.test(trimmed)
})
if (isList) {
// Format as list
const items = lines
.map(line => line.trim())
.filter(line => line)
.map(line => {
// Remove leading dash, bullet, or number
const content = line.replace(/^[-•]\s*/, '').replace(/^\d+\.\s*/, '')
return `<div class="flex gap-2 py-0.5"><span class="text-accent/60 shrink-0">•</span><span>${content}</span></div>`
})
return `<div class="space-y-0.5">${items.join('')}</div>`
} else {
// Regular paragraph - convert single newlines to line breaks
return `<p class="mb-2 last:mb-0">${para.replace(/\n/g, '<br />')}</p>`
}
})
return formatted.join('')
}
// 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' },
]
// Suggestion chips based on current page
function getSuggestions(path: string): Array<{ label: string; prompt: string }> {
const p = (path || '').split('?')[0]
if (p.startsWith('/terminal/hunt')) {
return [
{ label: 'Ending soon', prompt: 'Show auctions ending in the next 2 hours' },
{ label: 'Find brandables', prompt: 'Generate 10 available 5-letter brandable .com domains' },
{ label: 'Trending now', prompt: 'What keywords are trending today?' },
]
}
if (p.startsWith('/terminal/market')) {
return [
{ label: 'Best deals', prompt: 'Show the best deals under $100' },
{ label: 'Ending today', prompt: 'What auctions are ending today?' },
]
}
if (p.startsWith('/terminal/watchlist')) {
return [
{ label: 'My watchlist', prompt: 'Show my watchlist' },
{ label: 'Analyze top', prompt: 'Analyze my most recently added domain' },
]
}
if (p.startsWith('/terminal/portfolio')) {
return [
{ label: 'Portfolio ROI', prompt: 'What is my overall portfolio ROI?' },
{ label: 'Renewals', prompt: 'Which domains are up for renewal soon?' },
{ label: 'Top performers', prompt: 'Show my top 5 domains by value' },
]
}
if (p.startsWith('/terminal/sniper')) {
return [
{ label: 'My alerts', prompt: 'Show my sniper alerts' },
{ label: 'Matching now', prompt: 'Are there auctions matching my alerts right now?' },
]
}
if (p.startsWith('/terminal/listing')) {
return [
{ label: 'My listings', prompt: 'Show my active listings' },
{ label: 'New leads', prompt: 'Do I have any new leads?' },
]
}
if (p.startsWith('/terminal/inbox')) {
return [
{ label: 'Unread count', prompt: 'How many unread messages do I have?' },
{ label: 'Recent threads', prompt: 'Show my recent inquiry threads' },
]
}
if (p.startsWith('/terminal/yield')) {
return [
{ label: 'Yield stats', prompt: 'Show my yield dashboard stats' },
{ label: 'Top earners', prompt: 'Which domains are earning the most?' },
]
}
// Default
return [
{ label: 'Market snapshot', prompt: 'Give me a quick market snapshot' },
{ label: 'Analyze domain', prompt: 'Analyze ' },
]
}
// Teaser examples for Scout
const TEASER_EXAMPLES = [
{ icon: Search, text: '"Analyze startup.io — should I buy?"' },
{ icon: TrendingUp, text: '"What auctions are ending in 2 hours?"' },
{ icon: Briefcase, text: '"What\'s my portfolio ROI?"' },
{ icon: Target, text: '"Find 5-letter .com brandables"' },
{ icon: Zap, text: '"Any new leads on my listings?"' },
// 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() {
@ -224,7 +117,7 @@ export function HunterCompanion() {
const storageKey = useMemo(() => {
const uidPart = user?.id ? String(user.id) : 'anon'
return `pounce:hunter_companion:v2:${uidPart}`
return `pounce:hc:v3:${uidPart}`
}, [user?.id])
const [open, setOpen] = useState(false)
@ -235,7 +128,7 @@ export function HunterCompanion() {
const listRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
// Load from localStorage
// Load messages
useEffect(() => {
if (!canChat) return
try {
@ -244,32 +137,28 @@ export function HunterCompanion() {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) setMessages(parsed)
}
} catch {
// ignore
}
} catch {}
}, [storageKey, canChat])
// Save to localStorage
// Save messages
useEffect(() => {
if (!canChat) return
if (!canChat || messages.length === 0) return
try {
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-60)))
} catch {
// ignore
}
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-50)))
} catch {}
}, [messages, storageKey, canChat])
// Auto-scroll
useEffect(() => {
if (!open) return
const el = listRef.current
if (el) el.scrollTop = el.scrollHeight
}, [open, messages.length])
if (open && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight
}
}, [open, messages])
// Focus input
useEffect(() => {
if (open && canChat) {
setTimeout(() => inputRef.current?.focus(), 50)
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [open, canChat])
@ -282,11 +171,12 @@ export function HunterCompanion() {
const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg, createdAt: Date.now() }
const assistantId = uid()
setMessages((prev) => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '', createdAt: Date.now() }])
setInput('')
setSending(true)
const history = messages.slice(-20).map((m) => ({ role: m.role as Role, content: m.content }))
const history = messages.slice(-10).map((m) => ({ role: m.role, content: m.content }))
try {
await streamChat({
@ -294,14 +184,14 @@ export function HunterCompanion() {
path: pathname || '/terminal/hunt',
onDelta: (delta) => {
setMessages((prev) =>
prev.map((m) => (m.id === assistantId ? { ...m, content: (m.content || '') + delta } : m))
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + delta } : m))
)
},
})
} catch (e: any) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Request failed'}` } : m
m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Failed'}` } : m
)
)
} finally {
@ -312,75 +202,51 @@ export function HunterCompanion() {
const clear = () => {
setMessages([])
try {
localStorage.removeItem(storageKey)
} catch {
// ignore
}
try { localStorage.removeItem(storageKey) } catch {}
}
const suggestions = getSuggestions(pathname || '/terminal/hunt')
return (
<>
{/* FAB */}
{/* Floating Button */}
<button
onClick={() => setOpen(true)}
className={clsx(
'fixed bottom-5 right-5 z-[150] w-14 h-14 border flex items-center justify-center transition-all',
'bg-[#0A0A0A]/90 backdrop-blur-md hover:scale-105',
'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',
canChat ? 'border-accent/40 text-accent' : 'border-white/10 text-white/40'
)}
aria-label="Open Hunter Companion"
>
{canChat && <div 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 animate-pulse" />}
{!canChat && <Lock className="absolute -top-1 -right-1 w-3 h-3 text-white/40" />}
<MessageSquare className="w-6 h-6" />
</button>
{/* Panel */}
{/* Chat Panel */}
{open && (
<div className="fixed inset-0 z-[160]">
<div className="absolute inset-0 bg-black/85 backdrop-blur-sm" onClick={() => setOpen(false)} />
<div className="absolute inset-0 bg-black/80" onClick={() => setOpen(false)} />
<div className="absolute bottom-4 right-4 w-[92vw] max-w-[440px] h-[75vh] max-h-[760px] bg-[#0A0A0A] border border-white/[0.08] shadow-2xl flex flex-col overflow-hidden">
<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">
{/* Header */}
<div className="px-4 py-3 border-b border-white/[0.08] bg-[#050505] flex items-center justify-between shrink-0">
<div className="min-w-0">
<div className="flex items-center gap-2">
{canChat ? (
<div className="w-2 h-2 bg-accent animate-pulse" />
) : (
<Lock className="w-3.5 h-3.5 text-white/40" />
)}
<span className="text-[11px] font-mono tracking-[0.15em] text-accent uppercase font-medium">
Hunter Companion
</span>
{!canChat && (
<span className="px-1.5 py-0.5 text-[9px] font-mono bg-white/5 border border-white/10 text-white/40">
LOCKED
</span>
)}
</div>
<div className="text-[10px] font-mono text-white/30 mt-0.5">
{canChat ? 'Domain trading AI assistant' : 'Upgrade to unlock'}
</div>
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-black/50">
<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">
Hunter Companion
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
{canChat && messages.length > 0 && (
<button
onClick={clear}
className="w-8 h-8 flex items-center justify-center text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
title="Clear chat"
>
<button onClick={clear} className="p-2 text-white/40 hover:text-white/70">
<Trash2 className="w-4 h-4" />
</button>
)}
<button
onClick={() => setOpen(false)}
className="w-8 h-8 flex items-center justify-center text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
title="Close"
>
<button onClick={() => setOpen(false)} className="p-2 text-white/40 hover:text-white/70">
<X className="w-4 h-4" />
</button>
</div>
@ -389,54 +255,39 @@ export function HunterCompanion() {
{/* Body */}
<div ref={listRef} className="flex-1 overflow-y-auto p-4">
{!canChat ? (
/* Scout Teaser */
<div className="h-full flex flex-col">
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
<div className="w-16 h-16 border border-accent/20 bg-accent/5 flex items-center justify-center mb-4">
<Sparkles className="w-8 h-8 text-accent" />
</div>
<h3 className="text-sm font-medium text-white mb-2">AI Trading Assistant</h3>
<p className="text-xs font-mono text-white/50 leading-relaxed max-w-[280px] mb-6">
Get instant domain analysis, market insights, portfolio advice, and deal recommendations.
</p>
<div className="w-full space-y-2 mb-6">
{TEASER_EXAMPLES.map((ex, i) => (
<div
key={i}
className="flex items-center gap-3 px-3 py-2 bg-white/[0.02] border border-white/[0.06] text-left"
>
<ex.icon className="w-4 h-4 text-accent/60 shrink-0" />
<span className="text-[11px] font-mono text-white/50">{ex.text}</span>
</div>
))}
</div>
<Link
href="/pricing"
className="px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-accent/90 transition-colors"
>
Upgrade to Trader
</Link>
<p className="text-[10px] font-mono text-white/30 mt-3">
Available on Trader & Tycoon plans
</p>
// Scout Teaser
<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.
</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"
>
Upgrade to Trader
</Link>
</div>
) : messages.length === 0 ? (
/* Empty state for Trader/Tycoon */
<div className="h-full flex flex-col items-center justify-center text-center px-4">
<div className="w-12 h-12 border border-accent/20 bg-accent/5 flex items-center justify-center mb-3">
<Sparkles className="w-6 h-6 text-accent" />
</div>
<h3 className="text-sm font-medium text-white mb-1">Ready to hunt</h3>
<p className="text-[11px] font-mono text-white/40 mb-4">
Ask me anything about domains, auctions, or your portfolio.
// 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" />
<p className="text-xs text-white/50 mb-4">
Ask me about domains, auctions, or your portfolio.
</p>
{/* Quick suggestions */}
<div className="flex flex-wrap justify-center gap-2">
{suggestions.map((s, i) => (
{SUGGESTIONS.map((s, i) => (
<button
key={i}
onClick={() => {
@ -447,7 +298,7 @@ export function HunterCompanion() {
send(s.prompt)
}
}}
className="px-3 py-1.5 text-[10px] font-mono border border-accent/20 bg-accent/5 text-accent hover:bg-accent/10 transition-colors"
className="px-3 py-1.5 text-[10px] font-mono border border-accent/20 text-accent hover:bg-accent/10"
>
{s.label}
</button>
@ -455,52 +306,30 @@ export function HunterCompanion() {
</div>
</div>
) : (
/* Chat 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-[88%] px-3 py-2 text-xs leading-relaxed',
'max-w-[85%] px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap',
m.role === 'user'
? 'bg-accent/10 border border-accent/20 text-accent'
: 'bg-white/[0.03] border border-white/[0.08] text-white/80'
? 'bg-accent/10 border border-accent/20 text-accent font-mono'
: 'bg-white/5 border border-white/10 text-white/80'
)}
>
{m.role === 'assistant' ? (
<div
className="prose-chat"
dangerouslySetInnerHTML={{ __html: formatMessage(m.content) || (sending ? '…' : '') }}
/>
) : (
<span className="font-mono">{m.content}</span>
)}
{m.content || (sending && m.role === 'assistant' ? '...' : '')}
</div>
</div>
))}
{/* Suggestion chips after messages */}
{!sending && messages.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-2">
{suggestions.slice(0, 2).map((s, i) => (
<button
key={i}
onClick={() => send(s.prompt)}
className="px-2 py-1 text-[9px] font-mono border border-white/10 text-white/40 hover:text-white/60 hover:border-white/20 transition-colors"
>
{s.label}
</button>
))}
</div>
)}
</div>
)}
</div>
{/* Input (only for Trader/Tycoon) */}
{/* Input */}
{canChat && (
<div className="p-3 border-t border-white/[0.08] bg-[#050505] shrink-0">
<div className="flex items-center gap-2">
<div className="p-3 border-t border-white/10 bg-black/50">
<div className="flex gap-2">
<input
ref={inputRef}
value={input}
@ -511,19 +340,14 @@ export function HunterCompanion() {
send()
}
}}
placeholder="Ask about domains, auctions, portfolio…"
className="flex-1 px-3 py-2.5 bg-white/[0.03] border border-white/[0.08] text-white text-xs font-mono outline-none focus:border-accent/30 placeholder:text-white/25"
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}
/>
<button
onClick={() => send()}
disabled={sending || !input.trim()}
className={clsx(
'w-10 h-10 flex items-center justify-center border transition-all',
'disabled:opacity-40 disabled:cursor-not-allowed',
'border-accent/30 bg-accent/10 text-accent hover:bg-accent/20'
)}
aria-label="Send"
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" />}
</button>
@ -533,25 +357,6 @@ export function HunterCompanion() {
</div>
</div>
)}
{/* Styles for formatted messages */}
<style jsx global>{`
.prose-chat {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.7;
}
.prose-chat p {
margin-bottom: 0.75rem;
}
.prose-chat p:last-child {
margin-bottom: 0;
}
.prose-chat .space-y-0\.5 > div {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
`}</style>
</>
)
}