| import { useState, useRef, useEffect } from 'react' |
| import type { ChatMessage } from '../types' |
| import { SourceBadge } from './SourceBadge' |
|
|
| interface ChatPanelProps { |
| onSend: (message: string, temperature?: number, maxTokens?: number) => Promise<any> |
| loading: boolean |
| } |
|
|
| function formatNum(n: number): string { |
| if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` |
| if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` |
| return n.toString() |
| } |
|
|
| export function ChatPanel({ onSend, loading }: ChatPanelProps) { |
| const [messages, setMessages] = useState<ChatMessage[]>([]) |
| const [input, setInput] = useState('') |
| const [temperature, setTemperature] = useState(0.0) |
| const [showSettings, setShowSettings] = useState(false) |
| const scrollRef = useRef<HTMLDivElement>(null) |
|
|
| useEffect(() => { |
| if (scrollRef.current) { |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight |
| } |
| }, [messages, loading]) |
|
|
| const handleSend = async () => { |
| const msg = input.trim() |
| if (!msg || loading) return |
|
|
| const userMsg: ChatMessage = { |
| id: `u-${Date.now()}`, |
| role: 'user', |
| text: msg, |
| timestamp: Date.now(), |
| } |
| setMessages(prev => [...prev, userMsg]) |
| setInput('') |
|
|
| try { |
| const resp = await onSend(msg, temperature) |
| const botMsg: ChatMessage = { |
| id: `b-${Date.now()}`, |
| role: 'palimpseste', |
| text: resp.response, |
| confidence: resp.confidence, |
| source: resp.source, |
| explanation: resp.explanation, |
| chain: resp.chain, |
| elapsed_ms: resp.elapsed_ms, |
| timestamp: Date.now(), |
| } |
| setMessages(prev => [...prev, botMsg]) |
| } catch (err) { |
| setMessages(prev => [...prev, { |
| id: `e-${Date.now()}`, |
| role: 'palimpseste', |
| text: '⚠️ Connection error. Is the API server running on :3332?', |
| source: 'fallback', |
| confidence: 0, |
| timestamp: Date.now(), |
| }]) |
| } |
| } |
|
|
| const handleKeyDown = (e: React.KeyboardEvent) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault() |
| handleSend() |
| } |
| } |
|
|
| const clearChat = () => setMessages([]) |
|
|
| return ( |
| <div className="flex flex-col h-full glass-card overflow-hidden"> |
| {/* Header */} |
| <div className="flex items-center justify-between px-5 py-3 border-b border-pal-border"> |
| <div className="flex items-center gap-3"> |
| <div className="relative"> |
| <div className="w-2.5 h-2.5 rounded-full bg-pal-green" /> |
| <div className="absolute inset-0 w-2.5 h-2.5 rounded-full bg-pal-green animate-ping" /> |
| </div> |
| <h2 className="text-sm font-semibold text-pal-text">Cortex Chat</h2> |
| <span className="text-xs text-pal-muted"> |
| {messages.length} message{messages.length !== 1 ? 's' : ''} |
| </span> |
| </div> |
| <div className="flex items-center gap-2"> |
| <button |
| onClick={() => setShowSettings(!showSettings)} |
| className="text-pal-muted hover:text-pal-text transition-colors p-1.5 rounded-lg hover:bg-pal-card" |
| title="Settings" |
| > |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> |
| <circle cx="12" cy="12" r="3" /> |
| <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /> |
| </svg> |
| </button> |
| {messages.length > 0 && ( |
| <button |
| onClick={clearChat} |
| className="text-pal-muted hover:text-pal-red transition-colors p-1.5 rounded-lg hover:bg-pal-card text-xs" |
| > |
| Clear |
| </button> |
| )} |
| </div> |
| </div> |
|
|
| {/* Settings drawer */} |
| {showSettings && ( |
| <div className="px-5 py-3 border-b border-pal-border bg-pal-surface/50 animate-slide-up"> |
| <div className="flex items-center gap-4"> |
| <label className="text-xs text-pal-muted font-medium whitespace-nowrap"> |
| Temperature: <span className="text-pal-accent2 font-mono">{temperature.toFixed(2)}</span> |
| </label> |
| <input |
| type="range" |
| min="0" |
| max="1" |
| step="0.05" |
| value={temperature} |
| onChange={e => setTemperature(parseFloat(e.target.value))} |
| className="flex-1 accent-pal-accent" |
| /> |
| </div> |
| </div> |
| )} |
|
|
| {/* Messages */} |
| <div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 space-y-4"> |
| {messages.length === 0 && ( |
| <div className="flex flex-col items-center justify-center h-full text-center gap-4 animate-fade-in"> |
| <div className="text-5xl animate-float">🧠</div> |
| <div> |
| <h3 className="text-lg font-bold gradient-text mb-1">PALIMPSESTE</h3> |
| <p className="text-sm text-pal-muted max-w-xs"> |
| A self-referential hypervectorial cortex.<br/> |
| No weights. No gradient. No GPU. |
| </p> |
| </div> |
| <div className="flex flex-wrap gap-2 justify-center max-w-md"> |
| {['hello', 'who are you?', 'what is python?', 'what is the capital of france'].map(q => ( |
| <button |
| key={q} |
| onClick={() => setInput(q)} |
| className="px-3 py-1.5 text-xs rounded-full glass hover:border-pal-accent/40 transition-all text-pal-muted hover:text-pal-text" |
| > |
| {q} |
| </button> |
| ))} |
| </div> |
| </div> |
| )} |
|
|
| {messages.map(msg => ( |
| <div |
| key={msg.id} |
| className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-slide-up`} |
| > |
| <div |
| className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${ |
| msg.role === 'user' |
| ? 'bg-gradient-to-br from-pal-accent/30 to-pal-accent/10 border border-pal-accent/30' |
| : 'glass border border-pal-border' |
| }`} |
| > |
| <p className="text-sm text-pal-text whitespace-pre-wrap break-words">{msg.text}</p> |
|
|
| {msg.role === 'palimpseste' && msg.source && ( |
| <div className="mt-2 pt-2 border-t border-pal-border/50 space-y-1.5"> |
| <div className="flex items-center justify-between gap-2 flex-wrap"> |
| <SourceBadge source={msg.source} confidence={msg.confidence ?? 0} /> |
| {msg.elapsed_ms !== undefined && ( |
| <span className="text-[10px] text-pal-muted font-mono"> |
| {msg.elapsed_ms < 1 ? '<1' : msg.elapsed_ms.toFixed(0)}ms |
| </span> |
| )} |
| </div> |
| {msg.explanation && msg.source !== 'fallback' && ( |
| <p className="text-[11px] text-pal-muted leading-relaxed"> |
| <span className="text-pal-accent2/70">💡 </span> |
| {msg.explanation.split('\n').map((line, i) => ( |
| <span key={i}>{i > 0 && <br/>}{line}</span> |
| ))} |
| </p> |
| )} |
| {msg.chain && msg.chain.success && ( |
| <ChainVisualization chain={msg.chain} /> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| ))} |
|
|
| {loading && ( |
| <div className="flex justify-start animate-fade-in"> |
| <div className="glass border border-pal-border rounded-2xl px-4 py-3"> |
| <div className="flex gap-1.5"> |
| <span className="w-2 h-2 rounded-full bg-pal-accent animate-bounce" style={{ animationDelay: '0ms' }} /> |
| <span className="w-2 h-2 rounded-full bg-pal-accent animate-bounce" style={{ animationDelay: '150ms' }} /> |
| <span className="w-2 h-2 rounded-full bg-pal-accent animate-bounce" style={{ animationDelay: '300ms' }} /> |
| </div> |
| </div> |
| </div> |
| )} |
| </div> |
|
|
| {/* Input */} |
| <div className="px-4 py-3 border-t border-pal-border"> |
| <div className="flex gap-2 items-end"> |
| <textarea |
| value={input} |
| onChange={e => setInput(e.target.value)} |
| onKeyDown={handleKeyDown} |
| placeholder="Ask PALIMPSESTE anything..." |
| rows={1} |
| className="flex-1 bg-pal-surface border border-pal-border rounded-xl px-4 py-2.5 text-sm text-pal-text placeholder:text-pal-muted focus:outline-none focus:border-pal-accent/50 focus:glow-accent transition-all resize-none max-h-32" |
| style={{ minHeight: '42px' }} |
| /> |
| <button |
| onClick={handleSend} |
| disabled={!input.trim() || loading} |
| className="px-4 py-2.5 rounded-xl bg-gradient-to-br from-pal-accent to-pal-accent2 text-white font-semibold text-sm transition-all hover:scale-105 hover:glow-accent disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100" |
| > |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> |
| <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| </div> |
| ) |
| } |
|
|
| function ChainVisualization({ chain }: { chain: NonNullable<ChatMessage['chain']> }) { |
| return ( |
| <div className="mt-2 p-2.5 rounded-lg bg-pal-surface/70 border border-pal-accent/20"> |
| <div className="text-[10px] text-pal-accent font-semibold mb-1.5 uppercase tracking-wide"> |
| 🔗 Chain ({chain.n_hops} hops) |
| </div> |
| <div className="space-y-1"> |
| {chain.steps.map((step, i) => ( |
| <div key={i} className="flex items-start gap-2 text-[11px]"> |
| <span className="text-pal-muted font-mono mt-0.5">{i + 1}.</span> |
| <div className="flex-1"> |
| <span className="text-pal-text">{step.sub_question}</span> |
| <span className="text-pal-accent2 mx-1">→</span> |
| <span className="text-pal-green font-medium">{step.sub_answer}</span> |
| </div> |
| </div> |
| ))} |
| <div className="flex items-center gap-2 text-[11px] pt-1 border-t border-pal-border/50"> |
| <span className="text-pal-muted">Result:</span> |
| <span className="text-pal-gold font-semibold">{chain.answer}</span> |
| </div> |
| </div> |
| </div> |
| ) |
| } |
|
|