Subhadip007 commited on
Commit
9b7c6ff
·
1 Parent(s): 230bf14

feat: implement ChatGPT style UI, streaming generation, math/code blocks, citations & multi-model fallback

Browse files
.env.example CHANGED
@@ -1 +1,2 @@
1
  GROQ_API_KEY=your_groq_api_key_here
 
 
1
  GROQ_API_KEY=your_groq_api_key_here
2
+ HF_API_KEY=your_key_here
config/settings.py CHANGED
@@ -80,6 +80,7 @@ TOP_K_RERANK = 5 # Keep top 5 after reranking
80
  # LLM SETTINGS
81
  # ------------------------------------------
82
  GROQ_API_KEY = os.getenv('GROQ_API_KEY') # Loaded from .env
 
83
  LLM_MODEL_NAME = 'llama-3.3-70b-versatile' # Groq model ID
84
  LLM_TEMPERATURE = 0.1 # Low = More factual/consistent
85
  LLM_MAX_TOKENS = 2048 # Max response tokens
 
80
  # LLM SETTINGS
81
  # ------------------------------------------
82
  GROQ_API_KEY = os.getenv('GROQ_API_KEY') # Loaded from .env
83
+ HF_API_KEY = os.getenv('HF_API_KEY')
84
  LLM_MODEL_NAME = 'llama-3.3-70b-versatile' # Groq model ID
85
  LLM_TEMPERATURE = 0.1 # Low = More factual/consistent
86
  LLM_MAX_TOKENS = 2048 # Max response tokens
frontend-next/app/globals.css CHANGED
@@ -926,3 +926,46 @@ select {
926
  color: var(--accent);
927
  transform: translateY(-2px);
928
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
  color: var(--accent);
927
  transform: translateY(-2px);
928
  }
