Download ChatPanel.tsx from metarm/QMetaRam: direct link, hf CLI and curl.
- Browser
- Download file 14 kB
-
https://huggingface.co/metarm/QMetaRam/resolve/main/ChatPanel.tsx
- Command line
-
hf download hf://metarm/QMetaRam/ChatPanel.tsx
-
curl -L -o ChatPanel.tsx https://huggingface.co/metarm/QMetaRam/resolve/main/ChatPanel.tsx
14 kB
| import { AnimatePresence, motion } from "framer-motion"; | |
| import { History, Mic, Send, Sparkles, Trash2, X } from "lucide-react"; | |
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { cn } from "@/lib/utils"; | |
| export interface ChatMessage { | |
| id: string; | |
| role: "user" | "assistant"; | |
| content: string; | |
| ts: number; | |
| } | |
| export interface ChatSession { | |
| id: string; | |
| title: string; | |
| messages: ChatMessage[]; | |
| createdAt: number; | |
| updatedAt: number; | |
| } | |
| const STORAGE_KEY = "qhub_chat_sessions"; | |
| const ACTIVE_KEY = "qhub_active_session"; | |
| const WELCOME_MSG: ChatMessage = { | |
| id: "w", | |
| role: "assistant", | |
| content: "Welcome to QHub. The sky listens.", | |
| ts: Date.now(), | |
| }; | |
| function loadSessions(): ChatSession[] { | |
| try { | |
| const raw = localStorage.getItem(STORAGE_KEY); | |
| return raw ? (JSON.parse(raw) as ChatSession[]) : []; | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function saveSessions(sessions: ChatSession[]) { | |
| try { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)); | |
| } catch { /* quota guard */ } | |
| } | |
| function newSession(): ChatSession { | |
| return { | |
| id: crypto.randomUUID(), | |
| title: `Chat ${new Date().toLocaleString("fa-IR", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`, | |
| messages: [{ ...WELCOME_MSG, id: crypto.randomUUID(), ts: Date.now() }], | |
| createdAt: Date.now(), | |
| updatedAt: Date.now(), | |
| }; | |
| } | |
| interface ChatPanelProps { | |
| onSpeakingChange?: (speaking: boolean) => void; | |
| } | |
| export function ChatPanel({ onSpeakingChange }: ChatPanelProps) { | |
| const [expanded, setExpanded] = useState(false); | |
| const [historyOpen, setHistoryOpen] = useState(false); | |
| const [sessions, setSessions] = useState<ChatSession[]>(() => { | |
| const saved = loadSessions(); | |
| if (saved.length > 0) return saved; | |
| const first = newSession(); | |
| saveSessions([first]); | |
| return [first]; | |
| }); | |
| const [activeId, setActiveId] = useState<string>(() => { | |
| const saved = localStorage.getItem(ACTIVE_KEY); | |
| const all = loadSessions(); | |
| if (saved && all.find(s => s.id === saved)) return saved; | |
| return all[0]?.id ?? newSession().id; | |
| }); | |
| const activeSession = sessions.find(s => s.id === activeId) ?? sessions[0]; | |
| const messages = activeSession?.messages ?? [WELCOME_MSG]; | |
| const [draft, setDraft] = useState(""); | |
| const [thinking, setThinking] = useState(false); | |
| const [listening, setListening] = useState(false); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| // Persist whenever sessions change | |
| useEffect(() => { | |
| saveSessions(sessions); | |
| }, [sessions]); | |
| useEffect(() => { | |
| localStorage.setItem(ACTIVE_KEY, activeId); | |
| }, [activeId]); | |
| useEffect(() => { | |
| scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); | |
| }, [messages, expanded]); | |
| const updateActiveMessages = useCallback((updater: (prev: ChatMessage[]) => ChatMessage[]) => { | |
| setSessions(prev => | |
| prev.map(s => | |
| s.id === activeId | |
| ? { ...s, messages: updater(s.messages), updatedAt: Date.now() } | |
| : s | |
| ) | |
| ); | |
| }, [activeId]); | |
| const send = useCallback(() => { | |
| const text = draft.trim(); | |
| if (!text) return; | |
| const userMsg: ChatMessage = { id: crypto.randomUUID(), role: "user", content: text, ts: Date.now() }; | |
| updateActiveMessages(m => [...m, userMsg]); | |
| // Auto-title from first user message | |
| setSessions(prev => | |
| prev.map(s => { | |
| if (s.id !== activeId) return s; | |
| const isDefault = s.title.startsWith("Chat "); | |
| return isDefault && s.messages.filter(x => x.role === "user").length === 0 | |
| ? { ...s, title: text.slice(0, 40) + (text.length > 40 ? "…" : "") } | |
| : s; | |
| }) | |
| ); | |
| setDraft(""); | |
| setThinking(true); | |
| onSpeakingChange?.(true); | |
| setTimeout(() => { | |
| const reply: ChatMessage = { | |
| id: crypto.randomUUID(), | |
| role: "assistant", | |
| content: synthesize(text), | |
| ts: Date.now(), | |
| }; | |
| updateActiveMessages(m => [...m, reply]); | |
| setThinking(false); | |
| onSpeakingChange?.(false); | |
| }, 900 + Math.random() * 800); | |
| }, [draft, onSpeakingChange, updateActiveMessages, activeId]); | |
| const startNewSession = useCallback(() => { | |
| const s = newSession(); | |
| setSessions(prev => [s, ...prev]); | |
| setActiveId(s.id); | |
| setHistoryOpen(false); | |
| setExpanded(true); | |
| }, []); | |
| const switchSession = useCallback((id: string) => { | |
| setActiveId(id); | |
| setHistoryOpen(false); | |
| setExpanded(true); | |
| }, []); | |
| const deleteSession = useCallback((id: string) => { | |
| setSessions(prev => { | |
| const next = prev.filter(s => s.id !== id); | |
| if (next.length === 0) { | |
| const fresh = newSession(); | |
| saveSessions([fresh]); | |
| setActiveId(fresh.id); | |
| return [fresh]; | |
| } | |
| if (id === activeId) setActiveId(next[0].id); | |
| return next; | |
| }); | |
| }, [activeId]); | |
| return ( | |
| <motion.div | |
| layout | |
| className={cn("fixed z-30 bottom-4 left-1/2 -translate-x-1/2 w-[min(640px,92vw)]")} | |
| transition={{ type: "spring", stiffness: 180, damping: 24 }} | |
| > | |
| {/* ── History side-panel ── */} | |
| <AnimatePresence> | |
| {historyOpen && ( | |
| <motion.div | |
| key="history" | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0, x: -20 }} | |
| transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }} | |
| className="absolute bottom-full mb-2 left-0 w-64 glass-strong rounded-2xl overflow-hidden shadow-2xl" | |
| > | |
| <div className="flex items-center justify-between px-4 py-2.5 border-b border-white/5"> | |
| <span className="text-xs font-semibold text-foreground/80">تاریخچه مکالمات</span> | |
| <div className="flex items-center gap-1"> | |
| <button | |
| onClick={startNewSession} | |
| className="text-[10px] px-2 py-0.5 rounded-full bg-primary/20 text-primary hover:bg-primary/30 transition-colors" | |
| title="مکالمه جدید" | |
| > | |
| + جدید | |
| </button> | |
| <button onClick={() => setHistoryOpen(false)} className="text-muted-foreground hover:text-foreground transition-colors ml-1"> | |
| <X className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="max-h-64 overflow-y-auto py-1"> | |
| {sessions.length === 0 && ( | |
| <p className="text-xs text-muted-foreground text-center py-4">هیچ مکالمهای ذخیره نشده</p> | |
| )} | |
| {sessions.map(s => ( | |
| <div | |
| key={s.id} | |
| className={cn( | |
| "group flex items-center gap-2 px-4 py-2 cursor-pointer transition-colors hover:bg-primary/5", | |
| s.id === activeId && "bg-primary/10" | |
| )} | |
| onClick={() => switchSession(s.id)} | |
| > | |
| <div className="flex-1 min-w-0"> | |
| <div className="text-xs font-medium truncate text-foreground/90">{s.title}</div> | |
| <div className="text-[10px] text-muted-foreground mt-0.5"> | |
| {s.messages.length} پیام · {new Date(s.updatedAt).toLocaleDateString("fa-IR")} | |
| </div> | |
| </div> | |
| <button | |
| onClick={e => { e.stopPropagation(); deleteSession(s.id); }} | |
| className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all" | |
| title="حذف مکالمه" | |
| > | |
| <Trash2 className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* ── Messages panel ── */} | |
| <AnimatePresence initial={false}> | |
| {expanded && ( | |
| <motion.div | |
| key="messages" | |
| initial={{ opacity: 0, y: 16, height: 0 }} | |
| animate={{ opacity: 1, y: 0, height: "auto" }} | |
| exit={{ opacity: 0, y: 16, height: 0 }} | |
| transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }} | |
| className="glass-strong rounded-3xl mb-2 overflow-hidden" | |
| > | |
| <div className="flex items-center justify-between px-4 py-2 border-b border-white/5"> | |
| <div className="flex items-center gap-2 text-xs text-muted-foreground min-w-0"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shrink-0" /> | |
| <span className="truncate max-w-[160px]" title={activeSession?.title}> | |
| {activeSession?.title ?? "Live channel"} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-1.5"> | |
| <button | |
| onClick={() => updateActiveMessages(() => [{ ...WELCOME_MSG, id: crypto.randomUUID(), ts: Date.now() }])} | |
| className="text-muted-foreground hover:text-destructive transition-colors" | |
| title="پاک کردن مکالمه" | |
| > | |
| <Trash2 className="w-3.5 h-3.5" /> | |
| </button> | |
| <button onClick={() => setExpanded(false)} className="text-muted-foreground hover:text-foreground transition-colors"> | |
| <X className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| <div ref={scrollRef} className="max-h-72 overflow-y-auto px-4 py-3 space-y-3"> | |
| {messages.map(m => ( | |
| <motion.div | |
| key={m.id} | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")} | |
| > | |
| <div | |
| className={cn( | |
| "max-w-[80%] rounded-2xl px-3.5 py-2 text-sm leading-relaxed", | |
| m.role === "user" | |
| ? "bg-primary/15 text-foreground border border-primary/30" | |
| : "glass text-foreground/90" | |
| )} | |
| > | |
| {m.content} | |
| </div> | |
| </motion.div> | |
| ))} | |
| {thinking && ( | |
| <div className="flex items-center gap-1.5 text-xs text-muted-foreground pl-1"> | |
| <Sparkles className="w-3 h-3 animate-pulse" /> | |
| thinking | |
| <span className="flex gap-0.5"> | |
| {[0, 1, 2].map(i => ( | |
| <span key={i} className="w-1 h-1 rounded-full bg-primary/70 animate-pulse" style={{ animationDelay: `${i * 0.15}s` }} /> | |
| ))} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* ── Input bar ── */} | |
| <div className="glass-strong rounded-full pl-2 pr-1.5 py-1.5 flex items-center gap-2"> | |
| {/* History button */} | |
| <button | |
| onClick={() => setHistoryOpen(h => !h)} | |
| className={cn("rounded-full h-8 w-8 grid place-items-center transition-colors", | |
| historyOpen ? "bg-primary/20 text-primary" : "text-muted-foreground hover:text-primary")} | |
| aria-label="تاریخچه مکالمات" | |
| title="تاریخچه مکالمات" | |
| > | |
| <History className="w-4 h-4" /> | |
| </button> | |
| {/* Toggle messages */} | |
| <button | |
| onClick={() => setExpanded(e => !e)} | |
| className="text-muted-foreground hover:text-primary transition-colors" | |
| aria-label="Toggle messages" | |
| > | |
| <Sparkles className="w-4 h-4" /> | |
| </button> | |
| <input | |
| value={draft} | |
| onChange={e => setDraft(e.target.value)} | |
| onFocus={() => setExpanded(true)} | |
| onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); send(); } }} | |
| placeholder="Speak to the sky…" | |
| className="flex-1 bg-transparent outline-none text-sm placeholder:text-muted-foreground/60 py-1.5" | |
| /> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={() => setListening(l => !l)} | |
| className={cn("rounded-full h-8 w-8", listening && "bg-primary/20 text-primary")} | |
| title={listening ? "توقف ضبط صدا" : "شروع ضبط صدا"} | |
| > | |
| <Mic className="w-4 h-4" /> | |
| </Button> | |
| <Button | |
| onClick={send} | |
| size="icon" | |
| className="rounded-full h-8 w-8 bg-gradient-to-br from-primary to-accent disabled:opacity-40" | |
| disabled={!draft.trim() || thinking} | |
| title="ارسال پیام" | |
| > | |
| <Send className="w-3.5 h-3.5" /> | |
| </Button> | |
| </div> | |
| {listening && <Waveform />} | |
| </motion.div> | |
| ); | |
| } | |
| function Waveform() { | |
| return ( | |
| <div className="absolute -top-8 left-1/2 -translate-x-1/2 flex items-end gap-0.5 h-6"> | |
| {Array.from({ length: 24 }).map((_, i) => ( | |
| <motion.span | |
| key={i} | |
| className="w-1 rounded-full bg-gradient-to-t from-primary to-accent" | |
| animate={{ height: [4, 6 + Math.random() * 18, 4] }} | |
| transition={{ duration: 0.6 + Math.random() * 0.6, repeat: Infinity, delay: i * 0.04, ease: "easeInOut" }} | |
| /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| function synthesize(input: string) { | |
| const seeds = [ | |
| "Acknowledged. Routing intent through the cognitive mesh.", | |
| "Signal received. Constellations realigned to your query.", | |
| "Processed. The orbital memory has been updated.", | |
| "Done. A satellite carries your request beyond the haze.", | |
| ]; | |
| return `${seeds[Math.floor(Math.random() * seeds.length)]} ${input.length > 60 ? "" : "Anything else?"}`; | |
| } | |