QMetaRam / ChatPanel.tsx
metarm's picture
Upload folder using huggingface_hub (part 4)
945aae0 verified
Raw History Blame Contribute Delete
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?"}`;
}