929
+
930
+ /* -- UI Redesign -- */
931
+ .layout-wrapper { display: flex; height: 100vh; width: 100vw; overflow: hidden; }
932
+ .sidebar { width: 260px; background: rgba(5, 7, 10, 0.95); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; padding: 10px; z-index: 100; flex-shrink: 0; transition: transform 0.3s ease; }
933
+ @media (max-width: 768px) { .sidebar { position: fixed; height: 100vh; transform: translateX(-100%); } .sidebar.open { transform: translateX(0); } }
934
+ .sidebar-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 90; }
935
+ .new-chat-btn { display: flex; align-items: center; gap: 10px; width: 100%; padding: 12px; border-radius: 8px; background: rgba(255,255,255,0.05); color: #fff; font-weight: 500; font-size: 0.9rem; margin-bottom: 20px; transition: 0.2s; border: 1px solid rgba(255,255,255,0.1); }
936
+ .new-chat-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); }
937
+ .history-item { padding: 10px; border-radius: 6px; font-size: 0.85rem; color: var(--text-muted); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: 0.2s; }
938
+ .history-item:hover, .history-item.active { background: rgba(255,255,255,0.05); color: #fff; }
939
+
940
+ .main-chat-area { flex: 1; display: flex; flex-direction: column; position: relative; height: 100vh; }
941
+ .chat-container { flex: 1; overflow-y: auto; padding: 80px 20px 140px 20px; display: flex; flex-direction: column; gap: 24px; max-width: 900px; margin: 0 auto; width: 100%; }
942
+
943
+ .message-user { align-self: flex-end; background: rgba(255,255,255,0.1); color: #fff; padding: 12px 18px; border-radius: 20px; border-bottom-right-radius: 4px; max-width: 80%; }
944
+ .message-ai { align-self: flex-start; background: transparent; color: #e2e8f0; border-radius: 12px; width: 100%; }
945
+
946
+ .bottom-input-bar { position: fixed; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(3,5,8,0.9) 20%); padding: 20px; display: flex; justify-content: center; z-index: 50; pointer-events: none; }
947
+ @media (min-width: 769px) { .bottom-input-bar { left: 260px; } }
948
+ .bottom-input-bar-inner { width: 100%; max-width: 800px; display: flex; flex-direction: column; gap: 8px; pointer-events: auto; }
949
+
950
+ .hamburger { display: none; }
951
+ @media (max-width: 768px) { .hamburger { display: flex; z-index: 200; position: fixed; top: 16px; left: 16px; width: 40px; height: 40px; border-radius: 8px; background: rgba(255,255,255,0.1); align-items: center; justify-content: center; backdrop-filter: blur(10px); } }
952
+
953
+ /* Code Block */
954
+ .code-header { display: flex; justify-content: space-between; align-items: center; background: #282c34; padding: 4px 12px; border-top-left-radius: 8px; border-top-right-radius: 8px; font-family: monospace; font-size: 0.8rem; color: #abb2bf; }
955
+ .code-wrapper { border-radius: 8px; overflow: hidden; margin: 12px 0; border: 1px solid rgba(255,255,255,0.1); }
956
+ .code-wrapper pre { margin: 0 !important; border-radius: 0 !important; }
957
+
958
+ /* Citations Pill */
959
+ .citation-badge { display: inline-flex; align-items: center; justify-content: center; background: #3b82f6; color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 12px; font-weight: 600; cursor: pointer; text-decoration: none; vertical-align: super; line-height: 1; margin: 0 2px; }
960
+ .citation-badge:hover { background: #2563eb; }
961
+
962
+ /* Feedback row */
963
+ .feedback-row { display: flex; align-items: center; gap: 12px; margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.1); }
964
+ .star-btn { color: #4b5563; transition: 0.2s; cursor: pointer; }
965
+ .star-btn:hover, .star-btn.active { color: #f59e0b; }
966
+ .feedback-input { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); padding: 6px 12px; border-radius: 6px; font-size: 0.85rem; color: #fff; flex: 1; }
967
+ .feedback-submit { background: var(--accent); color: #000; padding: 6px 16px; border-radius: 6px; font-weight: 600; font-size: 0.85rem; }
968
+
969
+ .blinking-cursor { display: inline-block; width: 8px; height: 16px; background: #fff; animation: blink 1s step-end infinite; }
970
+ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
971
+
frontend-next/app/layout.tsx CHANGED
@@ -2,6 +2,7 @@
2
  import type { Metadata } from "next";
3
  import { Inter } from "next/font/google";
4
  import "./globals.css";
 
5
 
6
  const inter = Inter({ subsets: ["latin"] });
7
 
 
2
  import type { Metadata } from "next";
3
  import { Inter } from "next/font/google";
4
  import "./globals.css";
5
+ import 'katex/dist/katex.min.css';
6
 
7
  const inter = Inter({ subsets: ["latin"] });
8
 
frontend-next/app/page.tsx CHANGED
@@ -1,30 +1,19 @@
1
- "use client";
2
 
3
  import { useState, useRef, useEffect } from "react";
4
  import { motion, AnimatePresence } from "framer-motion";
5
  import { InlineMath, BlockMath } from 'react-katex';
6
- import 'katex/dist/katex.min.css';
 
7
  import {
8
- BookOpen,
9
- Clock,
10
- Zap,
11
- AlertCircle,
12
- CheckCircle,
13
- ExternalLink,
14
- Sparkles,
15
- Brain,
16
- ArrowRight,
17
- Layers,
18
- Fingerprint,
19
- Send,
20
- Info,
21
- X,
22
- Server,
23
- Activity,
24
- Rocket,
25
  } from "lucide-react";
26
 
27
- // ── Types ─────────────────────────────────────────────────
 
 
 
28
  interface Citation {
29
  paper_id: string;
30
  title: string;
@@ -33,840 +22,522 @@ interface Citation {
33
  arxiv_url: string;
34
  }
35
 
36
- interface QueryResult {
37
- answer: string;
38
- citations: Citation[];
39
- query: string;
40
- chunks_used: number;
41
- retrieval_time_ms: number;
42
- generation_time_ms: number;
43
- total_time_ms: number;
44
- has_context: boolean;
45
  }
46
 
47
- // ── Config ────────────────────────────────────────────────
48
- const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
 
 
 
 
 
 
 
49
 
50
- const EXAMPLE_QUERIES = [
51
- "How does LoRA reduce trainable parameters?",
52
- "What are challenges in multi-agent RL?",
53
- "Explain diffusion models for images",
54
- ];
55
-
56
- const CATEGORY_OPTIONS = [
57
- { value: "All", label: "All Topics" },
58
- { value: "cs.LG", label: "cs.LG", indexed: true },
59
- { value: "cs.AI", label: "cs.AI", indexed: true },
60
- { value: "stat.ML", label: "stat.ML", indexed: true },
61
- { value: "cs.CV", label: "cs.CV", indexed: false, disabled: true },
62
- { value: "cs.CL", label: "cs.CL", indexed: false, disabled: true },
63
- { value: "cs.RO", label: "cs.RO", indexed: false, disabled: true },
64
- ];
65
-
66
- // ── Custom Dropdown Component ─────────────────────────────
67
- function CustomSelect({
68
- options,
69
- value,
70
- onChange,
71
- width = '140px',
72
- }: {
73
- options: { value: string | number; label: string; disabled?: boolean; indexed?: boolean }[];
74
- value: string | number;
75
- onChange: (val: string | number) => void;
76
- width?: string;
77
- }) {
78
- const [isOpen, setIsOpen] = useState(false);
79
- const [placement, setPlacement] = useState<"top" | "bottom">("bottom");
80
- const ref = useRef<HTMLDivElement>(null);
81
 
82
- useEffect(() => {
83
- const handleClickOutside = (e: MouseEvent) => {
84
- if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false);
85
- };
86
- document.addEventListener('mousedown', handleClickOutside);
87
- return () => document.removeEventListener('mousedown', handleClickOutside);
88
- }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- const toggleOpen = () => {
91
- if (!isOpen && ref.current) {
92
- const rect = ref.current.getBoundingClientRect();
93
- // Need ~240px for full menu, pop up if space is tight below
94
- if (window.innerHeight - rect.bottom < 240) {
95
- setPlacement('top');
96
- } else {
97
- setPlacement('bottom');
98
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  }
100
- setIsOpen(!isOpen);
101
- };
 
 
 
 
 
102
 
103
- const activeLabel = options.find((o) => o.value === value)?.label || value;
 
 
 
 
 
 
 
 
 
 
104
 
 
 
 
105
  return (
106
- <div ref={ref} style={{ position: 'relative', width }}>
107
- <button
108
- onClick={toggleOpen}
109
- className="cyber-select"
110
- style={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
111
- >
112
- <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{activeLabel}</span>
113
- <span style={{ fontSize: '0.7em', opacity: 0.5, marginLeft: '8px' }}>{isOpen ? '▲' : '▼'}</span>
114
- </button>
115
- <AnimatePresence>
116
- {isOpen && (
117
- <motion.div
118
- initial={{ opacity: 0, y: placement === 'top' ? 10 : -10, scale: 0.95 }}
119
- animate={{ opacity: 1, y: 0, scale: 1 }}
120
- exit={{ opacity: 0, y: placement === 'top' ? 5 : -5, scale: 0.95 }}
121
- transition={{ duration: 0.15, ease: 'easeOut' }}
122
- className="custom-dropdown-menu"
123
- style={{
124
- top: placement === 'bottom' ? 'calc(100% + 8px)' : 'auto',
125
- bottom: placement === 'top' ? 'calc(100% + 8px)' : 'auto'
126
- }}
127
- >
128
- {options.map((opt) => (
129
- <button
130
- key={opt.value}
131
- onClick={() => {
132
- if (opt.disabled) return;
133
- onChange(opt.value);
134
- setIsOpen(false);
135
- }}
136
- disabled={opt.disabled}
137
- className={`custom-dropdown-item ${value === opt.value ? 'active' : ''}`}
138
- style={opt.disabled ? { opacity: 0.4, cursor: 'not-allowed' } : {}}
139
- >
140
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
141
- <span>{opt.label}</span>
142
- {opt.indexed !== undefined && (
143
- <div style={{
144
- fontSize: "0.65em",
145
- fontWeight: 600,
146
- padding: "2px 6px",
147
- borderRadius: "12px",
148
- backgroundColor: opt.indexed ? "rgba(16, 185, 129, 0.15)" : "rgba(156, 163, 175, 0.1)",
149
- color: opt.indexed ? "var(--success)" : "var(--text-muted)",
150
- marginLeft: "8px"
151
- }}>
152
- {opt.indexed ? "INDEXED" : "UNAVAILABLE"}
153
- </div>
154
- )}
155
- </div>
156
- </button>
157
- ))}
158
- </motion.div>
159
- )}
160
- </AnimatePresence>
161
  </div>
162
  );
163
- }
164
 
165
- function renderLaTeX(text: string) {
166
- const blockSplit = text.split(/(\$\$[\s\S]+?\$\$)/g);
167
- return blockSplit.map((blockPart, i) => {
168
- if (blockPart.startsWith("$$") && blockPart.endsWith("$$")) {
169
- const math = blockPart.slice(2, -2);
170
- return <BlockMath key={i} math={math} />;
 
 
 
 
 
 
 
 
 
 
 
171
  }
172
- const inlineSplit = blockPart.split(/(\$[\s\S]+?\$)/g);
173
- return (
174
- <span key={i}>
175
- {inlineSplit.map((inlinePart, j) => {
176
- if (inlinePart.startsWith("$") && inlinePart.endsWith("$")) {
177
- const math = inlinePart.slice(1, -1);
178
- return <InlineMath key={j} math={math} />;
179
- }
180
- return (
181
- <span key={j} style={{ whiteSpace: "pre-wrap" }}>
182
- {inlinePart}
183
- </span>
184
- );
185
- })}
186
- </span>
187
- );
188
- });
189
- }
190
 
191
- // ── Main Page ─────────────────────────────────────────────
192
- export default function Home() {
193
- const [question, setQuestion] = useState("");
194
- const [result, setResult] = useState<QueryResult | null>(null);
195
- const [loading, setLoading] = useState(false);
196
- const [error, setError] = useState("");
197
 
198
- // Settings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  const [topK, setTopK] = useState(5);
200
  const [category, setCategory] = useState("All");
201
- const [yearFilter, setYearFilter] = useState(false);
202
- const [yearFrom, setYearFrom] = useState(2024);
203
- const [showSettings, setShowSettings] = useState(false);
204
-
205
- // System state
206
- const [apiStatus, setApiStatus] = useState<
207
- "unknown" | "online" | "offline"
208
- >("unknown");
209
- const [showInfo, setShowInfo] = useState(false);
210
- const [showStatusDetails, setShowStatusDetails] = useState(false);
211
 
 
212
  const textareaRef = useRef<HTMLTextAreaElement>(null);
213
 
 
214
  useEffect(() => {
215
- if (textareaRef.current) {
216
- textareaRef.current.style.height = "auto";
217
- textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
218
  }
219
- }, [question]);
220
 
 
221
  useEffect(() => {
222
- checkHealth();
223
- }, []);
 
 
224
 
225
- const checkHealth = async () => {
226
- try {
227
- const r = await fetch(`${API_URL}/health`, {
228
- signal: AbortSignal.timeout(5000),
229
- });
230
- setApiStatus(r.ok ? "online" : "offline");
231
- } catch {
232
- setApiStatus("offline");
233
  }
 
 
 
 
 
234
  };
235
 
236
- const handleSearch = async () => {
237
- if (!question.trim()) return;
238
- setLoading(true);
239
- setError("");
240
- setResult(null);
 
241
 
242
- try {
243
- const payload: Record<string, unknown> = {
244
- question: question.trim(),
245
- top_k: topK,
 
 
 
 
 
 
 
 
 
 
 
 
246
  };
247
- if (category !== "All") payload.filter_category = category;
248
- if (yearFilter) payload.filter_year_gte = yearFrom;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
250
- const response = await fetch(`${API_URL}/query`, {
 
251
  method: "POST",
252
  headers: { "Content-Type": "application/json" },
253
- body: JSON.stringify(payload),
 
 
 
 
254
  });
255
 
256
- if (!response.ok)
257
- throw new Error(`API returned ${response.status}`);
258
- const data: QueryResult = await response.json();
259
- setResult(data);
260
- } catch (err) {
261
- setError(
262
- err instanceof Error
263
- ? err.message
264
- : "Failed to connect to API.",
265
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  } finally {
267
- setLoading(false);
268
  }
269
  };
270
 
271
- const resetApp = () => {
272
- setQuestion("");
273
- setResult(null);
274
- setError("");
275
- setLoading(false);
276
- };
277
-
278
- const hasSearched = result || loading || error;
279
-
280
  return (
281
- <>
282
- <div className="luminous-grid" />
283
- <div className="orb orb-cyan" />
284
- <div className="orb orb-purple" />
285
-
286
- <AnimatePresence>
287
- {showInfo && (
288
- <div className="info-modal-backdrop" onClick={() => setShowInfo(false)}>
289
- <motion.div
290
- initial={{ opacity: 0, scale: 0.95, y: 20 }}
291
- animate={{ opacity: 1, scale: 1, y: 0 }}
292
- exit={{ opacity: 0, scale: 0.95, y: 20 }}
293
- onClick={(e) => e.stopPropagation()}
294
- className="cyber-panel info-modal"
295
- >
296
- <button className="modal-close" onClick={() => setShowInfo(false)}>
297
- <X size={18} />
298
- </button>
299
- <h2>ResearchPilot Console</h2>
300
- <div style={{ display: "flex", alignItems: "center", gap: "12px", marginTop: "16px" }}>
301
- <div style={{ background: "rgba(138, 43, 226, 0.15)", border: "1px solid rgba(138, 43, 226, 0.4)", padding: "6px 14px", borderRadius: "99px", fontSize: "0.75rem", color: "var(--accent-2)", fontWeight: 700, letterSpacing: "0.05em", textTransform: "uppercase" }}>
302
- Lead Architect
303
- </div>
304
- <span style={{ fontFamily: "'Dancing Script', cursive", fontSize: "1.8rem", fontWeight: 700, background: "linear-gradient(135deg, #fff 20%, var(--accent-2) 100%)", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", letterSpacing: "0.05em", transform: "translateY(-2px)" }}>Subhadip Hensh</span>
305
- </div>
306
-
307
- <hr />
308
-
309
- <h3><Server size={18} /> System Overview</h3>
310
- <p style={{ fontSize: "1rem", lineHeight: 1.7, marginBottom: "16px" }}>ResearchPilot is a high-performance RAG (Retrieval-Augmented Generation) engine tailored for Machine Learning literature. It features hybrid sparse-dense searching, advanced cross-encoder reranking, and GPU-driven vector indexing via Qdrant.</p>
311
-
312
- <h3><Activity size={18} /> Current Operational Capacity</h3>
313
- <ul>
314
- <li><strong>Current Index</strong> Synthesizing 51,019 dense embeddings isolated from ~700 major AI & ML papers.</li>
315
- <li><strong>Data Categories</strong> Fully indexed on core Machine Learning (cs.LG) and AI (cs.AI).</li>
316
- </ul>
317
-
318
- <h3><Layers size={18} /> Core Technology Stack</h3>
319
- <ul>
320
- <li><strong>Frontend Application</strong> Next.js 16 (App Router), React, Framer Motion, Vanilla CSS (Glassmorphism).</li>
321
- <li><strong>Backend Environment</strong> Python, FastAPI, Uvicorn, Pydantic.</li>
322
- <li><strong>Vector Database Engine</strong> Qdrant (GPU Accelerated Dense Vectors).</li>
323
- <li><strong>RAG Processing Pipeline</strong> SentenceTransformers (BGE-base), BM25 Sparse Search, Cross-Encoder Reranking, Groq LLM (LLaMA 3.3).</li>
324
- <li><strong>Mathematics Engine</strong> KaTeX & React-KaTeX for fully dynamic native LaTeX equations.</li>
325
- </ul>
326
-
327
- <h3><Rocket size={18} /> Phase 2: In-Progress Architecture</h3>
328
- <ul>
329
- <li><strong>Massive Data Expansion</strong> Scaling dataset soon to 10,000+ — 20,000+ ML papers spanning NLP, Computer Vision, and Robotics.</li>
330
- <li><strong>Distributed Hardware Execution</strong> Scaling ingestion logic to cloud-based GPU clusters for extreme speed.</li>
331
- <li><strong>Multi-modal Analysis</strong> Soon integrating visual graph and chart processing abilities into the synthesis engine.</li>
332
- </ul>
333
- </motion.div>
334
  </div>
335
- )}
336
- </AnimatePresence>
337
-
338
- {/* ── Top Nav ── */}
339
- <header className="top-nav">
340
- <div className="brand" onClick={resetApp} style={{ cursor: "pointer" }}>
341
- <div className="brand-icon">
342
- <Brain size={22} />
 
 
 
 
 
 
 
 
 
 
343
  </div>
344
- <span>ResearchPilot</span>
345
- </div>
346
- <div className="nav-right">
347
- <button onClick={() => setShowInfo(true)} className="nav-icon-btn" aria-label="Project Info">
348
- <Info size={16} />
349
- </button>
350
- <div style={{ position: "relative" }}>
351
- <button
352
- onClick={async () => {
353
- await checkHealth();
354
- setShowStatusDetails(!showStatusDetails);
355
- }}
356
- className="nav-status"
357
- >
358
- <div
359
- className={`status-dot ${apiStatus === "online" ? "status-online" : apiStatus === "offline" ? "status-offline" : ""}`}
360
- />
361
- {apiStatus === "online"
362
- ? "Systems Nominal"
363
- : apiStatus === "offline"
364
- ? "Offline"
365
- : "Checking..."}
366
- </button>
 
 
 
367
 
368
- <AnimatePresence>
369
- {showStatusDetails && (
370
- <motion.div
371
- initial={{ opacity: 0, y: 10, scale: 0.95 }}
372
- animate={{ opacity: 1, y: 0, scale: 1 }}
373
- exit={{ opacity: 0, y: 10, scale: 0.95 }}
374
- className="cyber-panel custom-dropdown-menu"
375
- style={{
376
- position: "absolute",
377
- top: "calc(100% + 12px)",
378
- right: "-40px",
379
- left: "auto",
380
- width: "260px",
381
- padding: "16px",
382
- zIndex: 100,
383
- display: "flex",
384
- flexDirection: "column",
385
- gap: "8px",
386
- cursor: "default"
387
- }}
388
- onClick={(e) => e.stopPropagation()}
389
- >
390
- <h4 style={{ fontSize: "0.85rem", color: "#fff", marginBottom: "4px" }}>System Connection Status</h4>
391
- <p style={{ fontSize: "0.75rem", color: "var(--text-muted)", lineHeight: 1.5 }}>
392
- {apiStatus === "online"
393
- ? "🟢 Backend API and Qdrant Vector Database are connected and responding correctly. The system is ready for inference."
394
- : "🔴 Backend API is unreachable. You need to run 'python run_api.py' in your backend directory to enable RAG functionality."}
395
- </p>
396
- <button
397
- onClick={(e) => {
398
- e.stopPropagation();
399
- checkHealth();
400
- }}
401
- style={{
402
- marginTop: "8px",
403
- fontSize: "0.75rem",
404
- color: "var(--accent)",
405
- background: "rgba(0, 240, 255, 0.1)",
406
- padding: "6px 12px",
407
- borderRadius: "8px",
408
- border: "1px solid rgba(0, 240, 255, 0.3)",
409
- textAlign: "center",
410
- display: "block",
411
- width: "100%",
412
- cursor: "pointer",
413
- transition: "0.2s"
414
- }}
415
- onMouseEnter={(e) => {
416
- e.currentTarget.style.background = "rgba(0, 240, 255, 0.2)";
417
- }}
418
- onMouseLeave={(e) => {
419
- e.currentTarget.style.background = "rgba(0, 240, 255, 0.1)";
420
- }}
421
- >
422
- Re-verify Connection
423
- </button>
424
- </motion.div>
425
  )}
426
- </AnimatePresence>
427
- </div>
428
- <a
429
- href="https://github.com/07subhadip"
430
- target="_blank"
431
- rel="noopener noreferrer"
432
- className="github-link"
433
- aria-label="GitHub Profile"
434
- >
435
- <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
436
- <path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
437
- </svg>
438
- </a>
439
- </div>
440
- </header>
441
-
442
- <main className="app-container">
443
- {/* ── Central Hero Block ── */}
444
- <motion.div
445
- layout
446
- className="search-wrapper"
447
- style={{ marginTop: hasSearched ? "130px" : "15vh" }}
448
- transition={{ type: "spring", bounce: 0.2, duration: 0.8 }}
449
- >
450
- <AnimatePresence>
451
- {!hasSearched && (
452
- <motion.div
453
- layout
454
- initial={{ opacity: 0, scale: 0.95 }}
455
- animate={{ opacity: 1, scale: 1 }}
456
- exit={{ opacity: 0, scale: 0.95 }}
457
- transition={{ duration: 0.5 }}
458
- style={{ width: "100%" }}
459
- >
460
- <h1 className="hero-title">
461
- Decipher the latest
462
- <br />
463
- <span className="text-gradient-2">
464
- ML Research
465
- </span>
466
- </h1>
467
- <p className="hero-subtitle">
468
- Neural hybrid search across ArXiv. <br />
469
- Cross-encoder reranked. LLM synthesized.
470
- </p>
471
- <div style={{ height: "64px" }} />
472
- </motion.div>
473
- )}
474
- </AnimatePresence>
475
-
476
- {/* ── ChatGPT Style Search Box ── */}
477
- <motion.div
478
- layout
479
- style={{
480
- width: "100%",
481
- margin: "0 auto",
482
- zIndex: 20,
483
- }}
484
- >
485
  <div className="chat-input-wrapper">
486
- <textarea
 
 
 
487
  ref={textareaRef}
488
- value={question}
489
- onChange={(e) => setQuestion(e.target.value)}
490
- placeholder="Message ResearchPilot..."
491
- rows={1}
492
- className="chat-input"
493
- style={{
494
- minHeight: hasSearched ? "20px" : "28px",
495
- maxHeight: "120px",
496
- overflowY: "auto",
497
- }}
498
  onKeyDown={(e) => {
499
- if (e.key === "Enter" && !e.shiftKey) {
500
  e.preventDefault();
501
- handleSearch();
502
  }
503
  }}
 
 
 
504
  />
505
- <button
506
- onClick={handleSearch}
507
- disabled={loading || !question.trim()}
508
- className="send-btn"
509
- >
510
- {loading ? (
511
- <div className="spinner-micro" />
512
- ) : (
513
- <Send
514
- size={18}
515
- strokeWidth={2.5}
516
- style={{ marginLeft: "-2px" }}
517
- />
518
- )}
519
  </button>
520
  </div>
521
-
522
- <div className="search-controls">
523
- <button
524
- onClick={() => setShowSettings(!showSettings)}
525
- className="controls-group"
526
- style={{
527
- color: "var(--text-muted)",
528
- fontSize: "0.8rem",
529
- fontWeight: 600,
530
- padding: "4px",
531
- cursor: "pointer",
532
- }}
533
- >
534
- <Layers size={14} />
535
- CONFIGURE {showSettings ? "▲" : "▼"}
536
- </button>
537
-
538
- <div className="controls-group">
539
- <AnimatePresence>
540
- {showSettings && (
541
- <motion.div
542
- initial={{ opacity: 0, height: 0 }}
543
- animate={{
544
- opacity: 1,
545
- height: "auto",
546
- }}
547
- exit={{ opacity: 0, height: 0 }}
548
- style={{
549
- overflow: "visible",
550
- display: "flex",
551
- gap: "12px",
552
- flexWrap: "wrap",
553
- alignItems: "center",
554
- }}
555
- >
556
- <CustomSelect
557
- value={topK}
558
- onChange={(val) => setTopK(Number(val))}
559
- options={[
560
- { value: 3, label: 'Top 3 Results' },
561
- { value: 5, label: 'Top 5 Results' },
562
- { value: 10, label: 'Top 10 Results' },
563
- ]}
564
- />
565
- <CustomSelect
566
- value={category}
567
- onChange={(val) => setCategory(String(val))}
568
- options={CATEGORY_OPTIONS}
569
- width="160px"
570
- />
571
- <button
572
- onClick={() =>
573
- setYearFilter(!yearFilter)
574
- }
575
- className={`cyber-btn-outline ${yearFilter ? "active" : ""}`}
576
- >
577
- YEAR FILTER{" "}
578
- {yearFilter ? "ON" : "OFF"}
579
- </button>
580
- {yearFilter && (
581
- <div className="year-stepper">
582
- <button onClick={() => setYearFrom(y => Math.max(2000, y - 1))} className="stepper-btn">-</button>
583
- <input
584
- type="number"
585
- value={yearFrom}
586
- readOnly
587
- className="stepper-input"
588
- />
589
- <button onClick={() => setYearFrom(y => Math.min(2026, y + 1))} className="stepper-btn">+</button>
590
- </div>
591
- )}
592
- </motion.div>
593
- )}
594
- </AnimatePresence>
595
- </div>
596
- </div>
597
- </motion.div>
598
-
599
- {/* ── Example Queries ── */}
600
- <AnimatePresence>
601
- {!hasSearched && (
602
- <motion.div
603
- layout
604
- initial={{ opacity: 0 }}
605
- animate={{ opacity: 1 }}
606
- exit={{ opacity: 0, filter: "blur(5px)" }}
607
- className="example-chips"
608
- >
609
- {EXAMPLE_QUERIES.map((q, i) => (
610
- <motion.button
611
- key={q}
612
- initial={{ opacity: 0, y: 10 }}
613
- animate={{ opacity: 1, y: 0 }}
614
- transition={{ delay: 0.1 * i + 0.3 }}
615
- onClick={() => {
616
- setQuestion(q);
617
- setTimeout(
618
- () => handleSearch(),
619
- 50,
620
- );
621
- }}
622
- className="chip"
623
- >
624
- {q}
625
- </motion.button>
626
- ))}
627
- </motion.div>
628
- )}
629
- </AnimatePresence>
630
- </motion.div>
631
-
632
- {/* ── Error State ── */}
633
- <AnimatePresence>
634
- {error && (
635
- <motion.div
636
- initial={{ opacity: 0, y: 10 }}
637
- animate={{ opacity: 1, y: 0 }}
638
- className="cyber-panel"
639
- style={{
640
- width: "100%",
641
- padding: "24px",
642
- borderColor: "var(--danger)",
643
- marginTop: "24px",
644
- }}
645
- >
646
- <div
647
- style={{
648
- display: "flex",
649
- gap: "16px",
650
- alignItems: "center",
651
- }}
652
- >
653
- <AlertCircle size={32} color="var(--danger)" />
654
- <div>
655
- <h3
656
- style={{
657
- color: "var(--danger)",
658
- fontSize: "1.2rem",
659
- marginBottom: "4px",
660
- }}
661
- >
662
- Critical Exception
663
- </h3>
664
- <p style={{ color: "var(--text-muted)" }}>
665
- {error}
666
- </p>
667
- </div>
668
- </div>
669
- </motion.div>
670
- )}
671
- </AnimatePresence>
672
-
673
- {/* ── Loading View ── */}
674
- <AnimatePresence>
675
- {loading && (
676
- <motion.div
677
- initial={{ opacity: 0 }}
678
- animate={{ opacity: 1 }}
679
- exit={{ opacity: 0 }}
680
- className="loader-view"
681
- style={{ width: "100%" }}
682
- >
683
- <div className="ring-spinner">
684
- <div className="ring ring-1" />
685
- <div className="ring ring-2" />
686
- <Brain
687
- size={22}
688
- style={{
689
- position: "absolute",
690
- top: "24px",
691
- left: "24px",
692
- color: "var(--text-main)",
693
- }}
694
- />
695
- </div>
696
- <div>
697
- <h2
698
- style={{
699
- fontSize: "1.4rem",
700
- fontWeight: 600,
701
- color: "#fff",
702
- marginBottom: "8px",
703
- }}
704
- >
705
- Synthesizing Knowledge
706
- </h2>
707
- <p
708
- style={{
709
- color: "var(--text-muted)",
710
- fontSize: "0.95rem",
711
- }}
712
- >
713
- Running Vector Search & LLM Inference
714
- </p>
715
- </div>
716
- </motion.div>
717
- )}
718
- </AnimatePresence>
719
-
720
- {/* ── Results Output ── */}
721
- {result && !loading && (
722
- <motion.div
723
- initial={{ opacity: 0, y: 20 }}
724
- animate={{ opacity: 1, y: 0 }}
725
- transition={{ staggerChildren: 0.1 }}
726
- className="results-area"
727
- >
728
- <motion.div
729
- className="cyber-panel answer-box"
730
- initial={{ opacity: 0, y: 20 }}
731
- animate={{ opacity: 1, y: 0 }}
732
- >
733
- <div className="answer-header">
734
- <div className="answer-label">
735
- <Sparkles size={20} /> AI Synthesis
736
- </div>
737
- {result.has_context ? (
738
- <div className="badge-grounded">
739
- <CheckCircle size={14} /> Grounded
740
- Sources
741
- </div>
742
- ) : (
743
- <div
744
- className="badge-grounded"
745
- style={{
746
- color: "var(--danger)",
747
- borderColor:
748
- "rgba(239, 68, 68, 0.3)",
749
- background:
750
- "rgba(239, 68, 68, 0.1)",
751
- }}
752
- >
753
- <AlertCircle size={14} /> Hallucination
754
- Risk
755
- </div>
756
- )}
757
- </div>
758
- <div className="answer-text">{renderLaTeX(result.answer)}</div>
759
- </motion.div>
760
-
761
- <motion.div
762
- className="stats-grid"
763
- initial={{ opacity: 0, y: 20 }}
764
- animate={{ opacity: 1, y: 0 }}
765
- >
766
- {[
767
- {
768
- l: "Execution Time",
769
- v: `${(result.total_time_ms / 1000).toFixed(1)}s`,
770
- i: Clock,
771
- c: "var(--accent)",
772
- },
773
- {
774
- l: "Vector Data",
775
- v: `${(result.retrieval_time_ms / 1000).toFixed(1)}s`,
776
- i: ArrowRight,
777
- c: "#fff",
778
- },
779
- {
780
- l: "LLM Generation",
781
- v: `${(result.generation_time_ms / 1000).toFixed(1)}s`,
782
- i: Zap,
783
- c: "var(--accent-2)",
784
- },
785
- {
786
- l: "Paper Chunks",
787
- v: result.chunks_used,
788
- i: Fingerprint,
789
- c: "var(--success)",
790
- },
791
- ].map((s, i) => (
792
- <div key={i} className="cyber-panel stat-card">
793
- <div
794
- className="stat-header"
795
- style={{ width: "100%" }}
796
- >
797
- <span className="stat-label">
798
- {s.l}
799
- </span>
800
- <s.i
801
- size={16}
802
- color={s.c}
803
- style={{ opacity: 0.8 }}
804
- />
805
- </div>
806
- <div
807
- className="stat-value"
808
- style={{
809
- width: "100%",
810
- textAlign: "left",
811
- color: s.c,
812
- }}
813
- >
814
- {s.v}
815
- </div>
816
- </div>
817
- ))}
818
- </motion.div>
819
-
820
- {result.citations.length > 0 && (
821
- <motion.div
822
- initial={{ opacity: 0, y: 20 }}
823
- animate={{ opacity: 1, y: 0 }}
824
- >
825
- <div className="section-title">
826
- <BookOpen
827
- size={18}
828
- color="var(--accent-2)"
829
- />
830
- Extracted Literature
831
- </div>
832
- <div className="citations-grid">
833
- {result.citations.map((cite, i) => (
834
- <a
835
- href={cite.arxiv_url}
836
- target="_blank"
837
- rel="noopener noreferrer"
838
- key={i}
839
- className="cyber-panel citation-card"
840
- >
841
- <div className="citation-open">
842
- <ExternalLink size={16} />
843
- </div>
844
- <div className="citation-meta">
845
- <span className="citation-id">
846
- {cite.paper_id}
847
- </span>
848
- <span className="citation-date">
849
- {cite.published_date}
850
- </span>
851
- </div>
852
- <h4 className="citation-title">
853
- {cite.title}
854
- </h4>
855
- <div className="citation-authors">
856
- {cite.authors
857
- .slice(0, 3)
858
- .join(", ")}
859
- {cite.authors.length > 3 &&
860
- ` +${cite.authors.length - 3} more`}
861
- </div>
862
- </a>
863
- ))}
864
- </div>
865
- </motion.div>
866
- )}
867
- </motion.div>
868
- )}
869
- </main>
870
- </>
871
  );
872
  }
 
1
+ 'use client';
2
 
3
  import { useState, useRef, useEffect } from "react";
4
  import { motion, AnimatePresence } from "framer-motion";
5
  import { InlineMath, BlockMath } from 'react-katex';
6
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
7
+ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
8
  import {
9
+ Brain, Search, PanelLeftClose, PanelLeft, Plus,
10
+ Send, Settings2, Trash2, Copy, Check, Star, ThumbsUp, ThumbsDown
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  } from "lucide-react";
12
 
13
+ // Config
14
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
15
+
16
+ // --- Types ---
17
  interface Citation {
18
  paper_id: string;
19
  title: string;
 
22
  arxiv_url: string;
23
  }
24
 
25
+ interface MessageTiming {
26
+ retrieval_time_ms?: number;
27
+ generation_time_ms?: number;
28
+ total_time_ms?: number;
29
+ chunks_used?: number;
 
 
 
 
30
  }
31
 
32
+ interface ChatMessage {
33
+ id: string;
34
+ role: "user" | "assistant";
35
+ content: string;
36
+ citations?: Citation[];
37
+ timing?: MessageTiming;
38
+ model_used?: string;
39
+ timestamp: number;
40
+ }
41
 
42
+ interface ChatSession {
43
+ id: string;
44
+ title: string;
45
+ messages: ChatMessage[];
46
+ timestamp: number;
47
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ // --- Message Renderer Component ---
50
+ const CodeBlock = ({ language, code }: { language: string, code: string }) => {
51
+ const [copied, setCopied] = useState(false);
52
+ const handleCopy = () => {
53
+ navigator.clipboard.writeText(code);
54
+ setCopied(true);
55
+ setTimeout(() => setCopied(false), 2000);
56
+ };
57
+ return (
58
+ <div className="code-wrapper">
59
+ <div className="code-header">
60
+ <span>{language || 'text'}</span>
61
+ <button onClick={handleCopy} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
62
+ {copied ? <><Check size={14} /> Copied!</> : <><Copy size={14} /> Copy</>}
63
+ </button>
64
+ </div>
65
+ <SyntaxHighlighter language={language || 'text'} style={oneDark} customStyle={{ margin: 0, borderTopLeftRadius: 0, borderTopRightRadius: 0 }}>
66
+ {code}
67
+ </SyntaxHighlighter>
68
+ </div>
69
+ );
70
+ };
71
 
72
+ const CitationBadge = ({ id }: { id: string }) => {
73
+ return (
74
+ <a
75
+ href={`https://arxiv.org/abs/${id}`}
76
+ target="_blank"
77
+ rel="noopener noreferrer"
78
+ className="citation-badge"
79
+ title={`View paper ${id} on ArXiv`}
80
+ >
81
+ {id}
82
+ </a>
83
+ );
84
+ };
85
+
86
+ // Safe rendering to avoid KaTeX crashing
87
+ const SafeMathBlock = ({ math }: { math: string }) => {
88
+ try { return <BlockMath math={math} />; } catch (e) { return <pre>{`$$${math}$$`}</pre>; }
89
+ };
90
+ const SafeMathInline = ({ math }: { math: string }) => {
91
+ try { return <InlineMath math={math} />; } catch (e) { return <span>{`$${math}$`}</span>; }
92
+ };
93
+
94
+ const renderTextWithMathAndCitations = (text: string) => {
95
+ // 1. Block Math
96
+ const blockSplit = text.split(/(\$\$[\s\S]+?\$\$)/g);
97
+ return blockSplit.map((bPart, i) => {
98
+ if (bPart.startsWith("$$") && bPart.endsWith("$$")) {
99
+ return <SafeMathBlock key={i} math={bPart.slice(2, -2)} />;
100
  }
101
+
102
+ // 2. Inline Math
103
+ const inlineSplit = bPart.split(/(\$[^\$\n]+?\$)/g);
104
+ return inlineSplit.map((iPart, j) => {
105
+ if (iPart.startsWith("$") && iPart.endsWith("$")) {
106
+ return <SafeMathInline key={`${i}-${j}`} math={iPart.slice(1, -1)} />;
107
+ }
108
 
109
+ // 3. Citations
110
+ const citeSplit = iPart.split(/\[(\d{4}\.\d{4,5})\]/g);
111
+ return citeSplit.map((cPart, k) => {
112
+ if (/^\d{4}\.\d{4,5}$/.test(cPart)) {
113
+ return <CitationBadge key={`${i}-${j}-${k}`} id={cPart} />;
114
+ }
115
+ return <span key={`${i}-${j}-${k}`} style={{ whiteSpace: "pre-wrap" }}>{cPart}</span>;
116
+ });
117
+ });
118
+ });
119
+ };
120
 
121
+ const MessageRenderer = ({ content }: { content: string }) => {
122
+ // Split by code blocks first
123
+ const parts = content.split(/(```[\s\S]*?```)/g);
124
  return (
125
+ <div style={{ lineHeight: 1.6 }}>
126
+ {parts.map((part, i) => {
127
+ if (part.startsWith('```') && part.endsWith('```')) {
128
+ const match = part.match(/```(\w+)?\n([\s\S]*?)```/);
129
+ if (match) {
130
+ return <CodeBlock key={i} language={match[1]} code={match[2]} />;
131
+ }
132
+ }
133
+ return <div key={i}>{renderTextWithMathAndCitations(part)}</div>;
134
+ })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </div>
136
  );
137
+ };
138
 
139
+ // --- Feedback Component ---
140
+ const FeedbackRow = ({ query, time, citationsCount, model }: { query: string, time: number, citationsCount: number, model: string }) => {
141
+ const [rating, setRating] = useState(0);
142
+ const [hoverRating, setHoverRating] = useState(0);
143
+ const [thumbs, setThumbs] = useState<"up"|"down"|null>(null);
144
+ const [comment, setComment] = useState("");
145
+ const [submitted, setSubmitted] = useState(false);
146
+
147
+ const handleSubmit = async () => {
148
+ try {
149
+ await fetch(`${API_URL}/feedback`, {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/json" },
152
+ body: JSON.stringify({ query, rating, thumbs, comment, model_used: model, citations_count: citationsCount, total_time_ms: time })
153
+ });
154
+ } catch (e) {
155
+ console.error(e);
156
  }
157
+ setSubmitted(true);
158
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
+ if (submitted) return <div style={{ fontSize: "0.85rem", color: "var(--success)", marginTop: "12px" }}><Check size={14} style={{ display: 'inline', marginRight: '4px' }} />Thank you for your feedback!</div>;
 
 
 
 
 
161
 
162
+ return (
163
+ <div className="feedback-row">
164
+ <div style={{ display: "flex", gap: "4px" }}>
165
+ {[1,2,3,4,5].map(star => (
166
+ <Star
167
+ key={star}
168
+ size={18}
169
+ className={`star-btn ${(hoverRating || rating) >= star ? 'active' : ''}`}
170
+ onMouseEnter={() => setHoverRating(star)}
171
+ onMouseLeave={() => setHoverRating(0)}
172
+ onClick={() => setRating(star)}
173
+ fill={(hoverRating || rating) >= star ? "#f59e0b" : "transparent"}
174
+ />
175
+ ))}
176
+ </div>
177
+ <div style={{ display: "flex", gap: "8px", borderLeft: "1px solid rgba(255,255,255,0.1)", paddingLeft: "12px" }}>
178
+ <ThumbsUp size={18} className={`star-btn ${thumbs === 'up' ? 'active' : ''}`} onClick={() => setThumbs('up')} />
179
+ <ThumbsDown size={18} className={`star-btn ${thumbs === 'down' ? 'active' : ''}`} onClick={() => setThumbs('down')} />
180
+ </div>
181
+ {(rating > 0 || thumbs) && (
182
+ <>
183
+ <input
184
+ type="text"
185
+ className="feedback-input"
186
+ placeholder="Tell us more (optional)"
187
+ value={comment}
188
+ onChange={(e) => setComment(e.target.value)}
189
+ />
190
+ <button className="feedback-submit" onClick={handleSubmit}>Submit</button>
191
+ </>
192
+ )}
193
+ </div>
194
+ );
195
+ };
196
+
197
+ export default function App() {
198
+ const [sessions, setSessions] = useState<ChatSession[]>([]);
199
+ const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
200
+ const [query, setQuery] = useState("");
201
+ const [sidebarOpen, setSidebarOpen] = useState(false);
202
+ const [isStreaming, setIsStreaming] = useState(false);
203
+ const [settingsOpen, setSettingsOpen] = useState(false);
204
  const [topK, setTopK] = useState(5);
205
  const [category, setCategory] = useState("All");
 
 
 
 
 
 
 
 
 
 
206
 
207
+ const chatEndRef = useRef<HTMLDivElement>(null);
208
  const textareaRef = useRef<HTMLTextAreaElement>(null);
209
 
210
+ // Load from local storage
211
  useEffect(() => {
212
+ const stored = localStorage.getItem("rp_history");
213
+ if (stored) {
214
+ setSessions(JSON.parse(stored));
215
  }
216
+ }, []);
217
 
218
+ // Save to local storage
219
  useEffect(() => {
220
+ if (sessions.length > 0) {
221
+ localStorage.setItem("rp_history", JSON.stringify(sessions));
222
+ }
223
+ }, [sessions]);
224
 
225
+ // Auto resize textarea
226
+ useEffect(() => {
227
+ if (textareaRef.current) {
228
+ textareaRef.current.style.height = "auto";
229
+ textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
 
 
 
230
  }
231
+ }, [query]);
232
+
233
+ // Scroll to bottom
234
+ const scrollToBottom = () => {
235
+ chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
236
  };
237
 
238
+ useEffect(() => {
239
+ scrollToBottom();
240
+ }, [sessions, activeSessionId, isStreaming]);
241
+
242
+ const activeSession = sessions.find(s => s.id === activeSessionId);
243
+ const currentMessages = activeSession?.messages || [];
244
 
245
+ const handleNewChat = () => {
246
+ setActiveSessionId(null);
247
+ setSidebarOpen(false);
248
+ };
249
+
250
+ const handleSend = async () => {
251
+ if (!query.trim() || isStreaming) return;
252
+
253
+ let sessionId = activeSessionId;
254
+ if (!sessionId) {
255
+ sessionId = Date.now().toString();
256
+ const newSession: ChatSession = {
257
+ id: sessionId,
258
+ title: query.trim().substring(0, 50) + (query.length > 50 ? "..." : ""),
259
+ messages: [],
260
+ timestamp: Date.now()
261
  };
262
+ setSessions(prev => [newSession, ...prev]);
263
+ setActiveSessionId(sessionId);
264
+ }
265
+
266
+ const userMessage: ChatMessage = {
267
+ id: Date.now().toString(),
268
+ role: "user",
269
+ content: query.trim(),
270
+ timestamp: Date.now()
271
+ };
272
+
273
+ const aiMessageId = (Date.now() + 1).toString();
274
+ const placeholderAiMessage: ChatMessage = {
275
+ id: aiMessageId,
276
+ role: "assistant",
277
+ content: "",
278
+ timestamp: Date.now() + 1
279
+ };
280
+
281
+ setSessions(prev => prev.map(s => {
282
+ if (s.id === sessionId) {
283
+ return { ...s, messages: [...s.messages, userMessage, placeholderAiMessage] };
284
+ }
285
+ return s;
286
+ }));
287
+
288
+ const originalQuery = query;
289
+ setQuery("");
290
+ setIsStreaming(true);
291
 
292
+ try {
293
+ const res = await fetch(`${API_URL}/query/stream`, {
294
  method: "POST",
295
  headers: { "Content-Type": "application/json" },
296
+ body: JSON.stringify({
297
+ question: originalQuery,
298
+ top_k: topK,
299
+ filter_category: category === "All" ? undefined : category
300
+ })
301
  });
302
 
303
+ if (!res.ok || !res.body) throw new Error("Stream failed");
304
+
305
+ const reader = res.body.getReader();
306
+ const decoder = new TextDecoder();
307
+
308
+ let accumulatedContent = "";
309
+
310
+ while (true) {
311
+ const { done, value } = await reader.read();
312
+ if (done) break;
313
+
314
+ const chunk = decoder.decode(value, { stream: true });
315
+ const lines = chunk.split("\n");
316
+
317
+ for (const line of lines) {
318
+ if (line.startsWith("data: ")) {
319
+ try {
320
+ const data = JSON.parse(line.slice(6));
321
+ if (data.done) {
322
+ // Final update with citations + timing
323
+ setSessions(prev => prev.map(s => {
324
+ if (s.id === sessionId) {
325
+ return {
326
+ ...s,
327
+ messages: s.messages.map(m =>
328
+ m.id === aiMessageId
329
+ ? { ...m, citations: data.citations, timing: data.timing, model_used: data.model_used }
330
+ : m
331
+ )
332
+ };
333
+ }
334
+ return s;
335
+ }));
336
+ } else if (data.token) {
337
+ accumulatedContent += data.token;
338
+ // Update streaming content
339
+ setSessions(prev => prev.map(s => {
340
+ if (s.id === sessionId) {
341
+ return {
342
+ ...s,
343
+ messages: s.messages.map(m =>
344
+ m.id === aiMessageId
345
+ ? { ...m, content: accumulatedContent }
346
+ : m
347
+ )
348
+ };
349
+ }
350
+ return s;
351
+ }));
352
+ // scrollToBottom immediately inside the stream
353
+ chatEndRef.current?.scrollIntoView();
354
+ }
355
+ } catch (e) {
356
+ console.error("Parse error:", e);
357
+ }
358
+ }
359
+ }
360
+ }
361
+ } catch (error) {
362
+ console.error(error);
363
+ setSessions(prev => prev.map(s => {
364
+ if (s.id === sessionId) {
365
+ return {
366
+ ...s,
367
+ messages: s.messages.map(m =>
368
+ m.id === aiMessageId
369
+ ? { ...m, content: "Error: Could not connect to API or request failed." }
370
+ : m
371
+ )
372
+ };
373
+ }
374
+ return s;
375
+ }));
376
  } finally {
377
+ setIsStreaming(false);
378
  }
379
  };
380
 
 
 
 
 
 
 
 
 
 
381
  return (
382
+ <div className="layout-wrapper" style={{ background: "var(--bg)", color: "var(--text-main)" }}>
383
+ {/* Mobile Header Menu */}
384
+ <div className="hamburger" onClick={() => setSidebarOpen(true)}>
385
+ <PanelLeft size={24} color="#fff" />
386
+ </div>
387
+
388
+ {/* Sidebar */}
389
+ <div className={`sidebar ${sidebarOpen ? 'open' : ''}`}>
390
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '24px' }}>
391
+ <div className="brand" style={{ fontSize: '1.1rem', gap: '8px' }}>
392
+ <Brain size={20} color="var(--accent)" /> ResearchPilot
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  </div>
394
+ {sidebarOpen && (
395
+ <PanelLeftClose size={20} color="#fff" onClick={() => setSidebarOpen(false)} style={{ cursor: 'pointer' }} />
396
+ )}
397
+ </div>
398
+
399
+ <button className="new-chat-btn" onClick={handleNewChat}>
400
+ <Plus size={16} /> New Chat
401
+ </button>
402
+
403
+ <div style={{ flex: 1, overflowY: 'auto' }}>
404
+ <div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '12px', textTransform: 'uppercase' }}>Recent</div>
405
+ {sessions.map(s => (
406
+ <div
407
+ key={s.id}
408
+ className={`history-item ${activeSessionId === s.id ? 'active' : ''}`}
409
+ onClick={() => { setActiveSessionId(s.id); setSidebarOpen(false); }}
410
+ >
411
+ {s.title}
412
  </div>
413
+ ))}
414
+ </div>
415
+ </div>
416
+
417
+ {/* Overlay for mobile sidebar */}
418
+ {sidebarOpen && <div className="sidebar-overlay" onClick={() => setSidebarOpen(false)} />}
419
+
420
+ {/* Main Area */}
421
+ <div className="main-chat-area">
422
+ <div className="chat-container">
423
+ {currentMessages.length === 0 ? (
424
+ <div style={{ margin: 'auto', textAlign: 'center', opacity: 0.5, maxWidth: '400px' }}>
425
+ <Brain size={48} style={{ margin: '0 auto 16px auto', display: 'block' }} />
426
+ <h2>How can I help you with ML research?</h2>
427
+ </div>
428
+ ) : (
429
+ currentMessages.map((msg, i) => (
430
+ <div key={msg.id} className={msg.role === 'user' ? 'message-user' : 'message-ai'}>
431
+ {msg.role === 'user' ? (
432
+ <div style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</div>
433
+ ) : (
434
+ <div style={{ width: '100%' }}>
435
+ {/* Name header for AI */}
436
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px', fontSize: '0.85rem', color: 'var(--accent)', fontWeight: 600 }}>
437
+ <Brain size={16} /> ResearchPilot {msg.model_used && <span style={{fontSize: '0.7rem', color: '#666', background: '#222', padding: '2px 6px', borderRadius: '4px'}}>{msg.model_used}</span>}
438
+ </div>
439
 
440
+ { /* Stream logic vs Final logic */
441
+ isStreaming && i === currentMessages.length - 1 ? (
442
+ <div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
443
+ {msg.content}
444
+ <span className="blinking-cursor"></span>
445
+ </div>
446
+ ) : (
447
+ <>
448
+ <MessageRenderer content={msg.content} />
449
+ {/* Citations section if present */}
450
+ {msg.citations && msg.citations.length > 0 && (
451
+ <div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
452
+ <div style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '8px' }}>SOURCES</div>
453
+ <div className="citations-grid">
454
+ {msg.citations.map(c => (
455
+ <div key={c.paper_id} className="citation-card">
456
+ <div className="citation-meta">
457
+ <span className="citation-id">{c.paper_id}</span>
458
+ </div>
459
+ <div className="citation-title">{c.title}</div>
460
+ <a href={c.arxiv_url} target="_blank" rel="noopener noreferrer" className="citation-open" title="Open ArXiv PDF">
461
+ <Search size={16} />
462
+ </a>
463
+ </div>
464
+ ))}
465
+ </div>
466
+ </div>
467
+ )}
468
+ {/* Feedback Row */}
469
+ {!isStreaming && msg.role === 'assistant' && msg.content && (
470
+ <FeedbackRow
471
+ query={currentMessages[i-1]?.content || ""}
472
+ time={msg.timing?.total_time_ms || 0}
473
+ citationsCount={msg.citations?.length || 0}
474
+ model={msg.model_used || "unknown"}
475
+ />
476
+ )}
477
+ </>
478
+ )
479
+ }
480
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  )}
482
+ </div>
483
+ ))
484
+ )}
485
+ <div ref={chatEndRef} style={{ height: "1px" }} />
486
+ </div>
487
+
488
+ {/* Bottom Input Area */}
489
+ <div className="bottom-input-bar">
490
+ <div className="bottom-input-bar-inner">
491
+ {/* Settings Popup inline */}
492
+ <AnimatePresence>
493
+ {settingsOpen && (
494
+ <motion.div
495
+ initial={{ opacity: 0, y: 10 }}
496
+ animate={{ opacity: 1, y: 0 }}
497
+ exit={{ opacity: 0, y: 10 }}
498
+ style={{ background: 'rgba(20,25,35,0.95)', border: '1px solid rgba(255,255,255,0.1)', padding: '12px', borderRadius: '12px', display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '8px' }}
499
+ >
500
+ <select style={{ background: '#000', border: '1px solid #333', color: '#fff', padding: '6px 12px', borderRadius: '6px' }} value={topK} onChange={(e) => setTopK(Number(e.target.value))}>
501
+ <option value={3}>Top 3</option>
502
+ <option value={5}>Top 5</option>
503
+ <option value={10}>Top 10</option>
504
+ </select>
505
+ <select style={{ background: '#000', border: '1px solid #333', color: '#fff', padding: '6px 12px', borderRadius: '6px' }} value={category} onChange={(e) => setCategory(e.target.value)}>
506
+ <option value="All">All Topics</option>
507
+ <option value="cs.LG">cs.LG</option>
508
+ <option value="cs.AI">cs.AI</option>
509
+ </select>
510
+ </motion.div>
511
+ )}
512
+ </AnimatePresence>
513
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  <div className="chat-input-wrapper">
515
+ <button onClick={() => setSettingsOpen(!settingsOpen)} style={{ color: settingsOpen ? 'var(--accent)' : 'var(--text-muted)' }}>
516
+ <Settings2 size={20} />
517
+ </button>
518
+ <textarea
519
  ref={textareaRef}
520
+ value={query}
521
+ onChange={(e) => setQuery(e.target.value)}
 
 
 
 
 
 
 
 
522
  onKeyDown={(e) => {
523
+ if (e.key === 'Enter' && !e.shiftKey) {
524
  e.preventDefault();
525
+ handleSend();
526
  }
527
  }}
528
+ placeholder="Message ResearchPilot..."
529
+ className="chat-input"
530
+ rows={1}
531
  />
532
+ <button onClick={handleSend} disabled={!query.trim() || isStreaming} className="send-btn">
533
+ <Send size={18} />
 
 
 
 
 
 
 
 
 
 
 
 
534
  </button>
535
  </div>
536
+ </div>
537
+ </div>
538
+ </div>
539
+
540
+ <div className="luminous-grid" />
541
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  );
543
  }
frontend-next/package-lock.json CHANGED
@@ -16,6 +16,7 @@
16
  "react": "19.2.4",
17
  "react-dom": "19.2.4",
18
  "react-katex": "^3.1.0",
 
19
  "tailwind-merge": "^3.5.0"
20
  },
21
  "devDependencies": {
@@ -25,6 +26,7 @@
25
  "@types/react": "^19",
26
  "@types/react-dom": "^19",
27
  "@types/react-katex": "^3.0.4",
 
28
  "eslint": "^9",
29
  "eslint-config-next": "16.2.2",
30
  "tailwindcss": "^4",
@@ -236,6 +238,15 @@
236
  "node": ">=6.0.0"
237
  }
238
  },
 
 
 
 
 
 
 
 
 
239
  "node_modules/@babel/template": {
240
  "version": "7.28.6",
241
  "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1611,6 +1622,15 @@
1611
  "dev": true,
1612
  "license": "MIT"
1613
  },
 
 
 
 
 
 
 
 
 
1614
  "node_modules/@types/json-schema": {
1615
  "version": "7.0.15",
1616
  "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1642,6 +1662,12 @@
1642
  "undici-types": "~6.21.0"
1643
  }
1644
  },
 
 
 
 
 
 
1645
  "node_modules/@types/react": {
1646
  "version": "19.2.14",
1647
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -1672,6 +1698,22 @@
1672
  "@types/react": "*"
1673
  }
1674
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1675
  "node_modules/@typescript-eslint/eslint-plugin": {
1676
  "version": "8.58.0",
1677
  "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
@@ -2720,6 +2762,36 @@
2720
  "url": "https://github.com/chalk/chalk?sponsor=1"
2721
  }
2722
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2723
  "node_modules/client-only": {
2724
  "version": "0.0.1",
2725
  "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2755,6 +2827,16 @@
2755
  "dev": true,
2756
  "license": "MIT"
2757
  },
 
 
 
 
 
 
 
 
 
 
2758
  "node_modules/commander": {
2759
  "version": "8.3.0",
2760
  "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
@@ -2879,6 +2961,19 @@
2879
  }
2880
  }
2881
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
2882
  "node_modules/deep-is": {
2883
  "version": "0.1.4",
2884
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3656,6 +3751,19 @@
3656
  "reusify": "^1.0.4"
3657
  }
3658
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
3659
  "node_modules/file-entry-cache": {
3660
  "version": "8.0.0",
3661
  "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3736,6 +3844,14 @@
3736
  "url": "https://github.com/sponsors/ljharb"
3737
  }
3738
  },
 
 
 
 
 
 
 
 
3739
  "node_modules/framer-motion": {
3740
  "version": "12.38.0",
3741
  "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
@@ -4051,6 +4167,36 @@
4051
  "node": ">= 0.4"
4052
  }
4053
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4054
  "node_modules/hermes-estree": {
4055
  "version": "0.25.1",
4056
  "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -4068,6 +4214,21 @@
4068
  "hermes-estree": "0.25.1"
4069
  }
4070
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4071
  "node_modules/ignore": {
4072
  "version": "5.3.2",
4073
  "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4120,6 +4281,30 @@
4120
  "node": ">= 0.4"
4121
  }
4122
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4123
  "node_modules/is-array-buffer": {
4124
  "version": "3.0.5",
4125
  "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4278,6 +4463,16 @@
4278
  "url": "https://github.com/sponsors/ljharb"
4279
  }
4280
  },
 
 
 
 
 
 
 
 
 
 
4281
  "node_modules/is-extglob": {
4282
  "version": "2.1.1",
4283
  "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -4337,6 +4532,16 @@
4337
  "node": ">=0.10.0"
4338
  }
4339
  },
 
 
 
 
 
 
 
 
 
 
4340
  "node_modules/is-map": {
4341
  "version": "2.0.3",
4342
  "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -5027,6 +5232,20 @@
5027
  "loose-envify": "cli.js"
5028
  }
5029
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5030
  "node_modules/lru-cache": {
5031
  "version": "5.1.1",
5032
  "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5486,6 +5705,31 @@
5486
  "node": ">=6"
5487
  }
5488
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5489
  "node_modules/path-exists": {
5490
  "version": "4.0.0",
5491
  "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5581,6 +5825,15 @@
5581
  "node": ">= 0.8.0"
5582
  }
5583
  },
 
 
 
 
 
 
 
 
 
5584
  "node_modules/prop-types": {
5585
  "version": "15.8.1",
5586
  "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5592,6 +5845,16 @@
5592
  "react-is": "^16.13.1"
5593
  }
5594
  },
 
 
 
 
 
 
 
 
 
 
5595
  "node_modules/punycode": {
5596
  "version": "2.3.1",
5597
  "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5663,6 +5926,26 @@
5663
  "react": ">=15.3.2 <20"
5664
  }
5665
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5666
  "node_modules/reflect.getprototypeof": {
5667
  "version": "1.0.10",
5668
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5686,6 +5969,22 @@
5686
  "url": "https://github.com/sponsors/ljharb"
5687
  }
5688
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5689
  "node_modules/regexp.prototype.flags": {
5690
  "version": "1.5.4",
5691
  "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -6072,6 +6371,16 @@
6072
  "node": ">=0.10.0"
6073
  }
6074
  },
 
 
 
 
 
 
 
 
 
 
6075
  "node_modules/stable-hash": {
6076
  "version": "0.0.5",
6077
  "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
 
16
  "react": "19.2.4",
17
  "react-dom": "19.2.4",
18
  "react-katex": "^3.1.0",
19
+ "react-syntax-highlighter": "^16.1.1",
20
  "tailwind-merge": "^3.5.0"
21
  },
22
  "devDependencies": {
 
26
  "@types/react": "^19",
27
  "@types/react-dom": "^19",
28
  "@types/react-katex": "^3.0.4",
29
+ "@types/react-syntax-highlighter": "^15.5.13",
30
  "eslint": "^9",
31
  "eslint-config-next": "16.2.2",
32
  "tailwindcss": "^4",
 
238
  "node": ">=6.0.0"
239
  }
240
  },
