Download apps/web/components/shell/ChatShell.tsx from Sandy-sys/finai: direct link, hf CLI and curl.
- Browser
- Download file 12.9 kB
-
https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/web/components/shell/ChatShell.tsx
- Command line
-
hf download hf://spaces/Sandy-sys/finai/apps/web/components/shell/ChatShell.tsx
-
curl -L -o ChatShell.tsx https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/web/components/shell/ChatShell.tsx
12.9 kB
| "use client"; | |
| import { useEffect, useState, useRef } from "react"; | |
| import Image from "next/image"; | |
| import { Send, History, Sparkles, Trash2, MessageSquare, Clock, ChevronLeft, Menu, X, Activity, Database } from "lucide-react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import type { UIPlan } from "@/components/dynamic/types"; | |
| type Health = { | |
| status: string; | |
| duckdb_ok: boolean; | |
| tables: number; | |
| llm_keys: number; | |
| mode: string; | |
| }; | |
| type HistoryEntry = { q: string; plan: UIPlan; ts: number }; | |
| type Props = { | |
| history: HistoryEntry[]; | |
| onAsk: (q: string) => void; | |
| onSelect: (e: HistoryEntry) => void; | |
| onClear?: () => void; | |
| loading: boolean; | |
| children: React.ReactNode; | |
| liveMode?: boolean; | |
| onToggleLive?: () => void; | |
| onOpenKB?: () => void; | |
| }; | |
| export function ChatShell({ history, onAsk, onSelect, onClear, loading, children, liveMode, onToggleLive, onOpenKB }: Props) { | |
| const [q, setQ] = useState(""); | |
| const [sidebarOpen, setSidebarOpen] = useState(false); | |
| const inputRef = useRef<HTMLInputElement>(null); | |
| const submit = () => { | |
| if (!q.trim() || loading) return; | |
| onAsk(q); | |
| setQ(""); | |
| }; | |
| return ( | |
| <div className="grid min-h-screen max-w-full overflow-x-hidden grid-cols-1 lg:grid-cols-[280px_1fr]"> | |
| {/* Mobile sidebar overlay */} | |
| <AnimatePresence> | |
| {sidebarOpen && ( | |
| <motion.div | |
| initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} | |
| className="fixed inset-0 z-40 bg-black/30 backdrop-blur-sm lg:hidden" | |
| onClick={() => setSidebarOpen(false)} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| <Sidebar | |
| history={history} | |
| onSelect={(e) => { onSelect(e); setSidebarOpen(false); }} | |
| onClear={onClear} | |
| mobileOpen={sidebarOpen} | |
| onCloseMobile={() => setSidebarOpen(false)} | |
| liveMode={liveMode} | |
| onToggleLive={onToggleLive ? () => { onToggleLive(); setSidebarOpen(false); } : undefined} | |
| onOpenKB={onOpenKB ? () => { onOpenKB(); setSidebarOpen(false); } : undefined} | |
| /> | |
| <main className="flex min-h-screen flex-col"> | |
| <Topbar onMenuClick={() => setSidebarOpen(true)} /> | |
| <section className={`flex-1 overflow-x-hidden ${liveMode ? "" : "px-4 py-6 sm:px-6"}`}>{children}</section> | |
| {/* ββ Enhanced Input Bar ββββββββββββββββββββββββββββββββ */} | |
| <div className="sticky bottom-0 z-10 border-t border-mb-mist bg-white/85 px-4 py-3 backdrop-blur-lg"> | |
| <form | |
| onSubmit={(e) => { e.preventDefault(); submit(); }} | |
| className="mx-auto max-w-4xl" | |
| > | |
| <div className="flex items-center gap-3 rounded-2xl border border-mb-amber-200/80 bg-white px-4 py-2 shadow-lg shadow-mb-orange/5 ring-1 ring-mb-orange/10 transition-shadow focus-within:shadow-xl focus-within:shadow-mb-orange/10 focus-within:ring-mb-orange/20"> | |
| <Sparkles className="h-4 w-4 shrink-0 text-mb-orange" /> | |
| <input | |
| ref={inputRef} | |
| value={q} | |
| onChange={(e) => setQ(e.target.value)} | |
| placeholder="Ask about budgets, programs, regional trendsβ¦" | |
| className="h-10 flex-1 bg-transparent text-[15px] text-mb-ink outline-none placeholder:text-mb-slate/40" | |
| suppressHydrationWarning | |
| autoComplete="off" | |
| /> | |
| <div className="flex items-center gap-2"> | |
| {q.trim() && ( | |
| <span className="hidden sm:inline text-[10px] text-mb-slate/40 font-mono"> | |
| β΅ Enter | |
| </span> | |
| )} | |
| <button | |
| type="submit" | |
| disabled={loading || !q.trim()} | |
| suppressHydrationWarning | |
| className="flex h-9 items-center gap-2 rounded-xl bg-gradient-to-r from-mb-orange to-mb-yellow px-4 text-sm font-semibold text-white shadow-lg shadow-mb-orange/30 transition-all hover:brightness-105 hover:shadow-xl disabled:opacity-40 disabled:shadow-none" | |
| > | |
| <Send className="h-4 w-4" /> | |
| <span className="hidden sm:inline">{loading ? "Thinkingβ¦" : "Ask"}</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="mt-1.5 flex justify-center"> | |
| <span className="text-[10px] text-mb-slate/35"> | |
| Results include provenance citations from your database Β· No hallucinated data | |
| </span> | |
| </div> | |
| </form> | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| } | |
| function Sidebar({ | |
| history, | |
| onSelect, | |
| onClear, | |
| mobileOpen, | |
| onCloseMobile, | |
| liveMode, | |
| onToggleLive, | |
| onOpenKB, | |
| }: Pick<Props, "history" | "onSelect" | "onClear" | "liveMode" | "onToggleLive" | "onOpenKB"> & { | |
| mobileOpen: boolean; | |
| onCloseMobile: () => void; | |
| }) { | |
| return ( | |
| <aside className={` | |
| ${mobileOpen ? "fixed inset-y-0 left-0 z-50 w-72 shadow-2xl" : "hidden"} | |
| border-r border-[#D4A11E]/35 bg-gradient-to-b from-[#F1C232] via-[#F6D76B] to-[#FFF1B8] p-4 backdrop-blur | |
| lg:relative lg:flex lg:flex-col lg:z-auto lg:w-auto lg:shadow-none | |
| `}> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <Image | |
| src="/magic-bus-logo.png" | |
| alt="Magic Bus India Foundation" | |
| width={36} | |
| height={36} | |
| className="rounded-lg shadow-sm" | |
| priority | |
| /> | |
| <div> | |
| <div className="font-serif text-lg leading-none">FinAI</div> | |
| <div className="text-[10px] uppercase tracking-[0.12em] text-mb-slate/55"> | |
| Adaptive Analytics | |
| </div> | |
| </div> | |
| </div> | |
| <button onClick={onCloseMobile} title="Close sidebar" className="rounded-md p-1 lg:hidden hover:bg-white/30"> | |
| <X className="h-4 w-4" /> | |
| </button> | |
| </div> | |
| {/* Live Insights toggle */} | |
| {onToggleLive && ( | |
| <button | |
| type="button" | |
| onClick={onToggleLive} | |
| suppressHydrationWarning | |
| className={`mt-4 flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all ${ | |
| liveMode | |
| ? "bg-gradient-to-r from-emerald-500 to-teal-600 text-white shadow-md" | |
| : "bg-white/50 text-mb-ink/80 ring-1 ring-mb-amber-200/40 hover:bg-white/70 hover:shadow-sm" | |
| }`} | |
| > | |
| <Activity className="h-4 w-4" /> | |
| Live Insights | |
| <span className="relative ml-auto flex h-2.5 w-2.5"> | |
| <span className={`absolute inline-flex h-full w-full rounded-full opacity-75 ${liveMode ? "animate-ping bg-white" : "bg-emerald-400"}`} /> | |
| <span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${liveMode ? "bg-white" : "bg-emerald-500"}`} /> | |
| </span> | |
| </button> | |
| )} | |
| {/* Knowledge Base manager */} | |
| {onOpenKB && ( | |
| <button | |
| type="button" | |
| onClick={onOpenKB} | |
| suppressHydrationWarning | |
| className="mt-2 flex w-full items-center gap-2 rounded-xl bg-white/50 px-3 py-2.5 text-sm font-semibold text-mb-ink/80 ring-1 ring-mb-amber-200/40 transition-all hover:bg-white/70 hover:shadow-sm" | |
| > | |
| <Database className="h-4 w-4 text-mb-amber-700" /> | |
| Knowledge Base | |
| <span className="ml-auto rounded-full bg-mb-amber-100 px-2 py-0.5 text-[10px] font-medium text-mb-amber-800"> | |
| Edit | |
| </span> | |
| </button> | |
| )} | |
| {/* History section */} | |
| <div className="mt-6 flex items-center justify-between"> | |
| <div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-mb-slate/55 font-semibold"> | |
| <Clock className="h-3 w-3" /> | |
| History | |
| {history.length > 0 && ( | |
| <span className="ml-1 rounded-full bg-white/50 px-1.5 py-0.5 text-[9px] font-bold text-mb-amber-800"> | |
| {history.length} | |
| </span> | |
| )} | |
| </div> | |
| {history.length > 0 && onClear && ( | |
| <button | |
| onClick={onClear} | |
| title="Clear history" | |
| className="rounded-md p-1 text-mb-slate/50 transition hover:bg-white/40 hover:text-rose-700" | |
| > | |
| <Trash2 className="h-3 w-3" /> | |
| </button> | |
| )} | |
| </div> | |
| <ul className="mt-2 flex-1 space-y-0.5 overflow-y-auto pr-1 scrollbar-thin"> | |
| {history.length === 0 ? ( | |
| <li className="rounded-xl bg-[#F1C232]/15 p-3 text-[12px] text-mb-slate/60 leading-relaxed"> | |
| <MessageSquare className="mb-1 h-4 w-4 text-mb-amber-700/40" /> | |
| Your analysis history will appear here. Saved locally on this device. | |
| </li> | |
| ) : ( | |
| history.map((h, i) => ( | |
| <li key={`${h.q}-${i}`}> | |
| <button | |
| onClick={() => onSelect(h)} | |
| className="group/h w-full rounded-xl px-2.5 py-2 text-left transition-all hover:bg-gradient-to-r hover:from-[#F9E08B]/80 hover:to-[#FFF3C2]/80 hover:shadow-sm" | |
| title={h.q} | |
| > | |
| <div className="truncate text-[13px] font-medium text-mb-ink/90 group-hover/h:text-mb-amber-900"> | |
| {h.q} | |
| </div> | |
| {h.ts && ( | |
| <div className="mt-0.5 flex items-center gap-1 text-[10px] text-mb-slate/45 group-hover/h:text-mb-amber-700/60"> | |
| <Clock className="h-2.5 w-2.5" /> | |
| {fmtTime(h.ts)} | |
| </div> | |
| )} | |
| </button> | |
| </li> | |
| )) | |
| )} | |
| </ul> | |
| </aside> | |
| ); | |
| } | |
| function fmtTime(ts: number): string { | |
| const d = new Date(ts); | |
| const now = new Date(); | |
| const sameDay = d.toDateString() === now.toDateString(); | |
| return sameDay | |
| ? d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) | |
| : d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + | |
| " Β· " + | |
| d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); | |
| } | |
| function Topbar({ onMenuClick }: { onMenuClick: () => void }) { | |
| const [h, setH] = useState<Health | null>(null); | |
| useEffect(() => { | |
| let alive = true; | |
| const load = () => | |
| fetch("/api/backend/health") | |
| .then((r) => (r.ok ? r.json() : null)) | |
| .then((j) => alive && setH(j)) | |
| .catch(() => alive && setH(null)); | |
| load(); | |
| const id = setInterval(load, 15000); | |
| return () => { | |
| alive = false; | |
| clearInterval(id); | |
| }; | |
| }, []); | |
| const tables = h?.tables ?? 0; | |
| const keys = h?.llm_keys ?? 0; | |
| const mode = h?.mode ?? "β¦"; | |
| const dbOk = h?.duckdb_ok; | |
| const loadingHealth = h === null; | |
| const dotColor = loadingHealth | |
| ? "bg-yellow-400 animate-pulse" | |
| : dbOk && keys > 0 | |
| ? "bg-emerald-400" | |
| : "bg-rose-400"; | |
| return ( | |
| <header className="flex items-center justify-between border-b border-[#D4A11E]/35 bg-gradient-to-r from-[#F1C232] via-[#F6D76B] to-[#FFF1B8] px-4 py-2.5 backdrop-blur sm:px-6"> | |
| <div className="flex items-center gap-3"> | |
| <button onClick={onMenuClick} title="Open sidebar" className="rounded-md p-1 lg:hidden hover:bg-white/30"> | |
| <Menu className="h-5 w-5 text-mb-ink/70" /> | |
| </button> | |
| <div className="flex items-center gap-2 text-sm text-mb-slate"> | |
| <span className={`h-2 w-2 rounded-full ${dotColor}`} /> | |
| <span className="font-semibold text-mb-orange">finai</span> | |
| <span className="hidden sm:inline text-mb-slate/40">Β·</span> | |
| <span className="hidden sm:inline text-[13px]"> | |
| {loadingHealth | |
| ? "connectingβ¦" | |
| : dbOk === false | |
| ? "database unavailable" | |
| : `${tables} tables Β· Llama 3.3`} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 text-xs"> | |
| {!loadingHealth && keys > 0 && ( | |
| <span className="hidden sm:inline rounded-full bg-emerald-100/70 px-2 py-0.5 text-[10px] font-medium text-emerald-800 ring-1 ring-emerald-300/30"> | |
| {keys} keys active | |
| </span> | |
| )} | |
| <span className="rounded-full bg-mb-amber-100/80 px-2 py-0.5 text-[10px] font-semibold text-mb-amber-800 ring-1 ring-mb-amber-200/60"> | |
| FY 2025β26 | |
| </span> | |
| </div> | |
| </header> | |
| ); | |
| } | |