Skip to content

Commit c904f42

Browse files
committed
feat: Introduce PageIndex feature for document upload, structured indexing, and AI chat interaction.
1 parent 6b9b01e commit c904f42

File tree

3 files changed

+556
-812
lines changed

3 files changed

+556
-812
lines changed
Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
import React, { useState, useRef, useEffect } from 'react';
2+
import {
3+
Upload,
4+
FileText,
5+
Search,
6+
Send,
7+
ChevronRight,
8+
ChevronDown,
9+
Loader2,
10+
MessageSquare,
11+
CheckCircle,
12+
AlertCircle,
13+
Info,
14+
Network,
15+
Cpu,
16+
Zap,
17+
UploadCloud
18+
} from 'lucide-react';
19+
import { motion, AnimatePresence } from 'framer-motion';
20+
import { cn } from '../../utils/cn';
21+
import { pageIndexService, PageIndexNode } from '../../services/pageIndexService';
22+
import { useAuth } from '../../contexts/AuthContext';
23+
import log from '../../utils/logger';
24+
25+
// ─── Sub-Component: TreeNode ────────────────────────────────────
26+
const TreeNode = ({ node, depth = 0 }: { node: PageIndexNode; depth?: number }) => {
27+
const [isExpanded, setIsExpanded] = useState(depth < 1);
28+
const hasChildren = node.nodes && node.nodes.length > 0;
29+
30+
return (
31+
<div className="select-none">
32+
<div
33+
className={cn(
34+
"flex items-start gap-2 py-1.5 px-2 rounded-lg transition-colors cursor-pointer group",
35+
"hover:bg-white/5"
36+
)}
37+
onClick={() => setIsExpanded(!isExpanded)}
38+
>
39+
<div className="flex-shrink-0 mt-0.5">
40+
{hasChildren ? (
41+
isExpanded ? <ChevronDown className="w-4 h-4 text-[#FF8A5B]" /> : <ChevronRight className="w-4 h-4 text-white/40" />
42+
) : (
43+
<div className="w-4 h-4" />
44+
)}
45+
</div>
46+
<div className="flex-1 min-w-0">
47+
<div className="flex items-center gap-2">
48+
<span className="text-[10px] font-mono text-[#FFB286] bg-[#FF8A5B]/10 px-1.5 py-0.5 rounded">
49+
P{node.page_index}
50+
</span>
51+
<h4 className="text-sm font-medium text-white/90 truncate group-hover:text-white">
52+
{node.title || "Untitled Section"}
53+
</h4>
54+
</div>
55+
{node.text && isExpanded && (
56+
<p className="text-xs text-white/50 mt-1 line-clamp-2 italic leading-relaxed">
57+
{node.text}
58+
</p>
59+
)}
60+
</div>
61+
</div>
62+
63+
<AnimatePresence>
64+
{isExpanded && hasChildren && (
65+
<motion.div
66+
initial={{ opacity: 0, height: 0 }}
67+
animate={{ opacity: 1, height: 'auto' }}
68+
exit={{ opacity: 0, height: 0 }}
69+
className="ml-4 border-l border-white/10 pl-2 mt-1"
70+
>
71+
{node.nodes?.map((child, i) => (
72+
<TreeNode key={i} node={child} depth={depth + 1} />
73+
))}
74+
</motion.div>
75+
)}
76+
</AnimatePresence>
77+
</div>
78+
);
79+
};
80+
81+
// ─── Main Component ──────────────────────────────────────────────
82+
export default function PageIndexView() {
83+
const { user } = useAuth();
84+
const [file, setFile] = useState<File | null>(null);
85+
const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'ready' | 'error'>('idle');
86+
const [progress, setProgress] = useState('');
87+
const [docId, setDocId] = useState<string | null>(null);
88+
const [tree, setTree] = useState<PageIndexNode[]>([]);
89+
const [chatMessages, setChatMessages] = useState<{ role: string; content: string }[]>([]);
90+
const [input, setInput] = useState('');
91+
const [isChatting, setIsChatting] = useState(false);
92+
const scrollRef = useRef<HTMLDivElement>(null);
93+
const fileInputRef = useRef<HTMLInputElement>(null);
94+
95+
// Auto-scroll chat
96+
useEffect(() => {
97+
if (scrollRef.current) {
98+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
99+
}
100+
}, [chatMessages, isChatting]);
101+
102+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
103+
if (e.target.files && e.target.files[0]) {
104+
setFile(e.target.files[0]);
105+
setStatus('idle');
106+
setTree([]);
107+
setChatMessages([]);
108+
}
109+
};
110+
111+
const handleUpload = async () => {
112+
if (!file) return;
113+
114+
try {
115+
setStatus('uploading');
116+
setProgress('Uploading to PageIndex...');
117+
const id = await pageIndexService.submitDocument(file);
118+
setDocId(id);
119+
120+
setStatus('processing');
121+
setProgress('Vectorless indexing in progress...');
122+
123+
const result = await pageIndexService.pollTreeStatus(id, (s) => setProgress(`Status: ${s}...`));
124+
setTree(result);
125+
setStatus('ready');
126+
127+
// Initial AI greeting
128+
setChatMessages([
129+
{ role: 'assistant', content: `Hello! I've successfully indexed **${file.name}**. I've extracted ${pageIndexService.countNodes(result)} structured points across the document. Ask me anything about it!` }
130+
]);
131+
} catch (err: any) {
132+
log.error('PageIndex upload failed', err);
133+
setStatus('error');
134+
setProgress(err.message || 'Indexing failed');
135+
}
136+
};
137+
138+
const handleSendMessage = async () => {
139+
if (!input.trim() || !tree.length || isChatting) return;
140+
141+
const userMsg = input.trim();
142+
setInput('');
143+
setChatMessages(prev => [...prev, { role: 'user', content: userMsg }]);
144+
setIsChatting(true);
145+
146+
try {
147+
const history = chatMessages.map(m => ({ role: m.role, content: m.content }));
148+
const response = await pageIndexService.chatWithDocument(userMsg, tree, history);
149+
setChatMessages(prev => [...prev, { role: 'assistant', content: response }]);
150+
} catch (err) {
151+
setChatMessages(prev => [...prev, { role: 'assistant', content: "Sorry, I encountered an error while processing your request." }]);
152+
} finally {
153+
setIsChatting(false);
154+
}
155+
};
156+
157+
return (
158+
<div className="flex flex-col gap-6 h-[calc(100vh-120px)] animate-in fade-in slide-in-from-bottom-4 duration-700">
159+
{/* Top Bar / Upload Zone */}
160+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
161+
<div className="lg:col-span-2 bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl p-6 relative overflow-hidden group">
162+
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity">
163+
<Network className="w-32 h-32 text-[#FF8A5B]" />
164+
</div>
165+
166+
<div className="relative z-10">
167+
<div className="flex items-center gap-3 mb-4">
168+
<div className="p-2 bg-[#FF8A5B]/20 rounded-lg">
169+
<UploadCloud className="w-5 h-5 text-[#FF8A5B]" />
170+
</div>
171+
<h2 className="text-xl font-bold text-white">Document Intelligence</h2>
172+
</div>
173+
174+
<p className="text-white/60 text-sm mb-6 max-w-md">
175+
Upload a PDF to perform **Vectorless RAG**. We extract the document's logical structure (Index Tree) for highly accurate, context-aware reasoning.
176+
</p>
177+
178+
<div className="flex flex-wrap items-center gap-4">
179+
<input
180+
type="file"
181+
ref={fileInputRef}
182+
onChange={handleFileChange}
183+
accept=".pdf"
184+
className="hidden"
185+
/>
186+
<button
187+
onClick={() => fileInputRef.current?.click()}
188+
className="px-5 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white font-medium hover:bg-white/10 transition-all flex items-center gap-2"
189+
>
190+
<FileText className="w-4 h-4 text-[#FFB286]" />
191+
{file ? file.name : "Select PDF Document"}
192+
</button>
193+
194+
{file && status === 'idle' && (
195+
<button
196+
onClick={handleUpload}
197+
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-[#FF8A5B] to-[#FFB286] text-white font-bold shadow-lg shadow-[#FF8A5B]/20 hover:scale-105 transition-all flex items-center gap-2"
198+
>
199+
<Zap className="w-4 h-4" />
200+
Start Indexing
201+
</button>
202+
)}
203+
204+
{(status === 'uploading' || status === 'processing') && (
205+
<div className="flex items-center gap-3 text-[#FFB286] animate-pulse">
206+
<Loader2 className="w-5 h-5 animate-spin" />
207+
<span className="text-sm font-medium">{progress}</span>
208+
</div>
209+
)}
210+
211+
{status === 'ready' && (
212+
<div className="flex items-center gap-2 text-emerald-400 bg-emerald-400/10 px-3 py-1.5 rounded-lg border border-emerald-400/20">
213+
<CheckCircle className="w-4 h-4" />
214+
<span className="text-xs font-bold uppercase tracking-wider">Indexed & Ready</span>
215+
</div>
216+
)}
217+
218+
{status === 'error' && (
219+
<div className="flex items-center gap-2 text-red-400 bg-red-400/10 px-3 py-1.5 rounded-lg border border-red-400/20">
220+
<AlertCircle className="w-4 h-4" />
221+
<span className="text-xs font-bold uppercase tracking-wider">{progress}</span>
222+
</div>
223+
)}
224+
</div>
225+
</div>
226+
</div>
227+
228+
<div className="bg-gradient-to-br from-[#FF8A5B]/10 to-transparent border border-white/10 rounded-2xl p-6">
229+
<h3 className="text-white font-bold mb-3 flex items-center gap-2">
230+
<Info className="w-4 h-4 text-[#FFB286]" />
231+
What is Vectorless RAG?
232+
</h3>
233+
<ul className="space-y-3">
234+
<li className="flex gap-3 text-xs text-white/70">
235+
<div className="mt-1 w-1.5 h-1.5 rounded-full bg-[#FFB286] flex-shrink-0" />
236+
<span>Traditional RAG splits text blindly into chunks, losing hierarchy.</span>
237+
</li>
238+
<li className="flex gap-3 text-xs text-white/70">
239+
<div className="mt-1 w-1.5 h-1.5 rounded-full bg-[#FFB286] flex-shrink-0" />
240+
<span>**PageIndex** extracts the actual index tree, keeping sections and sub-sections intact.</span>
241+
</li>
242+
<li className="flex gap-3 text-xs text-white/70">
243+
<div className="mt-1 w-1.5 h-1.5 rounded-full bg-[#FFB286] flex-shrink-0" />
244+
<span>This results in perfect context retrieval and much higher accuracy in LLM responses.</span>
245+
</li>
246+
</ul>
247+
</div>
248+
</div>
249+
250+
{/* Main Content Area: Split Tree and Chat */}
251+
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-12 gap-6 overflow-hidden">
252+
{/* Left: Tree Viewer */}
253+
<div className="lg:col-span-4 bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl flex flex-col min-h-0">
254+
<div className="p-4 border-b border-white/10 flex items-center justify-between">
255+
<h3 className="text-sm font-bold text-white uppercase tracking-widest flex items-center gap-2">
256+
<Network className="w-4 h-4 text-[#FF8A5B]" />
257+
Document Structure
258+
</h3>
259+
{tree.length > 0 && (
260+
<span className="text-[10px] font-bold text-white/40">
261+
{pageIndexService.countNodes(tree)} NODES
262+
</span>
263+
)}
264+
</div>
265+
266+
<div className="flex-1 overflow-y-auto p-4 scrollbar-hide">
267+
{tree.length > 0 ? (
268+
<div className="space-y-1">
269+
{tree.map((node, i) => (
270+
<TreeNode key={i} node={node} />
271+
))}
272+
</div>
273+
) : (
274+
<div className="h-full flex flex-col items-center justify-center text-center p-8 opacity-30">
275+
<FileText className="w-12 h-12 mb-4" />
276+
<p className="text-sm">No structured data available.<br />Upload and index a document to see the tree.</p>
277+
</div>
278+
)}
279+
</div>
280+
</div>
281+
282+
{/* Right: Chat Interface */}
283+
<div className="lg:col-span-8 bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl flex flex-col min-h-0">
284+
<div className="p-4 border-b border-white/10">
285+
<h3 className="text-sm font-bold text-white uppercase tracking-widest flex items-center gap-2">
286+
<MessageSquare className="w-4 h-4 text-[#FF8A5B]" />
287+
Contextual AI Chat
288+
</h3>
289+
</div>
290+
291+
<div
292+
ref={scrollRef}
293+
className="flex-1 overflow-y-auto p-6 space-y-6 scrollbar-hide"
294+
>
295+
{chatMessages.length === 0 ? (
296+
<div className="h-full flex flex-col items-center justify-center text-center opacity-30 space-y-4">
297+
<div className="p-6 rounded-full bg-white/5 border border-white/5 scale-125">
298+
<Cpu className="w-12 h-12" />
299+
</div>
300+
<div>
301+
<h4 className="text-lg font-bold">Vectorless RAG Chat</h4>
302+
<p className="text-sm max-w-xs">Once indexed, you can chat with your document using structured context.</p>
303+
</div>
304+
</div>
305+
) : (
306+
chatMessages.map((msg, i) => (
307+
<motion.div
308+
key={i}
309+
initial={{ opacity: 0, y: 10 }}
310+
animate={{ opacity: 1, y: 0 }}
311+
className={cn(
312+
"flex gap-4 max-w-[85%]",
313+
msg.role === 'user' ? "ml-auto flex-row-reverse" : "mr-auto"
314+
)}
315+
>
316+
<div className={cn(
317+
"w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center border",
318+
msg.role === 'user' ? "bg-white/10 border-white/20" : "bg-[#FF8A5B]/20 border-[#FF8A5B]/30"
319+
)}>
320+
{msg.role === 'user' ? <FileText className="w-4 h-4 text-white/70" /> : <Cpu className="w-4 h-4 text-[#FFB286]" />}
321+
</div>
322+
<div className={cn(
323+
"rounded-2xl px-4 py-3 text-sm leading-relaxed",
324+
msg.role === 'user'
325+
? "bg-gradient-to-br from-white/10 to-white/5 text-white border border-white/10"
326+
: "bg-[#FF8A5B]/5 text-white/90 border border-[#FF8A5B]/20 shadow-lg shadow-[#FF8A5B]/5"
327+
)}>
328+
{msg.content}
329+
</div>
330+
</motion.div>
331+
))
332+
)}
333+
{isChatting && (
334+
<div className="flex gap-4 mr-auto">
335+
<div className="w-8 h-8 rounded-full bg-[#FF8A5B]/20 border border-[#FF8A5B]/30 flex items-center justify-center">
336+
<Loader2 className="w-4 h-4 text-[#FFB286] animate-spin" />
337+
</div>
338+
<div className="bg-[#FF8A5B]/5 border border-[#FF8A5B]/20 rounded-2xl px-4 py-3">
339+
<div className="flex gap-1">
340+
<span className="w-1.5 h-1.5 rounded-full bg-[#FFB286] animate-bounce" style={{ animationDelay: '0ms' }} />
341+
<span className="w-1.5 h-1.5 rounded-full bg-[#FFB286] animate-bounce" style={{ animationDelay: '150ms' }} />
342+
<span className="w-1.5 h-1.5 rounded-full bg-[#FFB286] animate-bounce" style={{ animationDelay: '300ms' }} />
343+
</div>
344+
</div>
345+
</div>
346+
)}
347+
</div>
348+
349+
<div className="p-4 bg-white/5 border-t border-white/10">
350+
<div className="relative">
351+
<input
352+
type="text"
353+
placeholder={status === 'ready' ? "Ask a question about this document..." : "Index a document first to start chatting..."}
354+
disabled={status !== 'ready' || isChatting}
355+
value={input}
356+
onChange={(e) => setInput(e.target.value)}
357+
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
358+
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 pr-12 text-sm text-white placeholder:text-white/20 outline-none focus:border-[#FF8A5B]/50 transition-all"
359+
/>
360+
<button
361+
onClick={handleSendMessage}
362+
disabled={!input.trim() || status !== 'ready' || isChatting}
363+
className="absolute right-2 top-1.5 p-1.5 rounded-lg bg-[#FF8A5B] text-white hover:bg-[#FFB286] disabled:opacity-30 transition-all cursor-pointer"
364+
>
365+
<Send className="w-4 h-4" />
366+
</button>
367+
</div>
368+
</div>
369+
</div>
370+
</div>
371+
</div>
372+
);
373+
}

0 commit comments

Comments
 (0)