241
+ "node_modules/@babel/runtime": {
242
+ "version": "7.29.2",
243
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
244
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
245
+ "license": "MIT",
246
+ "engines": {
247
+ "node": ">=6.9.0"
248
+ }
249
+ },
250
  "node_modules/@babel/template": {
251
  "version": "7.28.6",
252
  "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
 
1622
  "dev": true,
1623
  "license": "MIT"
1624
  },
1625
+ "node_modules/@types/hast": {
1626
+ "version": "3.0.4",
1627
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
1628
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
1629
+ "license": "MIT",
1630
+ "dependencies": {
1631
+ "@types/unist": "*"
1632
+ }
1633
+ },
1634
  "node_modules/@types/json-schema": {
1635
  "version": "7.0.15",
1636
  "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
 
1662
  "undici-types": "~6.21.0"
1663
  }
1664
  },
1665
+ "node_modules/@types/prismjs": {
1666
+ "version": "1.26.6",
1667
+ "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
1668
+ "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
1669
+ "license": "MIT"
1670
+ },
1671
  "node_modules/@types/react": {
1672
  "version": "19.2.14",
1673
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
 
1698
  "@types/react": "*"
1699
  }
1700
  },
1701
+ "node_modules/@types/react-syntax-highlighter": {
1702
+ "version": "15.5.13",
1703
+ "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
1704
+ "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
1705
+ "dev": true,
1706
+ "license": "MIT",
1707
+ "dependencies": {
1708
+ "@types/react": "*"
1709
+ }
1710
+ },
1711
+ "node_modules/@types/unist": {
1712
+ "version": "3.0.3",
1713
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
1714
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
1715
+ "license": "MIT"
1716
+ },
1717
  "node_modules/@typescript-eslint/eslint-plugin": {
1718
  "version": "8.58.0",
1719
  "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
 
2762
  "url": "https://github.com/chalk/chalk?sponsor=1"
2763
  }
