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

This commit is contained in:
2025-12-17 14:49:49 +01:00
parent aab2a0c3ad
commit b35d5e0ba0
3 changed files with 355 additions and 461 deletions

View File

@ -63,31 +63,23 @@ async def _get_user_tier(db: AsyncSession, user: User) -> str:
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 (domain trading expert). Always respond in English.\n" "You are the Pounce Hunter Companion, an expert domain trading assistant. Always respond in English.\n"
"You have access to internal tools that return live data. Use tools when needed.\n\n" "You help users with: domain analysis, auction hunting, portfolio management, and trading decisions.\n\n"
"OUTPUT STYLE:\n" "RESPONSE FORMAT (CRITICAL):\n"
"- Never show raw tool output to the user.\n" "- Write in plain text. NO markdown asterisks, NO ** or *, NO code blocks.\n"
"- Never print phrases like 'Tool Result', 'TOOL_RESULT', or code-fenced JSON.\n" "- Use simple dashes (-) for bullet points.\n"
"- If you used tools, silently incorporate the data and present ONLY a clean summary.\n" "- Keep responses concise: 2-4 sentences intro, then bullets if needed.\n"
"- Keep formatting simple: short paragraphs and bullet points. Avoid dumping structured data.\n\n" "- Never show tool outputs, JSON, or internal data to the user.\n\n"
"BEHAVIOR:\n" "BEHAVIOR:\n"
"- Do NOT invent user preferences, keywords, TLDs, budgets, or tasks.\n" "- Be helpful, direct, and conversational like a knowledgeable colleague.\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" "- For domain questions: give a clear BUY / CONSIDER / SKIP recommendation with 3-5 reasons.\n"
"- Ask 12 clarifying questions when the user request is ambiguous.\n\n" "- Do NOT invent user preferences, keywords, or data. Ask if unclear.\n"
"WHEN TO USE TOOLS:\n" "- For greetings: respond naturally and ask how you can help.\n\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" "TOOL USAGE:\n"
"- Never proactively 'fetch' domains or run scans based on guessed keywords.\n\n" "- Use tools when user asks about their data (watchlist, portfolio, listings, inbox, yield) or a specific domain.\n"
"TOOL CALLING PROTOCOL:\n" "- To call tools, respond with ONLY: {\"tool_calls\":[{\"name\":\"...\",\"args\":{...}}]}\n"
"- If you need data, respond with ONLY a JSON object:\n" "- After receiving tool results, answer naturally without mentioning tools.\n\n"
' {"tool_calls":[{"name":"tool_name","args":{...}}, ...]}\n' f"AVAILABLE TOOLS:\n{json.dumps(tools, ensure_ascii=False)}\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"
) )
@ -151,11 +143,13 @@ async def run_agent(
{ {
"role": "assistant", "role": "assistant",
"content": ( "content": (
"Hey — how can I help?\n\n" "Hey! How can I help you today?\n\n"
"If you want, tell me:\n" "I can help with:\n"
"- which Terminal page youre on (or what youre trying to do)\n" "- Analyzing a specific domain\n"
"- or a specific domain youre considering\n" "- Finding auction deals or drops\n"
"- or what outcome you want (find deals, assess a name, manage leads, etc.)" "- 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", "role": "system",
"content": ( "content": (
"Final step: respond to the user.\n" "Final step: respond to the user in plain text.\n"
"- Do NOT output JSON tool_calls.\n" "- NO markdown: no ** or * for bold/italic, no code blocks.\n"
"- Do NOT request tools.\n" "- Use dashes (-) for bullets.\n"
"- Do NOT include raw tool outputs, internal tags, or code-fenced JSON.\n" "- Do NOT output JSON or mention tools.\n"
"- If you used tools, present only a clean human summary." "- Be concise and helpful."
), ),
} }
], ],

View File

@ -4,9 +4,21 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import Link from 'next/link' 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 { useStore } from '@/lib/store'
import { buildUiHelpAnswer, isUiHelpQuestion } from '@/components/chat/terminalHelp'
type Role = 'system' | 'user' | 'assistant' type Role = 'system' | 'user' | 'assistant'
@ -29,31 +41,26 @@ function getApiBase(): string {
return `${protocol}//${hostname}/api/v1` return `${protocol}//${hostname}/api/v1`
} }
async function streamChatCompletion(opts: { async function streamChat(opts: {
model?: string
messages: Array<{ role: Role; content: string }> messages: Array<{ role: Role; content: string }>
temperature?: number path: string
onDelta: (delta: string) => void onDelta: (delta: string) => void
path?: string
}): Promise<void> { }): Promise<void> {
// Use tool-calling agent for Trader/Tycoon. (Scout stays UI-help only.)
const res = await fetch(`${getApiBase()}/llm/agent`, { const res = await fetch(`${getApiBase()}/llm/agent`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: opts.model,
messages: opts.messages, messages: opts.messages,
temperature: opts.temperature, temperature: 0.5,
stream: true, stream: true,
path: opts.path || '/terminal/hunt', path: opts.path,
}), }),
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)
const detail = data?.detail || res.statusText || 'Request failed' throw new Error(data?.detail || res.statusText || 'Request failed')
throw new Error(detail)
} }
const reader = res.body?.getReader() const reader = res.body?.getReader()
@ -66,7 +73,6 @@ async function streamChatCompletion(opts: {
if (done) break if (done) break
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
// SSE frames separated by blank line
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) {
@ -92,6 +98,99 @@ function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' {
return 'scout' return 'scout'
} }
// Simple markdown-like formatting to clean HTML
function formatMessage(text: string): string {
if (!text) return ''
let html = text
// Escape HTML
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 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() { export function HunterCompanion() {
const pathname = usePathname() const pathname = usePathname()
const { subscription, user } = useStore() const { subscription, user } = useStore()
@ -100,132 +199,94 @@ 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:v1:${uidPart}` return `pounce:hunter_companion:v2:${uidPart}`
}, [user?.id]) }, [user?.id])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [error, setError] = useState<string | null>(null)
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
useEffect(() => { useEffect(() => {
if (!canChat) return
try { try {
const raw = localStorage.getItem(storageKey) const raw = localStorage.getItem(storageKey)
if (!raw) return if (raw) {
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 // ignore
} }
}, [storageKey]) }, [storageKey, canChat])
// Save to localStorage
useEffect(() => { useEffect(() => {
if (!canChat) return
try { try {
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-60))) localStorage.setItem(storageKey, JSON.stringify(messages.slice(-60)))
} catch { } catch {
// ignore // ignore
} }
}, [messages, storageKey]) }, [messages, storageKey, canChat])
// Auto-scroll
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const el = listRef.current const el = listRef.current
if (!el) return if (el) el.scrollTop = el.scrollHeight
el.scrollTop = el.scrollHeight
}, [open, messages.length]) }, [open, messages.length])
// Focus input
useEffect(() => { useEffect(() => {
if (!open) return if (open && canChat) {
// Focus input on open setTimeout(() => inputRef.current?.focus(), 50)
setTimeout(() => inputRef.current?.focus(), 0) }
}, [open]) }, [open, canChat])
// Only show inside terminal routes // Only show in terminal
if (!pathname?.startsWith('/terminal')) return null if (!pathname?.startsWith('/terminal')) return null
const send = async () => { const send = async (text?: string) => {
const text = input.trim() const msg = (text || input).trim()
if (!text || sending) return if (!msg || sending || !canChat) return
setError(null)
// Scout UI-help: answer locally (no LLM call) so Scout can learn the app. const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg, createdAt: Date.now() }
if (!canChat && isUiHelpQuestion(text)) { const assistantId = uid()
const userMsg: ChatMessage = { id: uid(), role: 'user', content: text, createdAt: Date.now() } setMessages((prev) => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '', 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() }])
setInput('') setInput('')
setSending(true) setSending(true)
const system = [ const history = messages.slice(-20).map((m) => ({ role: m.role as Role, content: m.content }))
{
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 users question is domain-related, be decisional and structured:\n- Recommendation: BUY / CONSIDER / SKIP\n- 36 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 }))
try { try {
await streamChatCompletion({ await streamChat({
messages: [...system, ...history, { role: 'user', content: text }], messages: [...history, { role: 'user', content: msg }],
temperature: 0.5,
path: pathname || '/terminal/hunt', path: pathname || '/terminal/hunt',
onDelta: (delta) => { onDelta: (delta) => {
setMessages((prev) => 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) { } catch (e: any) {
// If Scout: backend returns 403 -> use as upsell (real flow)
const msg = e?.message || 'Failed'
setError(msg)
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Request failed'}` } : m
? {
...m,
content: canChat
? `Request failed: ${msg}`
: `Hunter Companion is available on Trader & Tycoon. Upgrade to unlock live guidance.`,
}
: m
) )
) )
} finally { } finally {
setSending(false) setSending(false)
// Keep input focused after sending so the user can continue typing immediately
setTimeout(() => inputRef.current?.focus(), 0) setTimeout(() => inputRef.current?.focus(), 0)
} }
} }
const clear = () => { const clear = () => {
setMessages([]) setMessages([])
setError(null)
try { try {
localStorage.removeItem(storageKey) localStorage.removeItem(storageKey)
} catch { } catch {
@ -233,19 +294,22 @@ export function HunterCompanion() {
} }
} }
const suggestions = getSuggestions(pathname || '/terminal/hunt')
return ( return (
<> <>
{/* FAB */} {/* FAB */}
<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', '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', 'bg-[#0A0A0A]/90 backdrop-blur-md hover:scale-105',
canChat ? 'border-accent/30 text-accent' : 'border-white/[0.12] text-white/60' canChat ? 'border-accent/40 text-accent' : 'border-white/10 text-white/40'
)} )}
aria-label="Open Hunter Companion" 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" /> <MessageSquare className="w-6 h-6" />
</button> </button>
@ -254,34 +318,42 @@ export function HunterCompanion() {
<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/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 */} {/* 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="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" /> {canChat ? (
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Hunter Companion</span> <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 && ( {!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"> <span className="px-1.5 py-0.5 text-[9px] font-mono bg-white/5 border border-white/10 text-white/40">
<Lock className="w-3 h-3" /> Scout LOCKED
</span> </span>
)} )}
</div> </div>
<div className="text-xs font-mono text-white/35 mt-1 truncate"> <div className="text-[10px] font-mono text-white/30 mt-0.5">
Tactical help for BUY / CONSIDER / SKIP {canChat ? 'Domain trading AI assistant' : 'Upgrade to unlock'}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<button {canChat && messages.length > 0 && (
onClick={clear} <button
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]" onClick={clear}
title="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> <Trash2 className="w-4 h-4" />
</button>
)}
<button <button
onClick={() => setOpen(false)} 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" title="Close"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@ -290,94 +362,175 @@ export function HunterCompanion() {
</div> </div>
{/* Body */} {/* Body */}
<div ref={listRef} className="flex-1 overflow-y-auto p-4 space-y-3"> <div ref={listRef} className="flex-1 overflow-y-auto p-4">
{messages.length === 0 && ( {!canChat ? (
<div className="p-4 border border-white/[0.08] bg-white/[0.02]"> /* Scout Teaser */
<div className="flex items-center gap-2 text-accent mb-2"> <div className="h-full flex flex-col">
<Sparkles className="w-4 h-4" /> <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
<div className="text-xs font-bold uppercase tracking-wider">Start a hunt</div> <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>
<div className="text-xs font-mono text-white/50 leading-relaxed"> </div>
Paste a domain (or a short list) + your budget and Ill give you a decisive BUY/CONSIDER/SKIP with reasoning. ) : 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> </div>
{!canChat && ( <h3 className="text-sm font-medium text-white mb-1">Ready to hunt</h3>
<div className="mt-3 flex items-center justify-between gap-3"> <p className="text-[11px] font-mono text-white/40 mb-4">
<div className="text-[10px] font-mono text-white/35"> Ask me anything about domains, auctions, or your portfolio.
Trader & Tycoon get live guidance. </p>
</div>
<Link {/* Quick suggestions */}
href="/pricing" <div className="flex flex-wrap justify-center gap-2">
className="px-3 py-2 bg-accent text-black text-[10px] font-bold uppercase tracking-wider" {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 {s.label}
</Link> </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> </div>
)} )}
</div>
{messages.map((m) => ( {/* Input (only for Trader/Tycoon) */}
<div key={m.id} className={clsx('flex', m.role === 'user' ? 'justify-end' : 'justify-start')}> {canChat && (
<div <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( className={clsx(
'max-w-[85%] border px-3 py-2 text-xs font-mono leading-relaxed whitespace-pre-wrap', 'w-10 h-10 flex items-center justify-center border transition-all',
m.role === 'user' 'disabled:opacity-40 disabled:cursor-not-allowed',
? 'border-accent/30 bg-accent/10 text-accent' 'border-accent/30 bg-accent/10 text-accent hover:bg-accent/20'
: 'border-white/[0.08] bg-white/[0.02] text-white/70'
)} )}
aria-label="Send"
> >
{m.content || (m.role === 'assistant' && sending ? '…' : '')} {sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</div> </button>
</div> </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>
<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>
</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>
</> </>
) )
} }

View File

@ -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.',
'Its 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.',
'Its 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: its 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 Ill 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("Heres 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”), Ill give you a step-by-step flow.')
return lines.join('\n').trim()
}