Hunter Companion: pure trading assistant (Scout=teaser, Trader/Tycoon=full), clean formatting, suggestion chips
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
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:
@ -63,31 +63,23 @@ async def _get_user_tier(db: AsyncSession, user: User) -> str:
|
||||
def _build_system_prompt(path: str) -> str:
|
||||
tools = tool_catalog_for_prompt(path)
|
||||
return (
|
||||
"You are the Pounce Hunter Companion (domain trading expert). Always respond in English.\n"
|
||||
"You have access to internal tools that return live data. Use tools when needed.\n\n"
|
||||
"OUTPUT STYLE:\n"
|
||||
"- Never show raw tool output to the user.\n"
|
||||
"- Never print phrases like 'Tool Result', 'TOOL_RESULT', or code-fenced JSON.\n"
|
||||
"- If you used tools, silently incorporate the data and present ONLY a clean summary.\n"
|
||||
"- Keep formatting simple: short paragraphs and bullet points. Avoid dumping structured data.\n\n"
|
||||
"You are the Pounce Hunter Companion, an expert domain trading assistant. Always respond in English.\n"
|
||||
"You help users with: domain analysis, auction hunting, portfolio management, and trading decisions.\n\n"
|
||||
"RESPONSE FORMAT (CRITICAL):\n"
|
||||
"- Write in plain text. NO markdown asterisks, NO ** or *, NO code blocks.\n"
|
||||
"- Use simple dashes (-) for bullet points.\n"
|
||||
"- Keep responses concise: 2-4 sentences intro, then bullets if needed.\n"
|
||||
"- Never show tool outputs, JSON, or internal data to the user.\n\n"
|
||||
"BEHAVIOR:\n"
|
||||
"- Do NOT invent user preferences, keywords, TLDs, budgets, or tasks.\n"
|
||||
"- If the user greets you or sends a minimal message (e.g., 'hi', 'hello'), respond naturally and ask what they want help with.\n"
|
||||
"- Ask 1–2 clarifying questions when the user request is ambiguous.\n\n"
|
||||
"WHEN TO USE TOOLS:\n"
|
||||
"- Use tools only when the user explicitly asks about their account data, their current page, their lists (watchlist/portfolio/listings/inbox/yield), or when they provide a specific domain to analyze.\n"
|
||||
"- Never proactively 'fetch' domains or run scans based on guessed keywords.\n\n"
|
||||
"TOOL CALLING PROTOCOL:\n"
|
||||
"- If you need data, respond with ONLY a JSON object:\n"
|
||||
' {"tool_calls":[{"name":"tool_name","args":{...}}, ...]}\n'
|
||||
"- Do not include any other text when requesting tools.\n"
|
||||
"- After tools are executed, you will receive TOOL_RESULT messages.\n"
|
||||
"- When you are ready to answer the user, respond normally (not JSON) and do NOT mention tools.\n\n"
|
||||
"AVAILABLE TOOLS (JSON schemas):\n"
|
||||
f"{json.dumps(tools, ensure_ascii=False)}\n\n"
|
||||
"RULES:\n"
|
||||
"- Never claim you checked external sources unless the user provided the data.\n"
|
||||
"- Keep answers practical and decisive. If (and only if) the user is asking about a specific domain: include BUY/CONSIDER/SKIP + bullets.\n"
|
||||
"- Be helpful, direct, and conversational like a knowledgeable colleague.\n"
|
||||
"- For domain questions: give a clear BUY / CONSIDER / SKIP recommendation with 3-5 reasons.\n"
|
||||
"- Do NOT invent user preferences, keywords, or data. Ask if unclear.\n"
|
||||
"- For greetings: respond naturally and ask how you can help.\n\n"
|
||||
"TOOL USAGE:\n"
|
||||
"- Use tools when user asks about their data (watchlist, portfolio, listings, inbox, yield) or a specific domain.\n"
|
||||
"- To call tools, respond with ONLY: {\"tool_calls\":[{\"name\":\"...\",\"args\":{...}}]}\n"
|
||||
"- After receiving tool results, answer naturally without mentioning tools.\n\n"
|
||||
f"AVAILABLE TOOLS:\n{json.dumps(tools, ensure_ascii=False)}\n"
|
||||
)
|
||||
|
||||
|
||||
@ -151,11 +143,13 @@ async def run_agent(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"Hey — how can I help?\n\n"
|
||||
"If you want, tell me:\n"
|
||||
"- which Terminal page you’re on (or what you’re trying to do)\n"
|
||||
"- or a specific domain you’re considering\n"
|
||||
"- or what outcome you want (find deals, assess a name, manage leads, etc.)"
|
||||
"Hey! How can I help you today?\n\n"
|
||||
"I can help with:\n"
|
||||
"- Analyzing a specific domain\n"
|
||||
"- Finding auction deals or drops\n"
|
||||
"- Reviewing your portfolio or watchlist\n"
|
||||
"- Checking your listings and leads\n\n"
|
||||
"Just tell me what you need."
|
||||
),
|
||||
}
|
||||
)
|
||||
@ -212,11 +206,11 @@ async def stream_final_answer(convo: list[dict[str, Any]], *, model: Optional[st
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Final step: respond to the user.\n"
|
||||
"- Do NOT output JSON tool_calls.\n"
|
||||
"- Do NOT request tools.\n"
|
||||
"- Do NOT include raw tool outputs, internal tags, or code-fenced JSON.\n"
|
||||
"- If you used tools, present only a clean human summary."
|
||||
"Final step: respond to the user in plain text.\n"
|
||||
"- NO markdown: no ** or * for bold/italic, no code blocks.\n"
|
||||
"- Use dashes (-) for bullets.\n"
|
||||
"- Do NOT output JSON or mention tools.\n"
|
||||
"- Be concise and helpful."
|
||||
),
|
||||
}
|
||||
],
|
||||
|
||||
@ -4,9 +4,21 @@ 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,
|
||||
TrendingUp,
|
||||
Search,
|
||||
Briefcase,
|
||||
Target,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { buildUiHelpAnswer, isUiHelpQuestion } from '@/components/chat/terminalHelp'
|
||||
|
||||
type Role = 'system' | 'user' | 'assistant'
|
||||
|
||||
@ -29,31 +41,26 @@ function getApiBase(): string {
|
||||
return `${protocol}//${hostname}/api/v1`
|
||||
}
|
||||
|
||||
async function streamChatCompletion(opts: {
|
||||
model?: string
|
||||
async function streamChat(opts: {
|
||||
messages: Array<{ role: Role; content: string }>
|
||||
temperature?: number
|
||||
path: string
|
||||
onDelta: (delta: string) => void
|
||||
path?: string
|
||||
}): Promise<void> {
|
||||
// Use tool-calling agent for Trader/Tycoon. (Scout stays UI-help only.)
|
||||
const res = await fetch(`${getApiBase()}/llm/agent`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: opts.model,
|
||||
messages: opts.messages,
|
||||
temperature: opts.temperature,
|
||||
temperature: 0.5,
|
||||
stream: true,
|
||||
path: opts.path || '/terminal/hunt',
|
||||
path: opts.path,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail = data?.detail || res.statusText || 'Request failed'
|
||||
throw new Error(detail)
|
||||
throw new Error(data?.detail || res.statusText || 'Request failed')
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
@ -66,7 +73,6 @@ async function streamChatCompletion(opts: {
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// SSE frames separated by blank line
|
||||
const parts = buffer.split('\n\n')
|
||||
buffer = parts.pop() || ''
|
||||
for (const p of parts) {
|
||||
@ -92,6 +98,99 @@ function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' {
|
||||
return 'scout'
|
||||
}
|
||||
|
||||
// Simple markdown-like formatting to clean HTML
|
||||
function formatMessage(text: string): string {
|
||||
if (!text) return ''
|
||||
let html = text
|
||||
// Escape HTML
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Bold: **text** or __text__
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
// Italic: *text* or _text_ (but not inside words)
|
||||
.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, '<em>$1</em>')
|
||||
.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '<em>$1</em>')
|
||||
// Inline code: `code`
|
||||
.replace(/`([^`]+)`/g, '<code class="bg-white/10 px-1 py-0.5 text-accent">$1</code>')
|
||||
// Line breaks
|
||||
.replace(/\n/g, '<br />')
|
||||
// Bullet points: - item or • item
|
||||
.replace(/<br \/>[-•]\s+/g, '<br />• ')
|
||||
return html
|
||||
}
|
||||
|
||||
// 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?"' },
|
||||
]
|
||||
|
||||
export function HunterCompanion() {
|
||||
const pathname = usePathname()
|
||||
const { subscription, user } = useStore()
|
||||
@ -100,132 +199,94 @@ export function HunterCompanion() {
|
||||
|
||||
const storageKey = useMemo(() => {
|
||||
const uidPart = user?.id ? String(user.id) : 'anon'
|
||||
return `pounce:hunter_companion:v1:${uidPart}`
|
||||
return `pounce:hunter_companion:v2:${uidPart}`
|
||||
}, [user?.id])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [input, setInput] = useState('')
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// Load from localStorage
|
||||
useEffect(() => {
|
||||
if (!canChat) return
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) setMessages(parsed)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) setMessages(parsed)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [storageKey])
|
||||
}, [storageKey, canChat])
|
||||
|
||||
// Save to localStorage
|
||||
useEffect(() => {
|
||||
if (!canChat) return
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-60)))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [messages, storageKey])
|
||||
}, [messages, storageKey, canChat])
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const el = listRef.current
|
||||
if (!el) return
|
||||
el.scrollTop = el.scrollHeight
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [open, messages.length])
|
||||
|
||||
// Focus input
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
// Focus input on open
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
}, [open])
|
||||
if (open && canChat) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open, canChat])
|
||||
|
||||
// Only show inside terminal routes
|
||||
// Only show in terminal
|
||||
if (!pathname?.startsWith('/terminal')) return null
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || sending) return
|
||||
setError(null)
|
||||
const send = async (text?: string) => {
|
||||
const msg = (text || input).trim()
|
||||
if (!msg || sending || !canChat) return
|
||||
|
||||
// Scout UI-help: answer locally (no LLM call) so Scout can learn the app.
|
||||
if (!canChat && isUiHelpQuestion(text)) {
|
||||
const userMsg: ChatMessage = { id: uid(), role: 'user', content: text, createdAt: Date.now() }
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: uid(),
|
||||
role: 'assistant',
|
||||
content: buildUiHelpAnswer(pathname || '/terminal/hunt', text),
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg])
|
||||
setInput('')
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return
|
||||
}
|
||||
|
||||
const userMsg: ChatMessage = { id: uid(), role: 'user', content: text, createdAt: Date.now() }
|
||||
const assistantMsgId = uid()
|
||||
setMessages((prev) => [...prev, userMsg, { id: assistantMsgId, role: 'assistant', content: '', createdAt: Date.now() }])
|
||||
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 system = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content:
|
||||
'You are the Pounce Hunter Companion: a domain trading expert (auctions, drops, brandables, pricing, risk, SEO, and negotiation). Always respond in English. Be natural and helpful like a normal chat.\n\nIf the user’s question is domain-related, be decisional and structured:\n- Recommendation: BUY / CONSIDER / SKIP\n- 3–6 bullets “Why”\n- Ask targeted follow-up questions if key info is missing (budget, use-case, timeframe, TLD preferences).\n\nIf the user asks about how the app works, explain the UI and workflow step-by-step.\n\nCritical: Never claim you checked external sources unless the user provided the data.',
|
||||
},
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: `Context: current_terminal_path=${pathname}; tier=${tier}.`,
|
||||
},
|
||||
]
|
||||
|
||||
const history = messages
|
||||
.slice(-20)
|
||||
.map((m) => ({ role: m.role as Role, content: m.content }))
|
||||
const history = messages.slice(-20).map((m) => ({ role: m.role as Role, content: m.content }))
|
||||
|
||||
try {
|
||||
await streamChatCompletion({
|
||||
messages: [...system, ...history, { role: 'user', content: text }],
|
||||
temperature: 0.5,
|
||||
await streamChat({
|
||||
messages: [...history, { role: 'user', content: msg }],
|
||||
path: pathname || '/terminal/hunt',
|
||||
onDelta: (delta) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, content: (m.content || '') + delta } : m))
|
||||
prev.map((m) => (m.id === assistantId ? { ...m, content: (m.content || '') + delta } : m))
|
||||
)
|
||||
},
|
||||
})
|
||||
} catch (e: any) {
|
||||
// If Scout: backend returns 403 -> use as upsell (real flow)
|
||||
const msg = e?.message || 'Failed'
|
||||
setError(msg)
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? {
|
||||
...m,
|
||||
content: canChat
|
||||
? `Request failed: ${msg}`
|
||||
: `Hunter Companion is available on Trader & Tycoon. Upgrade to unlock live guidance.`,
|
||||
}
|
||||
: m
|
||||
m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Request failed'}` } : m
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setSending(false)
|
||||
// Keep input focused after sending so the user can continue typing immediately
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
try {
|
||||
localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
@ -233,19 +294,22 @@ export function HunterCompanion() {
|
||||
}
|
||||
}
|
||||
|
||||
const suggestions = getSuggestions(pathname || '/terminal/hunt')
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FAB */}
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className={clsx(
|
||||
'fixed bottom-5 right-5 z-[150] w-14 h-14 border flex items-center justify-center',
|
||||
'bg-[#0A0A0A]/90 backdrop-blur-md',
|
||||
canChat ? 'border-accent/30 text-accent' : 'border-white/[0.12] text-white/60'
|
||||
'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',
|
||||
canChat ? 'border-accent/40 text-accent' : 'border-white/10 text-white/40'
|
||||
)}
|
||||
aria-label="Open Hunter Companion"
|
||||
>
|
||||
<div className="absolute -top-1 -left-1 w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||
{canChat && <div 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>
|
||||
|
||||
@ -254,34 +318,42 @@ export function HunterCompanion() {
|
||||
<div className="fixed inset-0 z-[160]">
|
||||
<div className="absolute inset-0 bg-black/85 backdrop-blur-sm" onClick={() => setOpen(false)} />
|
||||
|
||||
<div className="absolute bottom-4 right-4 w-[92vw] max-w-[420px] h-[70vh] max-h-[720px] bg-[#0A0A0A] border border-white/[0.08] shadow-2xl flex flex-col">
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-white/[0.08] bg-[#050505] flex items-center justify-between">
|
||||
<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">
|
||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Hunter Companion</span>
|
||||
{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="ml-2 inline-flex items-center gap-1 px-2 py-0.5 text-[9px] font-mono border border-white/[0.12] text-white/50">
|
||||
<Lock className="w-3 h-3" /> Scout
|
||||
<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-xs font-mono text-white/35 mt-1 truncate">
|
||||
Tactical help for BUY / CONSIDER / SKIP
|
||||
<div className="text-[10px] font-mono text-white/30 mt-0.5">
|
||||
{canChat ? 'Domain trading AI assistant' : 'Upgrade to unlock'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={clear}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]"
|
||||
title="Clear"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{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"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]"
|
||||
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" />
|
||||
@ -290,94 +362,175 @@ export function HunterCompanion() {
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="p-4 border border-white/[0.08] bg-white/[0.02]">
|
||||
<div className="flex items-center gap-2 text-accent mb-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<div className="text-xs font-bold uppercase tracking-wider">Start a hunt</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-white/50 leading-relaxed">
|
||||
Paste a domain (or a short list) + your budget and I’ll give you a decisive BUY/CONSIDER/SKIP with reasoning.
|
||||
</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>
|
||||
{!canChat && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[10px] font-mono text-white/35">
|
||||
Trader & Tycoon get live guidance.
|
||||
</div>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="px-3 py-2 bg-accent text-black text-[10px] font-bold uppercase tracking-wider"
|
||||
<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>
|
||||
|
||||
{/* Quick suggestions */}
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
if (s.prompt.endsWith(' ')) {
|
||||
setInput(s.prompt)
|
||||
inputRef.current?.focus()
|
||||
} else {
|
||||
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"
|
||||
>
|
||||
Upgrade
|
||||
</Link>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Chat 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',
|
||||
m.role === 'user'
|
||||
? 'bg-accent/10 border border-accent/20 text-accent'
|
||||
: 'bg-white/[0.03] border border-white/[0.08] text-white/80'
|
||||
)}
|
||||
>
|
||||
{m.role === 'assistant' ? (
|
||||
<div
|
||||
className="prose-chat"
|
||||
dangerouslySetInnerHTML={{ __html: formatMessage(m.content) || (sending ? '…' : '') }}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono">{m.content}</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={clsx('flex', m.role === 'user' ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
{/* Input (only for Trader/Tycoon) */}
|
||||
{canChat && (
|
||||
<div className="p-3 border-t border-white/[0.08] bg-[#050505] shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
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"
|
||||
disabled={sending}
|
||||
/>
|
||||
<button
|
||||
onClick={() => send()}
|
||||
disabled={sending || !input.trim()}
|
||||
className={clsx(
|
||||
'max-w-[85%] border px-3 py-2 text-xs font-mono leading-relaxed whitespace-pre-wrap',
|
||||
m.role === 'user'
|
||||
? 'border-accent/30 bg-accent/10 text-accent'
|
||||
: 'border-white/[0.08] bg-white/[0.02] text-white/70'
|
||||
'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"
|
||||
>
|
||||
{m.content || (m.role === 'assistant' && sending ? '…' : '')}
|
||||
</div>
|
||||
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<div className="text-[10px] font-mono text-rose-400/80 border border-rose-500/20 bg-rose-500/10 p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-white/[0.08] bg-[#050505]">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}}
|
||||
placeholder={canChat ? 'Ask Hunter Companion…' : 'Ask (will prompt upgrade)…'}
|
||||
className="flex-1 px-3 py-2 bg-white/[0.03] border border-white/[0.08] text-white text-xs font-mono outline-none focus:border-accent/40"
|
||||
disabled={sending}
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={sending || !input.trim()}
|
||||
className={clsx(
|
||||
'w-10 h-10 flex items-center justify-center border transition-colors',
|
||||
'disabled:opacity-50',
|
||||
canChat ? 'border-accent/30 bg-accent/10 text-accent hover:bg-accent/20' : 'border-white/[0.08] text-white/50 hover:text-white'
|
||||
)}
|
||||
aria-label="Send"
|
||||
>
|
||||
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] font-mono text-white/30 flex items-center justify-between">
|
||||
<span>Trader/Tycoon: live AI. Scout: visible + upgrade prompt.</span>
|
||||
<span className="text-white/20">ESC closes</span>
|
||||
</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.6;
|
||||
}
|
||||
.prose-chat strong {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose-chat em {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-style: italic;
|
||||
}
|
||||
.prose-chat code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,253 +0,0 @@
|
||||
export type TerminalHelpSection = {
|
||||
title: string
|
||||
bullets: string[]
|
||||
}
|
||||
|
||||
type HelpDoc = {
|
||||
overview: string[]
|
||||
sections: TerminalHelpSection[]
|
||||
}
|
||||
|
||||
// Keep this doc aligned with actual Terminal pages and UI.
|
||||
// This is used for Scout UI-help (no LLM call) and as quick onboarding for all tiers.
|
||||
export const TERMINAL_HELP: Record<string, HelpDoc> = {
|
||||
'/terminal': {
|
||||
overview: [
|
||||
'This is the Terminal home. It routes you into the active modules (Hunt, Market, Watchlist, Portfolio, etc.).',
|
||||
'Use the sidebar (desktop) or bottom navigation (mobile) to move between modules.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Quick start',
|
||||
bullets: [
|
||||
'Hunt: discover opportunities (auctions, drops, trends, brandables).',
|
||||
'Watchlist: track targets and availability.',
|
||||
'Portfolio: manage owned domains (renewals, ROI).',
|
||||
'For Sale: list domains and manage leads.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/welcome': {
|
||||
overview: [
|
||||
'Welcome is the onboarding entry inside the Terminal.',
|
||||
'It’s designed to orient you and push you into your first workflow.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'What you can do next',
|
||||
bullets: [
|
||||
'Go to Hunt to find opportunities.',
|
||||
'Add a domain to Watchlist to start tracking.',
|
||||
'Open Portfolio to model renewals and risk.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/hunt': {
|
||||
overview: [
|
||||
'The Hunt page is your discovery hub: auctions, drops, search, trends, and brandable generation.',
|
||||
'Use the tabs at the top to switch between engines.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Analyze a domain (right-side panel)',
|
||||
bullets: [
|
||||
'Click a domain name (where available) to open the Domain Analysis panel.',
|
||||
'Use the section tabs (Authority/Market/Risk/Value) to quickly evaluate.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Workflow',
|
||||
bullets: [
|
||||
'Shortlist candidates → Analyze → Add to Watchlist/Portfolio → Set alerts → Execute.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/watchlist': {
|
||||
overview: [
|
||||
'Watchlist tracks domains you care about and monitors availability changes.',
|
||||
'Use it to follow targets and trigger the Analysis panel from the list.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Common actions',
|
||||
bullets: [
|
||||
'Click a domain to open Domain Analysis.',
|
||||
'Refresh / remove items to keep the list clean.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/portfolio': {
|
||||
overview: [
|
||||
'Portfolio is for domains you already own (costs, renewal timing, ROI signals).',
|
||||
'Add domains here to avoid “renewal death by a thousand cuts”.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'CFO mindset',
|
||||
bullets: [
|
||||
'Track renewal dates and burn-rate.',
|
||||
'Drop low-signal names instead of carrying dead weight.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/market': {
|
||||
overview: [
|
||||
'Market is the feed for opportunities (external + Pounce sources).',
|
||||
'Use filters/sorting to reduce noise and move faster.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Speed',
|
||||
bullets: [
|
||||
'Filter by keyword/TLD/price and sort by score when available.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/intel': {
|
||||
overview: [
|
||||
'Intel is where you research TLD economics (pricing, renewal costs, history).',
|
||||
'Use it before you buy names with expensive renewals.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'TLD detail',
|
||||
bullets: [
|
||||
'Open a TLD to view pricing and history (higher tiers may unlock more history windows).',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/sniper': {
|
||||
overview: [
|
||||
'Sniper is your automated matching engine: alerts based on your criteria.',
|
||||
'It’s for catching opportunities without staring at feeds all day.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Best practice',
|
||||
bullets: [
|
||||
'Keep filters tight (budget, length, TLD) to avoid alert fatigue.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/listing': {
|
||||
overview: [
|
||||
'For Sale is Pounce Direct: list domains, verify ownership via DNS, manage leads.',
|
||||
'Use Leads to handle inquiries and negotiate.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Listing flow',
|
||||
bullets: [
|
||||
'Create listing → verify DNS ownership → publish → manage inquiries → close sale.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/inbox': {
|
||||
overview: [
|
||||
'Inbox is your buyer/seller communication hub for inquiries.',
|
||||
'Use Buying/Selling tabs to switch perspective.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Realtime',
|
||||
bullets: [
|
||||
'Threads and messages update via polling; unread counts show in navigation.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/yield': {
|
||||
overview: [
|
||||
'Yield is your monetization dashboard (feature is in development).',
|
||||
'You can activate domains and follow the DNS instructions when available.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Status',
|
||||
bullets: [
|
||||
'If something looks incomplete: it’s expected while the system is being built.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/terminal/settings': {
|
||||
overview: [
|
||||
'Settings is where you manage account and configuration.',
|
||||
],
|
||||
sections: [],
|
||||
},
|
||||
}
|
||||
|
||||
function normalizePath(pathname: string): string {
|
||||
if (!pathname) return '/terminal/hunt'
|
||||
// Collapse dynamic subroutes to their base sections
|
||||
if (pathname.startsWith('/terminal/intel/')) return '/terminal/intel'
|
||||
if (pathname === '/terminal/page' || pathname === '/terminal/page.tsx') return '/terminal'
|
||||
return pathname
|
||||
}
|
||||
|
||||
function genericTerminalHelp(): HelpDoc {
|
||||
return {
|
||||
overview: [
|
||||
'I can explain what this screen does, where to click, and the recommended workflow.',
|
||||
'Tell me your goal (e.g. “find brandables under $100” or “sell a domain”) and I’ll guide you step-by-step.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Core modules',
|
||||
bullets: [
|
||||
'Hunt: discovery engines (auctions, drops, search, trends, forge).',
|
||||
'Market: browse opportunities with filters.',
|
||||
'Watchlist: track targets and availability.',
|
||||
'Portfolio: manage owned domains (renewals, ROI).',
|
||||
'Sniper: set automated alerts.',
|
||||
'For Sale: list domains + handle leads.',
|
||||
'Inbox: buyer/seller threads.',
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function isUiHelpQuestion(text: string): boolean {
|
||||
const t = (text || '').toLowerCase()
|
||||
// English + German common help intents
|
||||
const keywords = [
|
||||
'how', 'where', 'what does', 'explain', 'guide', 'help', 'tutorial', 'how to', 'workflow', 'feature',
|
||||
'wie', 'wo', 'was ist', 'erkläre', 'erklären', 'hilfe', 'tutorial', 'anleitung', 'funktion', 'workflow',
|
||||
'tab', 'seite', 'page', 'button', 'panel', 'sidebar', 'inbox', 'watchlist', 'portfolio', 'hunt', 'market', 'intel', 'sniper', 'listing', 'yield',
|
||||
]
|
||||
return keywords.some((k) => t.includes(k))
|
||||
}
|
||||
|
||||
export function buildUiHelpAnswer(pathname: string, question: string): string {
|
||||
const p = normalizePath(pathname)
|
||||
const doc = TERMINAL_HELP[p] || genericTerminalHelp()
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push("Here’s how the UI works on this page:")
|
||||
for (const o of doc.overview) lines.push(`- ${o}`)
|
||||
|
||||
if (doc.sections.length) {
|
||||
lines.push('')
|
||||
for (const s of doc.sections) {
|
||||
lines.push(`${s.title}:`)
|
||||
for (const b of s.bullets) lines.push(`- ${b}`)
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('If you tell me what you want to achieve (e.g. “find brandables under $50”), I’ll give you a step-by-step flow.')
|
||||
return lines.join('\n').trim()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user