2764
  },
2765
+ "node_modules/character-entities": {
2766
+ "version": "2.0.2",
2767
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
2768
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
2769
+ "license": "MIT",
2770
+ "funding": {
2771
+ "type": "github",
2772
+ "url": "https://github.com/sponsors/wooorm"
2773
+ }
2774
+ },
2775
+ "node_modules/character-entities-legacy": {
2776
+ "version": "3.0.0",
2777
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
2778
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
2779
+ "license": "MIT",
2780
+ "funding": {
2781
+ "type": "github",
2782
+ "url": "https://github.com/sponsors/wooorm"
2783
+ }
2784
+ },
2785
+ "node_modules/character-reference-invalid": {
2786
+ "version": "2.0.1",
2787
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
2788
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
2789
+ "license": "MIT",
2790
+ "funding": {
2791
+ "type": "github",
2792
+ "url": "https://github.com/sponsors/wooorm"
2793
+ }
2794
+ },
2795
  "node_modules/client-only": {
2796
  "version": "0.0.1",
2797
  "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
 
2827
  "dev": true,
2828
  "license": "MIT"
2829
  },
2830
+ "node_modules/comma-separated-tokens": {
2831
+ "version": "2.0.3",
2832
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
2833
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
2834
+ "license": "MIT",
2835
+ "funding": {
2836
+ "type": "github",
2837
+ "url": "https://github.com/sponsors/wooorm"
2838
+ }
2839
+ },
2840
  "node_modules/commander": {
2841
  "version": "8.3.0",
2842
  "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
 
2961
  }
