diff --git a/frontend/src/app/terminal/layout.tsx b/frontend/src/app/terminal/layout.tsx index 34d867e..7d6ed62 100644 --- a/frontend/src/app/terminal/layout.tsx +++ b/frontend/src/app/terminal/layout.tsx @@ -5,6 +5,7 @@ import { useRouter, usePathname } from 'next/navigation' import { useStore } from '@/lib/store' import { AnalyzePanelProvider } from '@/components/analyze/AnalyzePanelProvider' import { BetaBanner } from '@/components/BetaBanner' +import { HunterCompanion } from '@/components/chat/HunterCompanion' import { Loader2 } from 'lucide-react' export default function TerminalLayout({ @@ -64,6 +65,7 @@ export default function TerminalLayout({ {children} + ) } diff --git a/frontend/src/components/chat/HunterCompanion.tsx b/frontend/src/components/chat/HunterCompanion.tsx new file mode 100644 index 0000000..40a8cb7 --- /dev/null +++ b/frontend/src/components/chat/HunterCompanion.tsx @@ -0,0 +1,352 @@ +'use client' + +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 { useStore } from '@/lib/store' + +type Role = 'system' | 'user' | 'assistant' + +type ChatMessage = { + id: string + role: Exclude + content: string + createdAt: number +} + +function uid() { + return `${Date.now()}_${Math.random().toString(16).slice(2)}` +} + +function getApiBase(): string { + if (typeof window === 'undefined') return 'http://localhost:8000/api/v1' + const { protocol, hostname } = window.location + if (hostname === 'localhost' || hostname === '127.0.0.1') return 'http://localhost:8000/api/v1' + if (/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(hostname)) return `http://${hostname}:8000/api/v1` + return `${protocol}//${hostname}/api/v1` +} + +async function streamChatCompletion(opts: { + model?: string + messages: Array<{ role: Role; content: string }> + temperature?: number + onDelta: (delta: string) => void +}): Promise { + const res = await fetch(`${getApiBase()}/llm/chat/completions`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: opts.model, + messages: opts.messages, + temperature: opts.temperature, + stream: true, + }), + }) + + if (!res.ok) { + const data = await res.json().catch(() => null) + const detail = data?.detail || res.statusText || 'Request failed' + throw new Error(detail) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('Streaming not supported') + + const decoder = new TextDecoder('utf-8') + let buffer = '' + for (;;) { + const { value, done } = await reader.read() + 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) { + const line = p.split('\n').find((l) => l.startsWith('data: ')) + if (!line) continue + const payload = line.replace(/^data:\s*/, '') + if (payload === '[DONE]') return + try { + const json = JSON.parse(payload) + const delta = json?.choices?.[0]?.delta?.content + if (typeof delta === 'string' && delta.length) opts.onDelta(delta) + } catch { + // ignore + } + } + } +} + +function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' { + const t = (subscription?.tier || '').toLowerCase() + if (t === 'trader') return 'trader' + if (t === 'tycoon') return 'tycoon' + return 'scout' +} + +export function HunterCompanion() { + const pathname = usePathname() + const { subscription, user } = useStore() + const tier = getTier(subscription) + const canChat = tier === 'trader' || tier === 'tycoon' + + const storageKey = useMemo(() => { + const uidPart = user?.id ? String(user.id) : 'anon' + return `pounce:hunter_companion:v1:${uidPart}` + }, [user?.id]) + + const [open, setOpen] = useState(false) + const [input, setInput] = useState('') + const [messages, setMessages] = useState([]) + const [sending, setSending] = useState(false) + const [error, setError] = useState(null) + + const listRef = useRef(null) + + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey) + if (!raw) return + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) setMessages(parsed) + } catch { + // ignore + } + }, [storageKey]) + + useEffect(() => { + try { + localStorage.setItem(storageKey, JSON.stringify(messages.slice(-60))) + } catch { + // ignore + } + }, [messages, storageKey]) + + useEffect(() => { + if (!open) return + const el = listRef.current + if (!el) return + el.scrollTop = el.scrollHeight + }, [open, messages.length]) + + // Only show inside terminal routes + if (!pathname?.startsWith('/terminal')) return null + + const send = async () => { + const text = input.trim() + if (!text || sending) return + setError(null) + + 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('') + setSending(true) + + const system = [ + { + role: 'system' as const, + content: + 'You are Pounce Hunter Companion. Be concise, tactical, and specific. Help the user hunt undervalued domains, assess risk, and decide BUY/CONSIDER/SKIP. If you need data, ask for it. 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 { + await streamChatCompletion({ + messages: [...system, ...history, { role: 'user', content: text }], + onDelta: (delta) => { + setMessages((prev) => + prev.map((m) => (m.id === assistantMsgId ? { ...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 + ) + ) + } finally { + setSending(false) + } + } + + const clear = () => { + setMessages([]) + setError(null) + try { + localStorage.removeItem(storageKey) + } catch { + // ignore + } + } + + return ( + <> + {/* FAB */} + + + {/* Panel */} + {open && ( +
+
setOpen(false)} /> + +
+ {/* Header */} +
+
+
+
+ Hunter Companion + {!canChat && ( + + Scout + + )} +
+
+ Tactical help for BUY / CONSIDER / SKIP +
+
+
+ + +
+
+ + {/* Body */} +
+ {messages.length === 0 && ( +
+
+ +
Start a hunt
+
+
+ Paste a domain (or a short list) + your budget and I’ll give you a decisive BUY/CONSIDER/SKIP with reasoning. +
+ {!canChat && ( +
+
+ Trader & Tycoon get live guidance. +
+ + Upgrade + +
+ )} +
+ )} + + {messages.map((m) => ( +
+
+ {m.content || (m.role === 'assistant' && sending ? '…' : '')} +
+
+ ))} + + {error && ( +
+ {error} +
+ )} +
+ + {/* Input */} +
+
+ 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} + /> + +
+
+ Trader/Tycoon: live AI. Scout: visible + upgrade prompt. + ESC closes +
+
+
+
+ )} + + ) +} + +