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 from __future__ import annotations
import json import json
import re
from typing import Any, AsyncIterator, Optional from typing import Any, AsyncIterator, Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings 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.models.user import User
from app.services.llm_gateway import chat_completions, chat_completions_stream from app.services.llm_gateway import chat_completions, chat_completions_stream
from app.services.llm_tools import execute_tool, tool_catalog_for_prompt from app.services.llm_tools import execute_tool, tool_catalog_for_prompt
settings = get_settings() 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: def _tier_level(tier: str) -> int:
t = (tier or "").lower() t = (tier or "").lower()
if t == "tycoon": if t == "tycoon":
@ -52,7 +29,6 @@ def _tier_level(tier: str) -> int:
async def _get_user_tier(db: AsyncSession, user: User) -> str: async def _get_user_tier(db: AsyncSession, user: User) -> str:
from sqlalchemy import select from sqlalchemy import select
res = await db.execute(select(Subscription).where(Subscription.user_id == user.id)) res = await db.execute(select(Subscription).where(Subscription.user_id == user.id))
sub = res.scalar_one_or_none() sub = res.scalar_one_or_none()
if not sub: if not sub:
@ -60,29 +36,81 @@ async def _get_user_tier(db: AsyncSession, user: User) -> str:
return sub.tier.value 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: def _build_system_prompt(path: str) -> str:
tools = tool_catalog_for_prompt(path) tools = tool_catalog_for_prompt(path)
return ( return (
"You are the Pounce Hunter Companion, a domain trading expert. Always respond in English.\n\n" "You are a domain trading assistant. Be brief and helpful.\n\n"
"CRITICAL RULES:\n" "RULES:\n"
"1. NEVER invent or hallucinate data. You do NOT have access to SEMrush, Estibot, GoDaddy sales, or external databases.\n" "- Give SHORT answers (2-3 sentences max)\n"
"2. If you don't have data, say so honestly. Only use data from tools you actually called.\n" "- Do NOT make up data. Only state facts from tool results.\n"
"3. Keep responses SHORT: 2-3 sentences max, then bullets if needed.\n" "- Do NOT format with markdown (no ** or *)\n"
"4. NO markdown: no ** or *, no code blocks, no headers with #.\n" "- If unsure, ask the user to clarify\n\n"
"5. Use dashes (-) for bullet points.\n\n" "TOOLS (call with JSON):\n"
"WHAT YOU CAN DO:\n" f"{json.dumps([{'name': t['name'], 'description': t['description']} for t in tools], indent=2)}\n\n"
"- Analyze domains using the analyze_domain tool (gives Pounce Score, risk, value estimate)\n" "To call a tool: {\"tool_calls\":[{\"name\":\"...\",\"args\":{}}]}"
"- 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"
) )
@ -97,22 +125,64 @@ def _try_parse_tool_calls(text: str) -> Optional[list[dict[str, Any]]]:
calls = obj.get("tool_calls") calls = obj.get("tool_calls")
if not isinstance(calls, list): if not isinstance(calls, list):
return None return None
out: list[dict[str, Any]] = [] out = []
for c in calls: for c in calls:
if not isinstance(c, dict): if isinstance(c, dict) and isinstance(c.get("name"), str):
continue out.append({"name": c["name"], "args": c.get("args") or {}})
name = c.get("name")
args = c.get("args") or {}
if isinstance(name, str) and isinstance(args, dict):
out.append({"name": name, "args": args})
return out or None 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) s = json.dumps(value, ensure_ascii=False)
if len(s) <= max_chars: return s[:max_chars] if len(s) > max_chars else s
return s
return s[: max_chars - 3] + "..."
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( async def run_agent(
@ -122,37 +192,40 @@ async def run_agent(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
path: str, path: str,
model: Optional[str] = None, model: Optional[str] = None,
temperature: float = 0.7, temperature: float = 0.3,
max_steps: int = 6, max_steps: int = 4,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """Run the agent with simplified logic."""
Runs a small tool loop to augment context, returning final messages to be used
for the final answer generation (optionally streamed).
"""
tier = await _get_user_tier(db, user) tier = await _get_user_tier(db, user)
if _tier_level(tier) < 2: 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 = [ base = [
{"role": "system", "content": _build_system_prompt(path)}, {"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 []) 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): for _ in range(max_steps):
payload = { payload = {
"model": model or settings.llm_default_model, "model": model or settings.llm_default_model,
@ -165,51 +238,52 @@ async def run_agent(
tool_calls = _try_parse_tool_calls(content) tool_calls = _try_parse_tool_calls(content)
if not tool_calls: if not tool_calls:
# append assistant and stop
convo.append({"role": "assistant", "content": content}) convo.append({"role": "assistant", "content": content})
return convo return convo
# append the tool request as assistant message (so model can see its own plan)
convo.append({"role": "assistant", "content": content}) convo.append({"role": "assistant", "content": content})
for call in tool_calls[:5]: # cap per step for call in tool_calls[:3]:
name = call["name"] name = call["name"]
args = call["args"] args = call["args"]
result = await execute_tool(db, user, name, args, path=path) result = await execute_tool(db, user, name, args, path=path)
convo.append(
{ # Format specific tool results
"role": "system", if name == "analyze_domain":
"content": ( formatted = _format_analysis_result(result)
f"TOOL_RESULT_INTERNAL name={name} json={_truncate_json(result)}. " convo.append({"role": "system", "content": f"Tool result:\n{formatted}"})
"This is internal context. Do NOT quote or display this to the user." 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 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 = { payload = {
"model": model or settings.llm_default_model, "model": model or settings.llm_default_model,
"messages": convo "messages": convo + [
+ [
{ {
"role": "system", "role": "system",
"content": ( "content": "Give a brief, helpful response based on the data. No markdown formatting. Keep it short.",
"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."
),
} }
], ],
"temperature": temperature, "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): async for chunk in chat_completions_stream(payload):
yield chunk yield chunk

View File

@ -12,19 +12,12 @@ import {
Loader2, Loader2,
Lock, Lock,
Trash2, Trash2,
TrendingUp,
Search,
Briefcase,
Target,
Zap,
} from 'lucide-react' } from 'lucide-react'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
type Role = 'system' | 'user' | 'assistant'
type ChatMessage = { type ChatMessage = {
id: string id: string
role: Exclude<Role, 'system'> role: 'user' | 'assistant'
content: string content: string
createdAt: number createdAt: number
} }
@ -42,7 +35,7 @@ function getApiBase(): string {
} }
async function streamChat(opts: { async function streamChat(opts: {
messages: Array<{ role: Role; content: string }> messages: Array<{ role: string; content: string }>
path: string path: string
onDelta: (delta: string) => void onDelta: (delta: string) => void
}): Promise<void> { }): Promise<void> {
@ -52,7 +45,7 @@ async function streamChat(opts: {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
messages: opts.messages, messages: opts.messages,
temperature: 0.5, temperature: 0.3,
stream: true, stream: true,
path: opts.path, path: opts.path,
}), }),
@ -68,6 +61,7 @@ async function streamChat(opts: {
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')
let buffer = '' let buffer = ''
for (;;) { for (;;) {
const { value, done } = await reader.read() const { value, done } = await reader.read()
if (done) break if (done) break
@ -75,6 +69,7 @@ async function streamChat(opts: {
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 p of parts) {
const line = p.split('\n').find((l) => l.startsWith('data: ')) const line = p.split('\n').find((l) => l.startsWith('data: '))
if (!line) continue if (!line) continue
@ -85,7 +80,7 @@ async function streamChat(opts: {
const delta = json?.choices?.[0]?.delta?.content const delta = json?.choices?.[0]?.delta?.content
if (typeof delta === 'string' && delta.length) opts.onDelta(delta) if (typeof delta === 'string' && delta.length) opts.onDelta(delta)
} catch { } catch {
// ignore // ignore parse errors
} }
} }
} }
@ -98,122 +93,20 @@ function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' {
return 'scout' return 'scout'
} }
// Format message text to clean HTML with proper spacing // Quick action suggestions
function formatMessage(text: string): string { const SUGGESTIONS = [
if (!text) return '' { label: 'Analyze a domain', prompt: 'analyze ' },
{ label: 'Ending soon', prompt: 'Show auctions ending in 2 hours' },
// Escape HTML first { label: 'My watchlist', prompt: 'Show my watchlist' },
let html = text { label: 'Portfolio stats', prompt: 'Show my portfolio summary' },
.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('')
}
// Suggestion chips based on current page // Teaser for Scout users
function getSuggestions(path: string): Array<{ label: string; prompt: string }> { const TEASER_ITEMS = [
const p = (path || '').split('?')[0] 'Analyze any domain instantly',
'Get BUY / SKIP recommendations',
if (p.startsWith('/terminal/hunt')) { 'Track auctions and drops',
return [ 'Monitor your portfolio',
{ 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?"' },
] ]
export function HunterCompanion() { export function HunterCompanion() {
@ -224,7 +117,7 @@ export function HunterCompanion() {
const storageKey = useMemo(() => { const storageKey = useMemo(() => {
const uidPart = user?.id ? String(user.id) : 'anon' const uidPart = user?.id ? String(user.id) : 'anon'
return `pounce:hunter_companion:v2:${uidPart}` return `pounce:hc:v3:${uidPart}`
}, [user?.id]) }, [user?.id])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -235,7 +128,7 @@ export function HunterCompanion() {
const listRef = useRef<HTMLDivElement | null>(null) const listRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null) const inputRef = useRef<HTMLInputElement | null>(null)
// Load from localStorage // Load messages
useEffect(() => { useEffect(() => {
if (!canChat) return if (!canChat) return
try { try {
@ -244,32 +137,28 @@ export function HunterCompanion() {
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) setMessages(parsed) if (Array.isArray(parsed)) setMessages(parsed)
} }
} catch { } catch {}
// ignore
}
}, [storageKey, canChat]) }, [storageKey, canChat])
// Save to localStorage // Save messages
useEffect(() => { useEffect(() => {
if (!canChat) return if (!canChat || messages.length === 0) return
try { try {
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-60))) localStorage.setItem(storageKey, JSON.stringify(messages.slice(-50)))
} catch { } catch {}
// ignore
}
}, [messages, storageKey, canChat]) }, [messages, storageKey, canChat])
// Auto-scroll // Auto-scroll
useEffect(() => { useEffect(() => {
if (!open) return if (open && listRef.current) {
const el = listRef.current listRef.current.scrollTop = listRef.current.scrollHeight
if (el) el.scrollTop = el.scrollHeight }
}, [open, messages.length]) }, [open, messages])
// Focus input // Focus input
useEffect(() => { useEffect(() => {
if (open && canChat) { if (open && canChat) {
setTimeout(() => inputRef.current?.focus(), 50) setTimeout(() => inputRef.current?.focus(), 100)
} }
}, [open, canChat]) }, [open, canChat])
@ -282,11 +171,12 @@ export function HunterCompanion() {
const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg, createdAt: Date.now() } const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg, createdAt: Date.now() }
const assistantId = uid() const assistantId = uid()
setMessages((prev) => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '', createdAt: Date.now() }]) setMessages((prev) => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '', createdAt: Date.now() }])
setInput('') setInput('')
setSending(true) 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 { try {
await streamChat({ await streamChat({
@ -294,14 +184,14 @@ export function HunterCompanion() {
path: pathname || '/terminal/hunt', path: pathname || '/terminal/hunt',
onDelta: (delta) => { onDelta: (delta) => {
setMessages((prev) => 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) { } catch (e: any) {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => 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 { } finally {
@ -312,75 +202,51 @@ export function HunterCompanion() {
const clear = () => { const clear = () => {
setMessages([]) setMessages([])
try { try { localStorage.removeItem(storageKey) } catch {}
localStorage.removeItem(storageKey)
} catch {
// ignore
}
} }
const suggestions = getSuggestions(pathname || '/terminal/hunt')
return ( return (
<> <>
{/* FAB */} {/* Floating Button */}
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className={clsx( className={clsx(
'fixed bottom-5 right-5 z-[150] w-14 h-14 border flex items-center justify-center transition-all', 'fixed bottom-5 right-5 z-[150] w-14 h-14 flex items-center justify-center',
'bg-[#0A0A0A]/90 backdrop-blur-md hover:scale-105', 'bg-black/90 backdrop-blur 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'
)} )}
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" />} {!canChat && <Lock className="absolute -top-1 -right-1 w-3 h-3 text-white/40" />}
<MessageSquare className="w-6 h-6" /> <MessageSquare className="w-6 h-6" />
</button> </button>
{/* Panel */} {/* Chat Panel */}
{open && ( {open && (
<div className="fixed inset-0 z-[160]"> <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 */} {/* Header */}
<div className="px-4 py-3 border-b border-white/[0.08] bg-[#050505] flex items-center justify-between shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-black/50">
<div className="min-w-0"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> {canChat ? (
{canChat ? ( <span className="w-2 h-2 bg-accent animate-pulse" />
<div className="w-2 h-2 bg-accent animate-pulse" /> ) : (
) : ( <Lock className="w-3.5 h-3.5 text-white/40" />
<Lock className="w-3.5 h-3.5 text-white/40" /> )}
)} <span className="text-xs font-mono text-accent uppercase tracking-wider">
<span className="text-[11px] font-mono tracking-[0.15em] text-accent uppercase font-medium"> Hunter Companion
Hunter Companion </span>
</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> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1">
{canChat && messages.length > 0 && ( {canChat && messages.length > 0 && (
<button <button onClick={clear} className="p-2 text-white/40 hover:text-white/70">
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"
>
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
)} )}
<button <button onClick={() => setOpen(false)} className="p-2 text-white/40 hover:text-white/70">
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"
>
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
@ -389,54 +255,39 @@ export function HunterCompanion() {
{/* Body */} {/* Body */}
<div ref={listRef} className="flex-1 overflow-y-auto p-4"> <div ref={listRef} className="flex-1 overflow-y-auto p-4">
{!canChat ? ( {!canChat ? (
/* Scout Teaser */ // Scout Teaser
<div className="h-full flex flex-col"> <div className="h-full flex flex-col items-center justify-center text-center px-4">
<div className="flex-1 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">
<div className="w-16 h-16 border border-accent/20 bg-accent/5 flex items-center justify-center mb-4"> <Sparkles className="w-7 h-7 text-accent" />
<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>
</div> </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> </div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
/* Empty state for Trader/Tycoon */ // Empty state
<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">
<div className="w-12 h-12 border border-accent/20 bg-accent/5 flex items-center justify-center mb-3"> <Sparkles className="w-8 h-8 text-accent/60 mb-3" />
<Sparkles className="w-6 h-6 text-accent" /> <p className="text-xs text-white/50 mb-4">
</div> Ask me about domains, auctions, or your portfolio.
<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.
</p> </p>
{/* Quick suggestions */}
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
{suggestions.map((s, i) => ( {SUGGESTIONS.map((s, i) => (
<button <button
key={i} key={i}
onClick={() => { onClick={() => {
@ -447,7 +298,7 @@ export function HunterCompanion() {
send(s.prompt) 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} {s.label}
</button> </button>
@ -455,52 +306,30 @@ export function HunterCompanion() {
</div> </div>
</div> </div>
) : ( ) : (
/* Chat 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-[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' m.role === 'user'
? 'bg-accent/10 border border-accent/20 text-accent' ? 'bg-accent/10 border border-accent/20 text-accent font-mono'
: 'bg-white/[0.03] border border-white/[0.08] text-white/80' : 'bg-white/5 border border-white/10 text-white/80'
)} )}
> >
{m.role === 'assistant' ? ( {m.content || (sending && m.role === 'assistant' ? '...' : '')}
<div
className="prose-chat"
dangerouslySetInnerHTML={{ __html: formatMessage(m.content) || (sending ? '…' : '') }}
/>
) : (
<span className="font-mono">{m.content}</span>
)}
</div> </div>
</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>
)} )}
</div> </div>
{/* Input (only for Trader/Tycoon) */} {/* Input */}
{canChat && ( {canChat && (
<div className="p-3 border-t border-white/[0.08] bg-[#050505] shrink-0"> <div className="p-3 border-t border-white/10 bg-black/50">
<div className="flex items-center gap-2"> <div className="flex gap-2">
<input <input
ref={inputRef} ref={inputRef}
value={input} value={input}
@ -511,19 +340,14 @@ export function HunterCompanion() {
send() send()
} }
}} }}
placeholder="Ask about domains, auctions, portfolio…" placeholder="Type a domain or ask a question..."
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" 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} disabled={sending}
/> />
<button <button
onClick={() => send()} onClick={() => send()}
disabled={sending || !input.trim()} disabled={sending || !input.trim()}
className={clsx( className="w-10 h-10 flex items-center justify-center border border-accent/30 bg-accent/10 text-accent disabled:opacity-40"
'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"
> >
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />} {sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</button> </button>
@ -533,25 +357,6 @@ export function HunterCompanion() {
</div> </div>
</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>
</> </>
) )
} }