2962
  }
2963
  },
2964
+ "node_modules/decode-named-character-reference": {
2965
+ "version": "1.3.0",
2966
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
2967
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
2968
+ "license": "MIT",
2969
+ "dependencies": {
2970
+ "character-entities": "^2.0.0"
2971
+ },
2972
+ "funding": {
2973
+ "type": "github",
2974
+ "url": "https://github.com/sponsors/wooorm"
2975
+ }
2976
+ },
2977
  "node_modules/deep-is": {
2978
  "version": "0.1.4",
2979
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
 
3751
  "reusify": "^1.0.4"
3752
  }
3753
  },
3754
+ "node_modules/fault": {
3755
+ "version": "1.0.4",
3756
+ "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
3757
+ "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
3758
+ "license": "MIT",
3759
+ "dependencies": {
3760
+ "format": "^0.2.0"
3761
+ },
3762
+ "funding": {
3763
+ "type": "github",
3764
+ "url": "https://github.com/sponsors/wooorm"
3765
+ }
3766
+ },
3767
  "node_modules/file-entry-cache": {
3768
  "version": "8.0.0",
3769
  "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
 
3844
  "url": "https://github.com/sponsors/ljharb"
3845
  }
3846
  },
3847
+ "node_modules/format": {
3848
+ "version": "0.2.2",
3849
+ "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
3850
+ "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
3851
+ "engines": {
3852
+ "node": ">=0.4.x"
3853
+ }
3854
+ },
3855
  "node_modules/framer-motion": {
3856
  "version": "12.38.0",
3857
  "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
 
4167
  "node": ">= 0.4"
4168
  }
4169
  },
4170
+ "node_modules/hast-util-parse-selector": {
4171
+ "version": "4.0.0",
4172
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
4173
+ "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
4174
+ "license": "MIT",
4175
+ "dependencies": {
4176
+ "@types/hast": "^3.0.0"
4177
+ },
4178
+ "funding": {
4179
+ "type": "opencollective",
4180
+ "url": "https://opencollective.com/unified"
4181
+ }
4182
+ },
4183
+ "node_modules/hastscript": {
4184
+ "version": "9.0.1",
4185
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
4186
+ "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
4187
+ "license": "MIT",
4188
+ "dependencies": {
4189
+ "@types/hast": "^3.0.0",
4190
+ "comma-separated-tokens": "^2.0.0",
4191
+ "hast-util-parse-selector": "^4.0.0",
4192
+ "property-information": "^7.0.0",
4193
+ "space-separated-tokens": "^2.0.0"
4194
+ },
4195
+ "funding": {
4196
+ "type": "opencollective",
4197
+ "url": "https://opencollective.com/unified"
4198
+ }
4199
+ },
4200
  "node_modules/hermes-estree": {
4201
  "version": "0.25.1",
4202
  "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
 
4214
  "hermes-estree": "0.25.1"
4215
  }
4216
  },
4217
+ "node_modules/highlight.js": {
4218
+ "version": "10.7.3",
4219
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
4220
+ "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
4221
+ "license": "BSD-3-Clause",
4222
+ "engines": {
4223
+ "node": "*"
4224
+ }
4225
+ },
4226
+ "node_modules/highlightjs-vue": {
4227
+ "version": "1.0.0",
4228
+ "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
4229
+ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
4230
+ "license": "CC0-1.0"
4231
+ },
4232
  "node_modules/ignore": {
4233
  "version": "5.3.2",
4234
  "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
 
4281
  "node": ">= 0.4"
4282
  }
4283
  },
4284
+ "node_modules/is-alphabetical": {
4285
+ "version": "2.0.1",
4286
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
4287
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
4288
+ "license": "MIT",
4289
+ "funding": {
4290
+ "type": "github",
4291
+ "url": "https://github.com/sponsors/wooorm"
4292
+ }
4293
+ },
4294
+ "node_modules/is-alphanumerical": {
4295
+ "version": "2.0.1",
4296
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
4297
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
4298
+ "license": "MIT",
4299
+ "dependencies": {
4300
+ "is-alphabetical": "^2.0.0",
4301
+ "is-decimal": "^2.0.0"
4302
+ },
4303
+ "funding": {
4304
+ "type": "github",
4305
+ "url": "https://github.com/sponsors/wooorm"
4306
+ }
4307
+ },
4308
  "node_modules/is-array-buffer": {
4309
  "version": "3.0.5",
4310
  "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
 
4463
  "url": "https://github.com/sponsors/ljharb"
4464
  }
4465
  },
4466
+ "node_modules/is-decimal": {
4467
+ "version": "2.0.1",
4468
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
4469
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
4470
+ "license": "MIT",
4471
+ "funding": {
4472
+ "type": "github",
4473
+ "url": "https://github.com/sponsors/wooorm"
4474
+ }
4475
+ },
4476
  "node_modules/is-extglob": {
4477
  "version": "2.1.1",
4478
  "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
 
4532
  "node": ">=0.10.0"
4533
  }
4534
  },
4535
+ "node_modules/is-hexadecimal": {
4536
+ "version": "2.0.1",
4537
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
4538
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
4539
+ "license": "MIT",
4540
+ "funding": {
4541
+ "type": "github",
4542
+ "url": "https://github.com/sponsors/wooorm"
4543
+ }
4544
+ },
4545
  "node_modules/is-map": {
4546
  "version": "2.0.3",
4547
  "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
 
5232
  "loose-envify": "cli.js"
5233
  }
5234
  },
5235
+ "node_modules/lowlight": {
5236
+ "version": "1.20.0",
5237
+ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
5238
+ "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
5239
+ "license": "MIT",
5240
+ "dependencies": {
5241
+ "fault": "^1.0.0",
5242
+ "highlight.js": "~10.7.0"
5243
+ },
5244
+ "funding": {
5245
+ "type": "github",
5246
+ "url": "https://github.com/sponsors/wooorm"
5247
+ }
5248
+ },
5249
  "node_modules/lru-cache": {
5250
  "version": "5.1.1",
5251
  "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
 
5705
  "node": ">=6"
5706
  }
5707
  },
5708
+ "node_modules/parse-entities": {
5709
+ "version": "4.0.2",
5710
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
5711
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
5712
+ "license": "MIT",
5713
+ "dependencies": {
5714
+ "@types/unist": "^2.0.0",
5715
+ "character-entities-legacy": "^3.0.0",
5716
+ "character-reference-invalid": "^2.0.0",
5717
+ "decode-named-character-reference": "^1.0.0",
5718
+ "is-alphanumerical": "^2.0.0",
5719
+ "is-decimal": "^2.0.0",
5720
+ "is-hexadecimal": "^2.0.0"
5721
+ },
5722
+ "funding": {
5723
+ "type": "github",
5724
+ "url": "https://github.com/sponsors/wooorm"
5725
+ }
5726
+ },
5727
+ "node_modules/parse-entities/node_modules/@types/unist": {
5728
+ "version": "2.0.11",
5729
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
5730
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
5731
+ "license": "MIT"
5732
+ },
5733
  "node_modules/path-exists": {
5734
  "version": "4.0.0",
5735
  "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
 
5825
  "node": ">= 0.8.0"
5826
  }
5827
  },
5828
+ "node_modules/prismjs": {
5829
+ "version": "1.30.0",
5830
+ "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
5831
+ "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
5832
+ "license": "MIT",
5833
+ "engines": {
5834
+ "node": ">=6"
5835
+ }
5836
+ },
5837
  "node_modules/prop-types": {
5838
  "version": "15.8.1",
5839
  "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
 
5845
  "react-is": "^16.13.1"
5846
  }
5847
  },
5848
+ "node_modules/property-information": {
5849
+ "version": "7.1.0",
5850
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
5851
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
5852
+ "license": "MIT",
5853
+ "funding": {
5854
+ "type": "github",
5855
+ "url": "https://github.com/sponsors/wooorm"
5856
+ }
5857
+ },
5858
  "node_modules/punycode": {
5859
  "version": "2.3.1",
5860
  "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
 
5926
  "react": ">=15.3.2 <20"
5927
  }
5928
  },
5929
+ "node_modules/react-syntax-highlighter": {
5930
+ "version": "16.1.1",
5931
+ "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
5932
+ "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
5933
+ "license": "MIT",
5934
+ "dependencies": {
5935
+ "@babel/runtime": "^7.28.4",
5936
+ "highlight.js": "^10.4.1",
5937
+ "highlightjs-vue": "^1.0.0",
5938
+ "lowlight": "^1.17.0",
5939
+ "prismjs": "^1.30.0",
5940
+ "refractor": "^5.0.0"
5941
+ },
5942
+ "engines": {
5943
+ "node": ">= 16.20.2"
5944
+ },
5945
+ "peerDependencies": {
5946
+ "react": ">= 0.14.0"
5947
+ }
5948
+ },
5949
  "node_modules/reflect.getprototypeof": {
5950
  "version": "1.0.10",
5951
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
 
5969
  "url": "https://github.com/sponsors/ljharb"
5970
  }
5971
  },
5972
+ "node_modules/refractor": {
5973
+ "version": "5.0.0",
5974
+ "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
5975
+ "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
5976
+ "license": "MIT",
5977
+ "dependencies": {
5978
+ "@types/hast": "^3.0.0",
5979
+ "@types/prismjs": "^1.0.0",
5980
+ "hastscript": "^9.0.0",
5981
+ "parse-entities": "^4.0.0"
5982
+ },
5983
+ "funding": {
5984
+ "type": "github",
5985
+ "url": "https://github.com/sponsors/wooorm"
5986
+ }
5987
+ },
5988
  "node_modules/regexp.prototype.flags": {
5989
  "version": "1.5.4",
5990
  "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
 
6371
  "node": ">=0.10.0"
6372
  }
6373
  },
6374
+ "node_modules/space-separated-tokens": {
6375
+ "version": "2.0.2",
6376
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
6377
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
6378
+ "license": "MIT",
6379
+ "funding": {
6380
+ "type": "github",
6381
+ "url": "https://github.com/sponsors/wooorm"
6382
+ }
6383
+ },
6384
  "node_modules/stable-hash": {
6385
  "version": "0.0.5",
6386
  "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
frontend-next/package.json CHANGED
@@ -17,6 +17,7 @@
17
  "react": "19.2.4",
18
  "react-dom": "19.2.4",
19
  "react-katex": "^3.1.0",
 
20
  "tailwind-merge": "^3.5.0"
21
  },
22
  "devDependencies": {
@@ -26,6 +27,7 @@
26
  "@types/react": "^19",
27
  "@types/react-dom": "^19",
28
  "@types/react-katex": "^3.0.4",
 
29
  "eslint": "^9",
30
  "eslint-config-next": "16.2.2",
31
  "tailwindcss": "^4",
 
17
  "react": "19.2.4",
18
  "react-dom": "19.2.4",
19
  "react-katex": "^3.1.0",
20
+ "react-syntax-highlighter": "^16.1.1",
21
  "tailwind-merge": "^3.5.0"
22
  },
23
  "devDependencies": {
 
27
  "@types/react": "^19",
28
  "@types/react-dom": "^19",
29
  "@types/react-katex": "^3.0.4",
30
+ "@types/react-syntax-highlighter": "^15.5.13",
31
  "eslint": "^9",
32
  "eslint-config-next": "16.2.2",
33
  "tailwindcss": "^4",
src/api/main.py CHANGED
@@ -26,7 +26,10 @@ from contextlib import asynccontextmanager
26
 
27
  from fastapi import FastAPI, HTTPException, Request
28
  from fastapi.middleware.cors import CORSMiddleware
29
- from fastapi.responses import JSONResponse
 
 
 
30
 
31
  from src.api.schemas import (
32
  QueryRequest,
@@ -35,6 +38,15 @@ from src.api.schemas import (
35
  HealthResponse,
36
  ErrorResponse,
37
  )
 
 
 
 
 
 
 
 
 
38
  from src.rag.pipeline import RAGPipeline
39
  from src.utils.logger import setup_logger, get_logger
40
 
@@ -149,6 +161,36 @@ async def health_check(request: Request) -> HealthResponse:
149
  version = "1.0.0",
150
  )
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
  @app.post(
154
  "/query",
 
26
 
27
  from fastapi import FastAPI, HTTPException, Request
28
  from fastapi.middleware.cors import CORSMiddleware
29
+ from fastapi.responses import JSONResponse, StreamingResponse
30
+ from pydantic import BaseModel
31
+ import json
32
+ import os
33
 
34
  from src.api.schemas import (
35
  QueryRequest,
 
38
  HealthResponse,
39
  ErrorResponse,
40
  )
41
+
42
+ class FeedbackRequest(BaseModel):
43
+ query: str
44
+ rating: int
45
+ thumbs: str | None = None
46
+ comment: str
47
+ model_used: str
48
+ citations_count: int
49
+ total_time_ms: float
50
  from src.rag.pipeline import RAGPipeline
51
  from src.utils.logger import setup_logger, get_logger
52
 
 
161
  version = "1.0.0",
162
  )
163
 
164
+ @app.post(
165
+ "/query/stream",
166
+ summary = "Stream query research papers",
167
+ tags = ["RAG"],
168
+ )
169
+ async def stream_query_papers(
170
+ request: Request,
171
+ query_input: QueryRequest,
172
+ ):
173
+ pipeline = request.app.state.rag_pipeline
174
+ return StreamingResponse(
175
+ pipeline.stream_query(
176
+ question = query_input.question,
177
+ top_k = query_input.top_k,
178
+ filter_category = query_input.filter_category,
179
+ filter_year_gte = query_input.filter_year_gte,
180
+ ),
181
+ media_type="text/event-stream"
182
+ )
183
+
184
+ @app.post(
185
+ "/feedback",
186
+ summary = "Submit feedback",
187
+ tags = ["System"],
188
+ )
189
+ async def submit_feedback(feedback: FeedbackRequest):
190
+ os.makedirs("logs", exist_ok=True)
191
+ with open("logs/feedback.jsonl", "a", encoding="utf-8") as f:
192
+ f.write(json.dumps(feedback.model_dump()) + "\n")
193
+ return {"status": "ok"}
194
 
195
  @app.post(
196
  "/query",
src/rag/llm_client.py CHANGED
@@ -1,103 +1,141 @@
1
- """
2
- Groq API client for LLM inference.
3
-
4
- WHY GROQ:
5
- - Free tier: 14,400 requests/day with Llama3
6
- - Speed: ~500 tokens/second (vs 10 tokens/second local CPU)
7
- - No GPU needed on our machine
8
- - Production-quality latency for demos
9
-
10
- WHY LLAMA3-8B:
11
- - Free on Groq
12
- - 8B parameters: strong reasoning for research QA
13
- - 8192 token context window: fits our 5 retrieved chunks
14
- - Fast: ~1-2 seconds for a full response
15
- """
16
-
17
  import os
 
 
18
  from groq import Groq
19
  from src.utils.logger import get_logger
20
  from config.settings import (
21
  GROQ_API_KEY,
22
- LLM_MODEL_NAME,
23
  LLM_TEMPERATURE,
24
  LLM_MAX_TOKENS,
25
  )
26
 
27
  logger = get_logger(__name__)
28
 
29
-
30
- class LLMClient:
31
  """
32
- Wrapper around Groq API for LLM inference.
33
-
34
- Designed as a simple interface so we can swap
35
- to any other LLM provider (OpenAI, Anthropic, local)
36
- by changing only this file.
37
  """
38
 
39
-
40
  def __init__(self):
41
- if not GROQ_API_KEY:
42
- raise ValueError(
43
- "GROQ_API_KEY not found. "
44
- "Add it to your .env file: GROQ_API_KEY=gsk_..."
45
- )
46
- self.client = Groq(api_key = GROQ_API_KEY)
47
- self.model = LLM_MODEL_NAME
48
- logger.info(f"LLMClient initialized with model: {self.model}")
49
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  def generate(
52
  self,
53
  system_prompt: str,
54
- user_prompt: str,
55
- temperature: float = LLM_TEMPERATURE,
56
- max_tokens: int = LLM_MAX_TOKENS,
57
- ) -> str:
 
 
58
  """
59
- Generate a response from the LLM.
60
-
61
- Args:
62
- system_prompt: Instructions for the LLM's behavior
63
- user_prompt: The actual question + context
64
- temperature: 0.0 = deterministic, 1.0 = creative
65
- We use 0.1 for factual research QA
66
- max_tokens: Maximum response length
67
-
68
- Returns:
69
- Generated text string
70
-
71
- GROQ API STRUCTURE:
72
- Uses OpenAI-compatible chat format:
73
- [{"role": "system", "content": "..."},
74
- {"role": "user", "content": "..."}]
75
  """
76
-
77
- try:
78
- response = self.client.chat.completions.create(
79
- model = self.model,
80
- messages = [
81
- {"role": "system", "content": system_prompt},
82
- {"role": "user", "content": user_prompt}
83
- ],
84
- temperature = temperature,
85
- max_tokens = max_tokens,
86
- )
87
-
88
- answer = response.choices[0].message.content
89
-
90
- # Log token usage for monitoring
91
- usage = response.usage
92
- logger.debug(
93
- f"LLM usage - "
94
- f"prompt: {usage.prompt_tokens} tokens, "
95
- f"completion: {usage.completion_tokens} tokens, "
96
- f"total: {usage.total_tokens} tokens"
97
- )
98
-
99
- return answer
100
-
101
- except Exception as e:
102
- logger.error(f"LLM generation failed: {e}")
103
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import json
3
+ import requests
4
  from groq import Groq
5
  from src.utils.logger import get_logger
6
  from config.settings import (
7
  GROQ_API_KEY,
8
+ HF_API_KEY,
9
  LLM_TEMPERATURE,
10
  LLM_MAX_TOKENS,
11
  )
12
 
13
  logger = get_logger(__name__)
14
 
15
+ class MultiModelClient:
 
16
  """
17
+ Multi-model LLM client with Qwen primary and Groq backup.
18
+ Supports code routing based on keywords.
 
 
 
19
  """
20
 
 
21
  def __init__(self):
22
+ if GROQ_API_KEY:
23
+ self.groq_client = Groq(api_key=GROQ_API_KEY)
24
+ else:
25
+ self.groq_client = None
26
+
27
+ self.hf_api_key = HF_API_KEY
28
+
29
+ self.primary_model = "Qwen/Qwen2.5-72B-Instruct"
30
+ self.secondary_model = "llama-3.3-70b-versatile"
31
+ self.code_model = "Qwen/Qwen2.5-Coder-7B-Instruct"
32
+
33
+ self.code_keywords = ["code", "implement", "function", "class", "python", "algorithm", "write a", "script"]
34
+
35
+ def get_model_for_query(self, question: str):
36
+ q_lower = question.lower()
37
+ if any(kw in q_lower for kw in self.code_keywords):
38
+ return [self.code_model, self.primary_model, self.secondary_model]
39
+ return [self.primary_model, self.secondary_model]
40
+
41
+ def _call_hf(self, model_id, messages, temperature, max_tokens, stream=False):
42
+ if not self.hf_api_key:
43
+ raise ValueError("HF_API_KEY not configured")
44
+
45
+ url = f"https://api-inference.huggingface.co/models/{model_id}/v1/chat/completions"
46
+ headers = {
47
+ "Authorization": f"Bearer {self.hf_api_key}",
48
+ "Content-Type": "application/json"
49
+ }
50
+ payload = {
51
+ "model": model_id,
52
+ "messages": messages,
53
+ "max_tokens": max_tokens,
54
+ "temperature": temperature,
55
+ "stream": stream
56
+ }
57
+
58
+ response = requests.post(url, headers=headers, json=payload, stream=stream)
59
+ if response.status_code == 429:
60
+ raise Exception("Rate limit (HTTP 429)")
61
+ if not response.ok:
62
+ raise Exception(f"HF Error: {response.text}")
63
+
64
+ if stream:
65
+ def generator():
66
+ for line in response.iter_lines():
67
+ if line:
68
+ line = line.decode('utf-8')
69
+ if line.startswith("data: "):
70
+ data_str = line[6:]
71
+ if data_str.strip() == "[DONE]":
72
+ break
73
+ try:
74
+ data = json.loads(data_str)
75
+ token = data["choices"][0]["delta"].get("content", "")
76
+ if token:
77
+ yield token
78
+ except:
79
+ pass
80
+ return generator()
81
+ else:
82
+ return response.json()["choices"][0]["message"]["content"]
83
+
84
+ def _call_groq(self, model_id, messages, temperature, max_tokens, stream=False):
85
+ if not self.groq_client:
86
+ raise ValueError("GROQ_API_KEY not configured")
87
+
88
+ response = self.groq_client.chat.completions.create(
89
+ model=model_id,
90
+ messages=messages,
91
+ temperature=temperature,
92
+ max_tokens=max_tokens,
93
+ stream=stream
94
+ )
95
+ if stream:
96
+ def generator():
97
+ for chunk in response:
98
+ token = chunk.choices[0].delta.content
99
+ if token:
100
+ yield token
101
+ return generator()
102
+ else:
103
+ return response.choices[0].message.content
104
 
105
  def generate(
106
  self,
107
  system_prompt: str,
108
+ user_prompt: str,
109
+ original_query: str = "",
110
+ temperature: float = LLM_TEMPERATURE,
111
+ max_tokens: int = LLM_MAX_TOKENS,
112
+ stream: bool = False
113
+ ):
114
  """
115
+ Generate response trying models in priority order.
116
+ Returns a tuple of (result, model_used).
117
+ If stream=True, result is a generator.
118
+ Otherwise, result is a string.
 
 
 
 
 
 
 
 
 
 
 
 
119
  """
120
+ models_to_try = self.get_model_for_query(original_query)
121
+ messages = [
122
+ {"role": "system", "content": system_prompt},
123
+ {"role": "user", "content": user_prompt}
124
+ ]
125
+
126
+ for model in models_to_try:
127
+ try:
128
+ is_hf = "Qwen" in model
129
+ logger.info(f"Attempting model: {model}")
130
+ if is_hf:
131
+ out = self._call_hf(model, messages, temperature, max_tokens, stream)
132
+ else:
133
+ out = self._call_groq(model, messages, temperature, max_tokens, stream)
134
+
135
+ logger.info(f"Model {model} selected successfully.")
136
+ return out, model
137
+ except Exception as e:
138
+ logger.warning(f"Model {model} failed: {e}")
139
+ continue
140
+
141
+ raise Exception("All models failed.")
 
 
 
 
 
 
src/rag/pipeline.py CHANGED
@@ -13,11 +13,12 @@ PIPELINE FLOW:
13
  """
14
 
15
  import time
 
16
  from dataclasses import dataclass, field
17
  from typing import Optional
18
 
19
  from src.retrieval.retrieval_pipeline import RetrievalPipeline
20
- from src.rag.llm_client import LLMClient
21
  from src.rag.prompt_templates import (
22
  SYSTEM_PROMPT,
23
  build_rag_prompt,
@@ -31,32 +32,15 @@ logger = get_logger(__name__)
31
 
32
  @dataclass
33
  class RAGResponse:
34
- """
35
- Structured response from the RAG pipeline.
36
-
37
- WHY A DATACLASS INSTEAD OF A DICT:
38
- Dicts can have any keys - you never know what's in them.
39
- A dataclass defines the exact contract. The FastAPI layer
40
- (Phase 11) and frontend (Phase 12) can rely on these
41
- fields always being present.
42
- """
43
- # The generated answer
44
  answer: str
45
-
46
- # Source papers used to generate the answer
47
  citations: list[dict]
48
-
49
- # Raw retrieved chunks (for debugging / evaluation)
50
  retrieved_chunks: list[dict]
51
-
52
- # Performance metadata
53
  query: str
54
  retrieval_time_ms: float
55
  generation_time_ms: float
56
  total_time_ms: float
57
-
58
- # Whether retrieval found retrieval content
59
  has_context: bool
 
60
 
61
 
62
  def to_dict(self) -> dict:
@@ -69,29 +53,14 @@ class RAGResponse:
69
  "total_time_ms": round(self.total_time_ms, 1),
70
  "has_context": self.has_context,
71
  "chunks_used": len(self.retrieved_chunks),
 
72
  }
73
 
74
-
75
-
76
-
77
  class RAGPipeline:
78
- """
79
- End-to-end RAG pipeline: query -> retrieve -> generate -> respond.
80
-
81
- Usage:
82
- pipeline = RAGPipeline()
83
- response = pipeline.query("How does LoRA reduce training parameters?")
84
- print(response.answer)
85
- for cite in response.citations:
86
- print(cite["title"], cite["arxiv_url"])
87
- """
88
-
89
  def __init__(self):
90
  logger.info("Initializing RAGPipeline...")
91
-
92
  self.retriever = RetrievalPipeline()
93
- self.llm = LLMClient()
94
-
95
  logger.info("RAGPipeline ready")
96
 
97
  def query(
@@ -101,26 +70,11 @@ class RAGPipeline:
101
  filter_category: Optional[str] = None,
102
  filter_year_gte: Optional[int] = None,
103
  ) -> RAGResponse:
104
- """
105
- Process a user question through the full RAG pipeline.
106
-
107
- Args:
108
- question: User's natural language question
109
- top_k: Number of chunks to retrieve
110
- filter_category: Optional ArXiv category filter
111
- filter_year_gte: Optional year filter
112
-
113
- Returns:
114
- RAGResponse with answer, citations, and timing metadata
115
- """
116
  question = question.strip()
117
-
118
  if not question:
119
  raise ValueError("Question cannot be empty")
120
 
121
  total_start = time.time()
122
-
123
- # ------------ Stage 1: Retrieval ------------
124
  retrieval_start = time.time()
125
 
126
  chunks = self.retriever.retrieve(
@@ -129,20 +83,12 @@ class RAGPipeline:
129
  filter_category = filter_category,
130
  filter_year_gte = filter_year_gte,
131
  )
132
-
133
  retrieval_ms = (time.time() - retrieval_start) * 1000
134
-
135
- logger.info(
136
- f"Retrieved: {len(chunks)} chunks in {retrieval_ms:.0f}ms"
137
- )
138
-
139
  has_context = len(chunks) > 0
140
 
141
- # ------------ Stage 2: Prompt Construction ------------
142
  if has_context:
143
  user_prompt = build_rag_prompt(question, chunks)
144
  else:
145
- # Fallback prompt when no relevant context found
146
  user_prompt = (
147
  f"The user asked: {question}\n\n"
148
  f"No relevant research papers were found in the database. "
@@ -150,23 +96,16 @@ class RAGPipeline:
150
  f"or broadening their query."
151
  )
152
 
153
- # ------------ Stage 3: LLM Generation ------------
154
  generation_start = time.time()
155
-
156
- answer = self.llm.generate(
157
  system_prompt = SYSTEM_PROMPT,
158
  user_prompt = user_prompt,
 
 
159
  )
160
 
161
  generation_ms = (time.time() - generation_start) * 1000
162
  total_ms = (time.time() - total_start) * 1000
163
-
164
- logger.info(
165
- f"Generated answer in {generation_ms:.0f}ms | "
166
- f"Total: {total_ms:.0f}ms"
167
- )
168
-
169
- # ------------ Stage 4: Build Citations ------------
170
  citations = build_citation_list(chunks)
171
 
172
  return RAGResponse(
@@ -178,4 +117,65 @@ class RAGPipeline:
178
  generation_time_ms = generation_ms,
179
  total_time_ms = total_ms,
180
  has_context = has_context,
181
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  """
14
 
15
  import time
16
+ import json
17
  from dataclasses import dataclass, field
18
  from typing import Optional
19
 
20
  from src.retrieval.retrieval_pipeline import RetrievalPipeline
21
+ from src.rag.llm_client import MultiModelClient
22
  from src.rag.prompt_templates import (
23
  SYSTEM_PROMPT,
24
  build_rag_prompt,
 
32
 
33
  @dataclass
34
  class RAGResponse:
 
 
 
 
 
 
 
 
 
 
35
  answer: str
 
 
36
  citations: list[dict]
 
 
37
  retrieved_chunks: list[dict]
 
 
38
  query: str
39
  retrieval_time_ms: float
40
  generation_time_ms: float
41
  total_time_ms: float
 
 
42
  has_context: bool
43
+ model_used: str = ""
44
 
45
 
46
  def to_dict(self) -> dict:
 
53
  "total_time_ms": round(self.total_time_ms, 1),
54
  "has_context": self.has_context,
55
  "chunks_used": len(self.retrieved_chunks),
56
+ "model_used": self.model_used,
57
  }
58
 
 
 
 
59
  class RAGPipeline:
 
 
 
 
 
 
 
 
 
 
 
60
  def __init__(self):
61
  logger.info("Initializing RAGPipeline...")
 
62
  self.retriever = RetrievalPipeline()
63
+ self.llm = MultiModelClient()
 
64
  logger.info("RAGPipeline ready")
65
 
66
  def query(
 
70
  filter_category: Optional[str] = None,
71
  filter_year_gte: Optional[int] = None,
72
  ) -> RAGResponse:
 
 
 
 
 
 
 
 
 
 
 
 
73
  question = question.strip()
 
74
  if not question:
75
  raise ValueError("Question cannot be empty")
76
 
77
  total_start = time.time()
 
 
78
  retrieval_start = time.time()
79
 
80
  chunks = self.retriever.retrieve(
 
83
  filter_category = filter_category,
84
  filter_year_gte = filter_year_gte,
85
  )
 
86
  retrieval_ms = (time.time() - retrieval_start) * 1000
 
 
 
 
 
87
  has_context = len(chunks) > 0
88
 
 
89
  if has_context:
90
  user_prompt = build_rag_prompt(question, chunks)
91
  else:
 
92
  user_prompt = (
93
  f"The user asked: {question}\n\n"
94
  f"No relevant research papers were found in the database. "
 
96
  f"or broadening their query."
97
  )
98
 
 
99
  generation_start = time.time()
100
+ answer, model_used = self.llm.generate(
 
101
  system_prompt = SYSTEM_PROMPT,
102
  user_prompt = user_prompt,
103
+ original_query = question,
104
+ stream=False
105
  )
106
 
107
  generation_ms = (time.time() - generation_start) * 1000
108
  total_ms = (time.time() - total_start) * 1000
 
 
 
 
 
 
 
109
  citations = build_citation_list(chunks)
110
 
111
  return RAGResponse(
 
117
  generation_time_ms = generation_ms,
118
  total_time_ms = total_ms,
119
  has_context = has_context,
120
+ model_used = model_used
121
+ )
122
+
123
+ def stream_query(
124
+ self,
125
+ question: str,
126
+ top_k: int = TOP_K_RERANK,
127
+ filter_category: Optional[str] = None,
128
+ filter_year_gte: Optional[int] = None,
129
+ ):
130
+ question = question.strip()
131
+ if not question:
132
+ raise ValueError("Question cannot be empty")
133
+
134
+ total_start = time.time()
135
+ retrieval_start = time.time()
136
+ chunks = self.retriever.retrieve(
137
+ query = question,
138
+ top_k_final = top_k,
139
+ filter_category = filter_category,
140
+ filter_year_gte = filter_year_gte,
141
+ )
142
+ retrieval_ms = (time.time() - retrieval_start) * 1000
143
+ has_context = len(chunks) > 0
144
+
145
+ if has_context:
146
+ user_prompt = build_rag_prompt(question, chunks)
147
+ else:
148
+ user_prompt = (
149
+ f"The user asked: {question}\n\n"
150
+ f"No relevant research papers were found in the database. "
151
+ f"Politely inform the user and suggest they try rephrasing "
152
+ f"or broadening their query."
153
+ )
154
+
155
+ generation_start = time.time()
156
+ generator, model_used = self.llm.generate(
157
+ system_prompt = SYSTEM_PROMPT,
158
+ user_prompt = user_prompt,
159
+ original_query = question,
160
+ stream=True
161
+ )
162
+
163
+ for token in generator:
164
+ yield f"data: {json.dumps({'token': token})}\n\n"
165
+
166
+ generation_ms = (time.time() - generation_start) * 1000
167
+ total_ms = (time.time() - total_start) * 1000
168
+ citations = build_citation_list(chunks)
169
+
170
+ metadata = {
171
+ "done": True,
172
+ "citations": citations,
173
+ "model_used": model_used,
174
+ "timing": {
175
+ "retrieval_time_ms": round(retrieval_ms, 1),
176
+ "generation_time_ms": round(generation_ms, 1),
177
+ "total_time_ms": round(total_ms, 1),
178
+ "chunks_used": len(chunks)
179
+ }
180
+ }
181
+ yield f"data: {json.dumps(metadata)}\n\n"