lakshmisravya123 commited on
Commit
760153d
·
1 Parent(s): e1d7ef4

Major upgrade: comprehensive document analysis

Browse files
.gitignore CHANGED
@@ -1,6 +1,6 @@
1
  node_modules/
2
- .env
3
  dist/
 
4
  .DS_Store
5
- uploads/*.pdf
6
- uploads/*.txt
 
1
  node_modules/
 
2
  dist/
3
+ .env
4
  .DS_Store
5
+ backend/uploads/*
6
+ !backend/uploads/.gitkeep
README-hf.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Smart Document Search
3
+ emoji: 🔍
4
+ colorFrom: yellow
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # Smart Document Search
11
+ Upload PDFs and text files, then ask questions in natural language. AI finds answers with source citations.
README.md CHANGED
@@ -1,11 +1,43 @@
1
- ---
2
- title: Smart Document Search
3
- emoji: "\U0001F50D"
4
- colorFrom: yellow
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
  # Smart Document Search
11
- Upload PDFs and text files, then ask questions in natural language. AI finds answers with source citations.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Smart Document Search
2
+
3
+ Upload PDFs and text files, then ask questions in natural language. AI finds answers from your documents with source citations.
4
+
5
+ ## Features
6
+
7
+ - **Upload PDF/TXT** - Drag-and-drop document upload
8
+ - **Auto-summarization** - AI summarizes each uploaded document
9
+ - **Natural language Q&A** - Ask anything about your documents
10
+ - **Source citations** - Every answer references which document it came from
11
+ - **Conversation memory** - Follow-up questions understand context
12
+ - **Multi-document** - Search across all uploaded documents simultaneously
13
+
14
+ ## Quick Start
15
+
16
+ ```bash
17
+ # Backend
18
+ cd backend
19
+ cp .env.example .env
20
+ npm install
21
+ npm start # Port 3004
22
+
23
+ # Frontend
24
+ cd frontend
25
+ npm install
26
+ npm run dev # Port 5177
27
+ ```
28
+
29
+ ### AI Setup (choose one - both are FREE)
30
+
31
+ | Provider | Best For | Setup |
32
+ |----------|----------|-------|
33
+ | **Groq** (cloud) | Deployment, sharing | Get free key at [console.groq.com/keys](https://console.groq.com/keys), add `GROQ_API_KEY` to `.env` |
34
+ | **Ollama** (local) | Development, offline | Install from [ollama.com](https://ollama.com), run `ollama pull llama3.2:3b` |
35
+
36
+ If both are configured, Groq is used first with Ollama as fallback.
37
+
38
+ ## Tech Stack
39
+
40
+ - **Frontend**: React 19 + Vite + react-dropzone
41
+ - **Backend**: Express.js + pdf-parse
42
+ - **AI**: Groq (cloud) / Ollama (local) - both free, no paid API needed
43
+ - **Search**: Text chunking with keyword-based retrieval (RAG pattern)
backend/services/ai.js CHANGED
@@ -8,7 +8,15 @@ async function callAI(prompt) {
8
  const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
9
  method: 'POST',
10
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${GROQ_API_KEY}` },
11
- body: JSON.stringify({ model: GROQ_MODEL, messages: [{ role: 'user', content: prompt }], temperature: 0.7 }),
 
 
 
 
 
 
 
 
12
  });
13
  if (res.ok) { const data = await res.json(); return data.choices[0].message.content; }
14
  console.warn('Groq failed, falling back to Ollama...');
@@ -23,39 +31,47 @@ async function callAI(prompt) {
23
  }
24
 
25
  function parseJSON(text) {
26
- try { return JSON.parse(text.trim()); }
27
- catch { const m = text.match(/\{[\s\S]*\}/); if (m) return JSON.parse(m[0]); throw new Error('Failed to parse AI response'); }
 
 
 
 
 
 
 
28
  }
29
 
30
- async function answerQuestion(question, relevantChunks) {
31
  const context = relevantChunks.map((c, i) =>
32
  `[Source ${i + 1} - ${c.filename}]:\n${c.text}`
33
  ).join('\n\n---\n\n');
34
 
35
- const answer = await callAI(`You are a helpful document research assistant. Answer based on the provided documents only. Cite sources. Be honest when info is not available.
36
 
37
  DOCUMENT CONTEXT:
38
  ${context}
39
 
40
  QUESTION: ${question}
41
 
42
- Provide a clear answer.`);
43
 
44
  return {
45
  answer,
46
- sourcesUsed: relevantChunks.map(c => ({ filename: c.filename, preview: c.text.substring(0, 100) + '...' })),
47
  };
48
  }
49
 
50
  async function summarizeDocument(text, filename) {
51
- const truncated = text.substring(0, 10000);
52
- const responseText = await callAI(`Summarize this document. Return ONLY valid JSON:
53
- {"title":"<title>","summary":"<3-5 sentences>","keyTopics":["<t1>","<t2>","<t3>"],"documentType":"<report|article|manual|contract|research|other>","keyFacts":["<f1>","<f2>","<f3>"]}
 
54
 
55
- DOCUMENT (${filename}):
56
- ${truncated}
 
57
 
58
- Return ONLY JSON, no markdown.`);
59
  return parseJSON(responseText);
60
  }
61
 
 
8
  const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
9
  method: 'POST',
10
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${GROQ_API_KEY}` },
11
+ body: JSON.stringify({
12
+ model: GROQ_MODEL,
13
+ messages: [
14
+ { role: 'system', content: 'You are an expert document analyst. Always return valid JSON when asked. Never wrap JSON in markdown code blocks.' },
15
+ { role: 'user', content: prompt }
16
+ ],
17
+ temperature: 0.3,
18
+ max_tokens: 4096,
19
+ }),
20
  });
21
  if (res.ok) { const data = await res.json(); return data.choices[0].message.content; }
22
  console.warn('Groq failed, falling back to Ollama...');
 
31
  }
32
 
33
  function parseJSON(text) {
34
+ try {
35
+ return JSON.parse(text.trim());
36
+ } catch {
37
+ const stripped = text.replace(/```(?:json)?\s*/gi, "").replace(/```/g, "").trim();
38
+ try { return JSON.parse(stripped); } catch {}
39
+ const m = stripped.match(/\{[\s\S]*\}/);
40
+ if (m) return JSON.parse(m[0]);
41
+ throw new Error("Failed to parse AI response as JSON");
42
+ }
43
  }
44
 
45
+ async function answerQuestion(question, relevantChunks, history = []) {
46
  const context = relevantChunks.map((c, i) =>
47
  `[Source ${i + 1} - ${c.filename}]:\n${c.text}`
48
  ).join('\n\n---\n\n');
49
 
50
+ const answer = await callAI(`You are a highly skilled document research assistant. Answer questions based ONLY on the provided documents. Always cite your sources using [Source N] notation. If the information is not available, say so honestly.
51
 
52
  DOCUMENT CONTEXT:
53
  ${context}
54
 
55
  QUESTION: ${question}
56
 
57
+ Provide a thorough, well-structured answer. Use bullet points or numbered lists when appropriate. Cite specific sources.`);
58
 
59
  return {
60
  answer,
61
+ sourcesUsed: relevantChunks.map(c => ({ filename: c.filename, preview: c.text.substring(0, 120) + '...' })),
62
  };
63
  }
64
 
65
  async function summarizeDocument(text, filename) {
66
+ const truncated = text.substring(0, 12000);
67
+ const wordCount = text.split(/\s+/).length;
68
+ const schemaPath = require("path").join(__dirname, "analysis_schema.json");
69
+ const schema = require("fs").readFileSync(schemaPath, "utf8");
70
 
71
+ const preamble = "You are an expert document analyst. Perform a comprehensive analysis of this document and return ONLY valid JSON (no markdown, no code fences).\n\nReturn this exact JSON structure:\n";
72
+ const suffix = "\n\nDOCUMENT (" + filename + ", ~" + wordCount + " words):\n" + truncated + "\n\nReturn ONLY the JSON object. No explanation, no markdown.";
73
+ const responseText = await callAI(preamble + schema + suffix);
74
 
 
75
  return parseJSON(responseText);
76
  }
77
 
backend/services/analysis_schema.json ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "title": "<extracted or inferred document title>",
3
+ "documentType": "<report|article|manual|contract|research|legal|email|memo|presentation|other>",
4
+ "summary": {
5
+ "oneSentence": "<concise 1-sentence TL;DR>",
6
+ "oneParagraph": "<3-5 sentence summary covering main points>",
7
+ "detailed": "<comprehensive 6-10 sentence summary with all key details>"
8
+ },
9
+ "keyTopics": [
10
+ "<topic1>",
11
+ "<topic2>",
12
+ "<topic3>",
13
+ "<topic4>",
14
+ "<topic5>"
15
+ ],
16
+ "keyInsights": [
17
+ {
18
+ "insight": "<key finding or insight>",
19
+ "confidence": 0.9,
20
+ "section": "<where found>"
21
+ },
22
+ {
23
+ "insight": "<key finding or insight>",
24
+ "confidence": 0.8,
25
+ "section": "<where found>"
26
+ },
27
+ {
28
+ "insight": "<key finding or insight>",
29
+ "confidence": 0.7,
30
+ "section": "<where found>"
31
+ }
32
+ ],
33
+ "entities": {
34
+ "people": [
35
+ "<person names mentioned>"
36
+ ],
37
+ "organizations": [
38
+ "<org names mentioned>"
39
+ ],
40
+ "dates": [
41
+ "<important dates mentioned>"
42
+ ],
43
+ "amounts": [
44
+ "<monetary amounts, statistics, quantities>"
45
+ ],
46
+ "locations": [
47
+ "<places mentioned>"
48
+ ]
49
+ },
50
+ "structureAnalysis": {
51
+ "hasHeadings": true,
52
+ "estimatedSections": 5,
53
+ "sectionNames": [
54
+ "<section1>",
55
+ "<section2>"
56
+ ],
57
+ "organizationQuality": "<excellent|good|fair|poor>",
58
+ "organizationNotes": "<brief note on document structure>"
59
+ },
60
+ "readability": {
61
+ "level": "<elementary|middle_school|high_school|college|graduate|professional>",
62
+ "fleschKincaidGrade": 10,
63
+ "notes": "<brief readability assessment>"
64
+ },
65
+ "sentimentAnalysis": {
66
+ "overall": "<positive|negative|neutral|mixed>",
67
+ "tone": "<formal|informal|technical|persuasive|informative|emotional>",
68
+ "sectionSentiments": [
69
+ {
70
+ "section": "<section name or area>",
71
+ "sentiment": "<positive|negative|neutral>",
72
+ "note": "<why>"
73
+ }
74
+ ]
75
+ },
76
+ "factsVsOpinions": {
77
+ "factStatements": [
78
+ "<verifiable factual claim from doc>",
79
+ "<another fact>"
80
+ ],
81
+ "opinionStatements": [
82
+ "<subjective opinion from doc>",
83
+ "<another opinion>"
84
+ ],
85
+ "ratio": "<mostly_factual|balanced|mostly_opinion>"
86
+ },
87
+ "relatedQuestions": [
88
+ "<question this document can answer 1>",
89
+ "<question this document can answer 2>",
90
+ "<question this document can answer 3>",
91
+ "<question this document can answer 4>",
92
+ "<question this document can answer 5>"
93
+ ],
94
+ "citationsCheck": {
95
+ "hasCitations": false,
96
+ "citationCount": 0,
97
+ "quality": "<strong|adequate|weak|none>",
98
+ "notes": "<assessment of references or citations>"
99
+ },
100
+ "keyFacts": [
101
+ "<fact1>",
102
+ "<fact2>",
103
+ "<fact3>",
104
+ "<fact4>",
105
+ "<fact5>"
106
+ ]
107
+ }
frontend/src/App.jsx CHANGED
@@ -2,12 +2,298 @@ import React, { useState, useRef, useEffect } from 'react';
2
  import { useDropzone } from 'react-dropzone';
3
  import { uploadDocument, deleteDocument, askQuestion } from './utils/api';
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  export default function App() {
6
  const [documents, setDocuments] = useState([]);
7
  const [messages, setMessages] = useState([]);
8
  const [input, setInput] = useState('');
9
  const [loading, setLoading] = useState(false);
10
  const [uploading, setUploading] = useState(false);
 
11
  const [sessionId] = useState(() => crypto.randomUUID());
12
  const messagesEnd = useRef(null);
13
 
@@ -21,19 +307,22 @@ export default function App() {
21
  setUploading(true);
22
  try {
23
  const data = await uploadDocument(file);
24
- setDocuments(prev => [...prev, {
25
  id: data.document.id,
26
  filename: data.document.filename,
27
  summary: data.document.summary,
28
- }]);
 
 
 
29
  setMessages(prev => [...prev, {
30
  role: 'assistant',
31
- content: `Uploaded "${data.document.filename}" (${data.document.chunkCount} chunks). ${data.document.summary?.summary || ''}`,
32
  }]);
33
  } catch (err) {
34
  setMessages(prev => [...prev, {
35
  role: 'assistant',
36
- content: `Failed to upload ${file.name}: ${err.message}`,
37
  }]);
38
  }
39
  setUploading(false);
@@ -46,16 +335,15 @@ export default function App() {
46
  const handleRemoveDoc = async (id) => {
47
  await deleteDocument(id);
48
  setDocuments(prev => prev.filter(d => d.id !== id));
 
49
  };
50
 
51
  const handleAsk = async () => {
52
  if (!input.trim() || loading) return;
53
-
54
  const question = input.trim();
55
  setInput('');
56
  setMessages(prev => [...prev, { role: 'user', content: question }]);
57
  setLoading(true);
58
-
59
  try {
60
  const docIds = documents.map(d => d.id);
61
  const data = await askQuestion(question, docIds, sessionId);
@@ -67,7 +355,7 @@ export default function App() {
67
  } catch (err) {
68
  setMessages(prev => [...prev, {
69
  role: 'assistant',
70
- content: `Error: ${err.message}`,
71
  }]);
72
  }
73
  setLoading(false);
@@ -84,7 +372,7 @@ export default function App() {
84
  <div className="app">
85
  <header className="header">
86
  <h1>Smart Document Search</h1>
87
- <p>Upload documents. Ask questions. Get answers.</p>
88
  </header>
89
 
90
  <div className="layout">
@@ -104,26 +392,34 @@ export default function App() {
104
  {documents.length > 0 && (
105
  <ul className="doc-list">
106
  {documents.map(d => (
107
- <li key={d.id} className="doc-item">
108
- <span className="doc-name" title={d.filename}>{d.filename}</span>
109
  <button className="doc-remove" onClick={() => handleRemoveDoc(d.id)}>x</button>
110
  </li>
111
  ))}
112
  </ul>
113
  )}
114
  </div>
 
 
 
 
 
 
 
115
  </div>
116
 
117
  <div className="chat-area">
118
  <div className="messages">
119
  {messages.length === 0 && (
120
  <div className="empty-state">
121
- <div className="icon">📄</div>
122
  <p>Upload documents and start asking questions</p>
 
123
  </div>
124
  )}
125
  {messages.map((m, i) => (
126
- <div key={i} className={`message ${m.role}`}>
127
  <div className="bubble">{m.content}</div>
128
  {m.sources && m.sources.length > 0 && (
129
  <div className="sources">
 
2
  import { useDropzone } from 'react-dropzone';
3
  import { uploadDocument, deleteDocument, askQuestion } from './utils/api';
4
 
5
+ function ConfidenceBar({ value }) {
6
+ const pct = Math.round((value || 0) * 100);
7
+ const color = pct >= 80 ? '#34d399' : pct >= 60 ? '#fbbf24' : '#f87171';
8
+ return (
9
+ <div className="confidence-bar">
10
+ <div className="confidence-fill" style={{ width: pct + '%', backgroundColor: color }} />
11
+ <span>{pct}%</span>
12
+ </div>
13
+ );
14
+ }
15
+
16
+ function Badge({ text, color }) {
17
+ return <span className={"badge badge-" + color}>{text}</span>;
18
+ }
19
+
20
+ function Section({ title, children, defaultOpen = false }) {
21
+ const [open, setOpen] = useState(defaultOpen);
22
+ return (
23
+ <div className="analysis-section">
24
+ <div className="section-header" onClick={() => setOpen(!open)}>
25
+ <span>{open ? '▼' : '▶'} {title}</span>
26
+ </div>
27
+ {open && <div className="section-body">{children}</div>}
28
+ </div>
29
+ );
30
+ }
31
+
32
+ function AnalysisPanel({ summary }) {
33
+ const [activeTab, setActiveTab] = useState('overview');
34
+ if (!summary) return null;
35
+ const s = summary;
36
+
37
+ const tabs = [
38
+ { id: 'overview', label: 'Overview' },
39
+ { id: 'insights', label: 'Key Insights' },
40
+ { id: 'entities', label: 'Entities' },
41
+ { id: 'structure', label: 'Structure' },
42
+ { id: 'sentiment', label: 'Sentiment' },
43
+ { id: 'facts', label: 'Facts/Opinions' },
44
+ { id: 'questions', label: 'Questions' },
45
+ { id: 'citations', label: 'Citations' },
46
+ ];
47
+
48
+ const renderContent = () => {
49
+ switch (activeTab) {
50
+ case 'overview':
51
+ return (
52
+ <div>
53
+ <Section title="TL;DR (1 sentence)" defaultOpen={true}>
54
+ <p className="summary-text">{s.summary?.oneSentence || (typeof s.summary === 'string' ? s.summary : '')}</p>
55
+ </Section>
56
+ <Section title="Summary (1 paragraph)">
57
+ <p className="summary-text">{s.summary?.oneParagraph}</p>
58
+ </Section>
59
+ <Section title="Detailed Summary">
60
+ <p className="summary-text">{s.summary?.detailed}</p>
61
+ </Section>
62
+ <Section title="Key Topics" defaultOpen={true}>
63
+ <div className="tag-list">
64
+ {(s.keyTopics || []).map((t, i) => <Badge key={i} text={t} color="blue" />)}
65
+ </div>
66
+ </Section>
67
+ <Section title="Key Facts">
68
+ <ul className="fact-list">
69
+ {(s.keyFacts || []).map((f, i) => <li key={i}>{f}</li>)}
70
+ </ul>
71
+ </Section>
72
+ {s.readability && (
73
+ <Section title="Readability">
74
+ <div className="readability-info">
75
+ <Badge text={'Level: ' + (s.readability.level || 'N/A')} color="purple" />
76
+ <Badge text={'Grade: ' + (s.readability.fleschKincaidGrade || 'N/A')} color="green" />
77
+ <p>{s.readability.notes}</p>
78
+ </div>
79
+ </Section>
80
+ )}
81
+ <div className="doc-meta">
82
+ <Badge text={'Type: ' + (s.documentType || 'unknown')} color="gray" />
83
+ {s.title && <Badge text={s.title} color="blue" />}
84
+ </div>
85
+ </div>
86
+ );
87
+
88
+ case 'insights':
89
+ return (
90
+ <div>
91
+ {(s.keyInsights || []).map((ins, i) => (
92
+ <div key={i} className="insight-card">
93
+ <p className="insight-text">{ins.insight}</p>
94
+ <div className="insight-meta">
95
+ <ConfidenceBar value={ins.confidence} />
96
+ {ins.section && <Badge text={ins.section} color="gray" />}
97
+ </div>
98
+ </div>
99
+ ))}
100
+ {(!s.keyInsights || s.keyInsights.length === 0) && <p className="empty-note">No insights extracted</p>}
101
+ </div>
102
+ );
103
+
104
+ case 'entities':
105
+ return (
106
+ <div>
107
+ {s.entities ? (
108
+ <div className="entity-grid">
109
+ {s.entities.people?.length > 0 && (
110
+ <div className="entity-group">
111
+ <h4>People</h4>
112
+ <div className="tag-list">{s.entities.people.map((e, i) => <Badge key={i} text={e} color="blue" />)}</div>
113
+ </div>
114
+ )}
115
+ {s.entities.organizations?.length > 0 && (
116
+ <div className="entity-group">
117
+ <h4>Organizations</h4>
118
+ <div className="tag-list">{s.entities.organizations.map((e, i) => <Badge key={i} text={e} color="purple" />)}</div>
119
+ </div>
120
+ )}
121
+ {s.entities.dates?.length > 0 && (
122
+ <div className="entity-group">
123
+ <h4>Dates</h4>
124
+ <div className="tag-list">{s.entities.dates.map((e, i) => <Badge key={i} text={e} color="green" />)}</div>
125
+ </div>
126
+ )}
127
+ {s.entities.amounts?.length > 0 && (
128
+ <div className="entity-group">
129
+ <h4>Amounts/Statistics</h4>
130
+ <div className="tag-list">{s.entities.amounts.map((e, i) => <Badge key={i} text={e} color="yellow" />)}</div>
131
+ </div>
132
+ )}
133
+ {s.entities.locations?.length > 0 && (
134
+ <div className="entity-group">
135
+ <h4>Locations</h4>
136
+ <div className="tag-list">{s.entities.locations.map((e, i) => <Badge key={i} text={e} color="red" />)}</div>
137
+ </div>
138
+ )}
139
+ </div>
140
+ ) : <p className="empty-note">No entities extracted</p>}
141
+ </div>
142
+ );
143
+
144
+ case 'structure':
145
+ return (
146
+ <div>
147
+ {s.structureAnalysis ? (
148
+ <div>
149
+ <div className="structure-badges">
150
+ <Badge text={'Headings: ' + (s.structureAnalysis.hasHeadings ? 'Yes' : 'No')} color={s.structureAnalysis.hasHeadings ? 'green' : 'red'} />
151
+ <Badge text={'Sections: ~' + (s.structureAnalysis.estimatedSections || '?')} color="blue" />
152
+ <Badge text={'Quality: ' + (s.structureAnalysis.organizationQuality || 'N/A')} color={
153
+ s.structureAnalysis.organizationQuality === 'excellent' ? 'green' :
154
+ s.structureAnalysis.organizationQuality === 'good' ? 'blue' :
155
+ s.structureAnalysis.organizationQuality === 'fair' ? 'yellow' : 'red'
156
+ } />
157
+ </div>
158
+ {s.structureAnalysis.sectionNames?.length > 0 && (
159
+ <div className="section-names">
160
+ <h4>Sections Found:</h4>
161
+ <ul>{s.structureAnalysis.sectionNames.map((n, i) => <li key={i}>{n}</li>)}</ul>
162
+ </div>
163
+ )}
164
+ {s.structureAnalysis.organizationNotes && <p className="structure-notes">{s.structureAnalysis.organizationNotes}</p>}
165
+ </div>
166
+ ) : <p className="empty-note">No structure analysis available</p>}
167
+ </div>
168
+ );
169
+
170
+ case 'sentiment':
171
+ return (
172
+ <div>
173
+ {s.sentimentAnalysis ? (
174
+ <div>
175
+ <div className="sentiment-overview">
176
+ <Badge text={'Overall: ' + (s.sentimentAnalysis.overall || 'N/A')} color={
177
+ s.sentimentAnalysis.overall === 'positive' ? 'green' :
178
+ s.sentimentAnalysis.overall === 'negative' ? 'red' :
179
+ s.sentimentAnalysis.overall === 'mixed' ? 'yellow' : 'gray'
180
+ } />
181
+ <Badge text={'Tone: ' + (s.sentimentAnalysis.tone || 'N/A')} color="purple" />
182
+ </div>
183
+ {s.sentimentAnalysis.sectionSentiments?.length > 0 && (
184
+ <div className="section-sentiments">
185
+ <h4>By Section:</h4>
186
+ {s.sentimentAnalysis.sectionSentiments.map((ss, i) => (
187
+ <div key={i} className="sentiment-item">
188
+ <Badge text={ss.section} color="gray" />
189
+ <Badge text={ss.sentiment} color={ss.sentiment === 'positive' ? 'green' : ss.sentiment === 'negative' ? 'red' : 'gray'} />
190
+ {ss.note && <span className="sentiment-note">{ss.note}</span>}
191
+ </div>
192
+ ))}
193
+ </div>
194
+ )}
195
+ </div>
196
+ ) : <p className="empty-note">No sentiment analysis available</p>}
197
+ </div>
198
+ );
199
+
200
+ case 'facts':
201
+ return (
202
+ <div>
203
+ {s.factsVsOpinions ? (
204
+ <div>
205
+ <div className="facts-ratio">
206
+ <Badge text={'Ratio: ' + (s.factsVsOpinions.ratio || 'N/A')} color={
207
+ s.factsVsOpinions.ratio === 'mostly_factual' ? 'green' :
208
+ s.factsVsOpinions.ratio === 'balanced' ? 'blue' : 'yellow'
209
+ } />
210
+ </div>
211
+ {s.factsVsOpinions.factStatements?.length > 0 && (
212
+ <div className="facts-section">
213
+ <h4>Factual Statements:</h4>
214
+ <ul className="fact-list">{s.factsVsOpinions.factStatements.map((f, i) => <li key={i} className="fact-item">{f}</li>)}</ul>
215
+ </div>
216
+ )}
217
+ {s.factsVsOpinions.opinionStatements?.length > 0 && (
218
+ <div className="opinions-section">
219
+ <h4>Opinion Statements:</h4>
220
+ <ul className="opinion-list">{s.factsVsOpinions.opinionStatements.map((o, i) => <li key={i} className="opinion-item">{o}</li>)}</ul>
221
+ </div>
222
+ )}
223
+ </div>
224
+ ) : <p className="empty-note">No fact/opinion analysis available</p>}
225
+ </div>
226
+ );
227
+
228
+ case 'questions':
229
+ return (
230
+ <div>
231
+ {s.relatedQuestions?.length > 0 ? (
232
+ <div className="questions-list">
233
+ <p className="questions-intro">This document can help answer:</p>
234
+ {s.relatedQuestions.map((q, i) => (
235
+ <div key={i} className="question-item">
236
+ <span className="q-number">{i + 1}</span>
237
+ <span>{q}</span>
238
+ </div>
239
+ ))}
240
+ </div>
241
+ ) : <p className="empty-note">No related questions identified</p>}
242
+ </div>
243
+ );
244
+
245
+ case 'citations':
246
+ return (
247
+ <div>
248
+ {s.citationsCheck ? (
249
+ <div>
250
+ <div className="citations-overview">
251
+ <Badge text={'Has Citations: ' + (s.citationsCheck.hasCitations ? 'Yes' : 'No')} color={s.citationsCheck.hasCitations ? 'green' : 'red'} />
252
+ <Badge text={'Count: ' + (s.citationsCheck.citationCount || 0)} color="blue" />
253
+ <Badge text={'Quality: ' + (s.citationsCheck.quality || 'N/A')} color={
254
+ s.citationsCheck.quality === 'strong' ? 'green' :
255
+ s.citationsCheck.quality === 'adequate' ? 'blue' :
256
+ s.citationsCheck.quality === 'weak' ? 'yellow' : 'red'
257
+ } />
258
+ </div>
259
+ {s.citationsCheck.notes && <p className="citations-notes">{s.citationsCheck.notes}</p>}
260
+ </div>
261
+ ) : <p className="empty-note">No citation analysis available</p>}
262
+ </div>
263
+ );
264
+
265
+ default:
266
+ return null;
267
+ }
268
+ };
269
+
270
+ return (
271
+ <div className="analysis-panel">
272
+ <div className="tab-bar">
273
+ {tabs.map(tab => (
274
+ <button
275
+ key={tab.id}
276
+ className={'tab-btn' + (activeTab === tab.id ? ' active' : '')}
277
+ onClick={() => setActiveTab(tab.id)}
278
+ >
279
+ {tab.label}
280
+ </button>
281
+ ))}
282
+ </div>
283
+ <div className="tab-content">
284
+ {renderContent()}
285
+ </div>
286
+ </div>
287
+ );
288
+ }
289
+
290
  export default function App() {
291
  const [documents, setDocuments] = useState([]);
292
  const [messages, setMessages] = useState([]);
293
  const [input, setInput] = useState('');
294
  const [loading, setLoading] = useState(false);
295
  const [uploading, setUploading] = useState(false);
296
+ const [selectedDoc, setSelectedDoc] = useState(null);
297
  const [sessionId] = useState(() => crypto.randomUUID());
298
  const messagesEnd = useRef(null);
299
 
 
307
  setUploading(true);
308
  try {
309
  const data = await uploadDocument(file);
310
+ const doc = {
311
  id: data.document.id,
312
  filename: data.document.filename,
313
  summary: data.document.summary,
314
+ };
315
+ setDocuments(prev => [...prev, doc]);
316
+ setSelectedDoc(doc);
317
+ const tldr = data.document.summary?.summary?.oneSentence || data.document.summary?.summary || '';
318
  setMessages(prev => [...prev, {
319
  role: 'assistant',
320
+ content: 'Uploaded "' + data.document.filename + '" (' + data.document.chunkCount + ' chunks). ' + tldr,
321
  }]);
322
  } catch (err) {
323
  setMessages(prev => [...prev, {
324
  role: 'assistant',
325
+ content: 'Failed to upload ' + file.name + ': ' + err.message,
326
  }]);
327
  }
328
  setUploading(false);
 
335
  const handleRemoveDoc = async (id) => {
336
  await deleteDocument(id);
337
  setDocuments(prev => prev.filter(d => d.id !== id));
338
+ if (selectedDoc?.id === id) setSelectedDoc(null);
339
  };
340
 
341
  const handleAsk = async () => {
342
  if (!input.trim() || loading) return;
 
343
  const question = input.trim();
344
  setInput('');
345
  setMessages(prev => [...prev, { role: 'user', content: question }]);
346
  setLoading(true);
 
347
  try {
348
  const docIds = documents.map(d => d.id);
349
  const data = await askQuestion(question, docIds, sessionId);
 
355
  } catch (err) {
356
  setMessages(prev => [...prev, {
357
  role: 'assistant',
358
+ content: 'Error: ' + err.message,
359
  }]);
360
  }
361
  setLoading(false);
 
372
  <div className="app">
373
  <header className="header">
374
  <h1>Smart Document Search</h1>
375
+ <p>Upload documents. Get comprehensive AI analysis. Ask questions.</p>
376
  </header>
377
 
378
  <div className="layout">
 
392
  {documents.length > 0 && (
393
  <ul className="doc-list">
394
  {documents.map(d => (
395
+ <li key={d.id} className={'doc-item' + (selectedDoc?.id === d.id ? ' selected' : '')}>
396
+ <span className="doc-name" title={d.filename} onClick={() => setSelectedDoc(d)}>{d.filename}</span>
397
  <button className="doc-remove" onClick={() => handleRemoveDoc(d.id)}>x</button>
398
  </li>
399
  ))}
400
  </ul>
401
  )}
402
  </div>
403
+
404
+ {selectedDoc?.summary && (
405
+ <div className="sidebar-card analysis-card">
406
+ <h3>Analysis: {selectedDoc.filename}</h3>
407
+ <AnalysisPanel summary={selectedDoc.summary} />
408
+ </div>
409
+ )}
410
  </div>
411
 
412
  <div className="chat-area">
413
  <div className="messages">
414
  {messages.length === 0 && (
415
  <div className="empty-state">
416
+ <div className="icon">&#128196;</div>
417
  <p>Upload documents and start asking questions</p>
418
+ <p className="empty-sub">AI will analyze structure, entities, sentiment, readability, and more</p>
419
  </div>
420
  )}
421
  {messages.map((m, i) => (
422
+ <div key={i} className={'message ' + m.role}>
423
  <div className="bubble">{m.content}</div>
424
  {m.sources && m.sources.length > 0 && (
425
  <div className="sources">
frontend/src/styles/global.css CHANGED
@@ -3,13 +3,16 @@
3
  :root {
4
  --bg: #0f172a;
5
  --bg-card: #1e293b;
 
6
  --accent: #38bdf8;
7
  --accent2: #818cf8;
8
  --success: #34d399;
 
9
  --danger: #f87171;
10
  --text: #e2e8f0;
11
  --text-dim: #94a3b8;
12
  --border: #334155;
 
13
  }
14
 
15
  body {
@@ -19,37 +22,40 @@ body {
19
  min-height: 100vh;
20
  }
21
 
22
- .app { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; }
23
 
24
- .header { text-align: center; margin-bottom: 2rem; }
25
  .header h1 {
26
  font-size: 2.2rem; font-weight: 800;
27
  background: linear-gradient(135deg, #38bdf8, #818cf8);
28
  -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
29
  }
30
- .header p { color: var(--text-dim); margin-top: 0.4rem; }
31
 
32
  /* Layout */
33
- .layout { display: grid; grid-template-columns: 280px 1fr; gap: 1.5rem; }
34
 
35
  /* Sidebar */
36
- .sidebar { }
37
  .sidebar-card { background: var(--bg-card); border-radius: 12px; padding: 1.2rem; margin-bottom: 1rem; }
38
- .sidebar-card h3 { font-size: 0.95rem; margin-bottom: 0.8rem; }
 
39
 
40
  .dropzone {
41
- border: 2px dashed var(--border); border-radius: 8px; padding: 1.5rem 1rem;
42
  text-align: center; cursor: pointer; transition: all 0.3s;
43
  }
44
- .dropzone:hover { border-color: var(--accent); }
45
  .dropzone p { color: var(--text-dim); font-size: 0.85rem; }
46
 
47
- .doc-list { list-style: none; padding: 0; }
48
  .doc-item {
49
  display: flex; justify-content: space-between; align-items: center;
50
- padding: 0.5rem 0; border-bottom: 1px solid var(--border);
51
- font-size: 0.85rem;
52
  }
 
 
53
  .doc-item:last-child { border-bottom: none; }
54
  .doc-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
55
  .doc-remove {
@@ -57,12 +63,134 @@ body {
57
  cursor: pointer; font-size: 0.9rem; margin-left: 0.5rem;
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  /* Chat */
61
  .chat-area { display: flex; flex-direction: column; min-height: 500px; }
62
 
63
  .messages {
64
  flex: 1; background: var(--bg-card); border-radius: 12px; padding: 1rem;
65
- overflow-y: auto; max-height: 500px; margin-bottom: 1rem;
66
  }
67
 
68
  .message { margin-bottom: 1rem; }
@@ -73,7 +201,7 @@ body {
73
  }
74
  .message.assistant .bubble {
75
  background: var(--bg); padding: 0.7rem 1rem; border-radius: 12px 12px 12px 0;
76
- line-height: 1.6; max-width: 90%;
77
  }
78
  .message .sources {
79
  font-size: 0.8rem; color: var(--text-dim); margin-top: 0.4rem; font-style: italic;
@@ -93,6 +221,7 @@ body {
93
 
94
  .empty-state { text-align: center; padding: 3rem; color: var(--text-dim); }
95
  .empty-state .icon { font-size: 3rem; margin-bottom: 1rem; }
 
96
 
97
  .uploading { text-align: center; padding: 1rem; color: var(--text-dim); font-size: 0.9rem; }
98
 
@@ -102,7 +231,15 @@ body {
102
  }
103
  @keyframes spin { to { transform: rotate(360deg); } }
104
 
105
- @media (max-width: 700px) {
 
 
 
 
 
 
106
  .layout { grid-template-columns: 1fr; }
107
  .header h1 { font-size: 1.6rem; }
 
 
108
  }
 
3
  :root {
4
  --bg: #0f172a;
5
  --bg-card: #1e293b;
6
+ --bg-hover: #253348;
7
  --accent: #38bdf8;
8
  --accent2: #818cf8;
9
  --success: #34d399;
10
+ --warning: #fbbf24;
11
  --danger: #f87171;
12
  --text: #e2e8f0;
13
  --text-dim: #94a3b8;
14
  --border: #334155;
15
+ --border-light: #475569;
16
  }
17
 
18
  body {
 
22
  min-height: 100vh;
23
  }
24
 
25
+ .app { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
26
 
27
+ .header { text-align: center; margin-bottom: 1.5rem; }
28
  .header h1 {
29
  font-size: 2.2rem; font-weight: 800;
30
  background: linear-gradient(135deg, #38bdf8, #818cf8);
31
  -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
32
  }
33
+ .header p { color: var(--text-dim); margin-top: 0.3rem; font-size: 0.95rem; }
34
 
35
  /* Layout */
36
+ .layout { display: grid; grid-template-columns: 380px 1fr; gap: 1.5rem; }
37
 
38
  /* Sidebar */
39
+ .sidebar { max-height: calc(100vh - 120px); overflow-y: auto; }
40
  .sidebar-card { background: var(--bg-card); border-radius: 12px; padding: 1.2rem; margin-bottom: 1rem; }
41
+ .sidebar-card h3 { font-size: 0.95rem; margin-bottom: 0.8rem; color: var(--accent); }
42
+ .analysis-card { max-height: 600px; overflow-y: auto; }
43
 
44
  .dropzone {
45
+ border: 2px dashed var(--border); border-radius: 8px; padding: 1.2rem 1rem;
46
  text-align: center; cursor: pointer; transition: all 0.3s;
47
  }
48
+ .dropzone:hover { border-color: var(--accent); background: rgba(56,189,248,0.05); }
49
  .dropzone p { color: var(--text-dim); font-size: 0.85rem; }
50
 
51
+ .doc-list { list-style: none; padding: 0; margin-top: 0.5rem; }
52
  .doc-item {
53
  display: flex; justify-content: space-between; align-items: center;
54
+ padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border);
55
+ font-size: 0.85rem; cursor: pointer; border-radius: 6px; transition: background 0.2s;
56
  }
57
+ .doc-item:hover { background: var(--bg-hover); }
58
+ .doc-item.selected { background: rgba(56,189,248,0.1); border-left: 3px solid var(--accent); }
59
  .doc-item:last-child { border-bottom: none; }
60
  .doc-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
61
  .doc-remove {
 
63
  cursor: pointer; font-size: 0.9rem; margin-left: 0.5rem;
64
  }
65
 
66
+ /* Analysis Panel */
67
+ .analysis-panel { }
68
+
69
+ .tab-bar {
70
+ display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.8rem;
71
+ border-bottom: 1px solid var(--border); padding-bottom: 0.5rem;
72
+ }
73
+ .tab-btn {
74
+ background: none; border: none; color: var(--text-dim); font-size: 0.75rem;
75
+ padding: 0.35rem 0.6rem; cursor: pointer; border-radius: 6px; transition: all 0.2s;
76
+ white-space: nowrap;
77
+ }
78
+ .tab-btn:hover { color: var(--text); background: var(--bg-hover); }
79
+ .tab-btn.active { color: var(--accent); background: rgba(56,189,248,0.15); font-weight: 600; }
80
+ .tab-content { }
81
+
82
+ /* Sections */
83
+ .analysis-section { margin-bottom: 0.5rem; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
84
+ .section-header {
85
+ padding: 0.5rem 0.7rem; cursor: pointer; font-size: 0.82rem; font-weight: 600;
86
+ background: rgba(255,255,255,0.03); transition: background 0.2s; user-select: none;
87
+ }
88
+ .section-header:hover { background: var(--bg-hover); }
89
+ .section-body { padding: 0.6rem 0.7rem; font-size: 0.82rem; line-height: 1.5; }
90
+
91
+ /* Summary text */
92
+ .summary-text { color: var(--text); line-height: 1.6; }
93
+
94
+ /* Tags / Badges */
95
+ .tag-list { display: flex; flex-wrap: wrap; gap: 0.3rem; }
96
+ .badge {
97
+ display: inline-block; padding: 0.2rem 0.5rem; border-radius: 999px;
98
+ font-size: 0.72rem; font-weight: 600; white-space: nowrap;
99
+ }
100
+ .badge-blue { background: rgba(56,189,248,0.15); color: #38bdf8; }
101
+ .badge-purple { background: rgba(129,140,248,0.15); color: #818cf8; }
102
+ .badge-green { background: rgba(52,211,153,0.15); color: #34d399; }
103
+ .badge-yellow { background: rgba(251,191,36,0.15); color: #fbbf24; }
104
+ .badge-red { background: rgba(248,113,113,0.15); color: #f87171; }
105
+ .badge-gray { background: rgba(148,163,184,0.15); color: #94a3b8; }
106
+
107
+ /* Confidence bar */
108
+ .confidence-bar {
109
+ display: flex; align-items: center; gap: 0.5rem;
110
+ background: var(--bg); border-radius: 999px; height: 18px;
111
+ overflow: hidden; position: relative; flex: 1; max-width: 120px;
112
+ }
113
+ .confidence-fill {
114
+ height: 100%; border-radius: 999px; transition: width 0.5s ease;
115
+ min-width: 2px;
116
+ }
117
+ .confidence-bar span {
118
+ position: absolute; right: 6px; font-size: 0.65rem; font-weight: 700; color: white;
119
+ }
120
+
121
+ /* Insights */
122
+ .insight-card {
123
+ background: var(--bg); border-radius: 8px; padding: 0.6rem;
124
+ margin-bottom: 0.5rem; border-left: 3px solid var(--accent);
125
+ }
126
+ .insight-text { font-size: 0.82rem; margin-bottom: 0.4rem; }
127
+ .insight-meta { display: flex; align-items: center; gap: 0.5rem; }
128
+
129
+ /* Entities */
130
+ .entity-grid { display: flex; flex-direction: column; gap: 0.6rem; }
131
+ .entity-group h4 { font-size: 0.78rem; color: var(--text-dim); margin-bottom: 0.3rem; }
132
+
133
+ /* Structure */
134
+ .structure-badges { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.5rem; }
135
+ .section-names h4 { font-size: 0.78rem; color: var(--text-dim); margin-bottom: 0.3rem; }
136
+ .section-names ul { list-style: disc; padding-left: 1.2rem; font-size: 0.8rem; }
137
+ .section-names li { margin-bottom: 0.2rem; }
138
+ .structure-notes { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.4rem; font-style: italic; }
139
+
140
+ /* Sentiment */
141
+ .sentiment-overview { display: flex; gap: 0.4rem; margin-bottom: 0.6rem; }
142
+ .section-sentiments h4 { font-size: 0.78rem; color: var(--text-dim); margin-bottom: 0.3rem; }
143
+ .sentiment-item {
144
+ display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0;
145
+ border-bottom: 1px solid var(--border); font-size: 0.8rem;
146
+ }
147
+ .sentiment-item:last-child { border-bottom: none; }
148
+ .sentiment-note { color: var(--text-dim); font-size: 0.75rem; font-style: italic; }
149
+
150
+ /* Facts vs Opinions */
151
+ .facts-ratio { margin-bottom: 0.6rem; }
152
+ .facts-section h4, .opinions-section h4 { font-size: 0.78rem; color: var(--text-dim); margin-bottom: 0.3rem; }
153
+ .fact-list, .opinion-list { list-style: none; padding: 0; }
154
+ .fact-list li, .opinion-list li {
155
+ padding: 0.3rem 0.5rem; margin-bottom: 0.3rem; border-radius: 6px;
156
+ font-size: 0.8rem; line-height: 1.4;
157
+ }
158
+ .fact-item { background: rgba(52,211,153,0.08); border-left: 3px solid var(--success); }
159
+ .opinion-item { background: rgba(251,191,36,0.08); border-left: 3px solid var(--warning); }
160
+
161
+ /* Questions */
162
+ .questions-intro { font-size: 0.82rem; color: var(--text-dim); margin-bottom: 0.5rem; }
163
+ .question-item {
164
+ display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.4rem 0;
165
+ border-bottom: 1px solid var(--border); font-size: 0.82rem;
166
+ }
167
+ .question-item:last-child { border-bottom: none; }
168
+ .q-number {
169
+ background: var(--accent); color: var(--bg); border-radius: 50%;
170
+ width: 20px; height: 20px; display: flex; align-items: center;
171
+ justify-content: center; font-size: 0.7rem; font-weight: 700; flex-shrink: 0;
172
+ }
173
+
174
+ /* Citations */
175
+ .citations-overview { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.5rem; }
176
+ .citations-notes { font-size: 0.8rem; color: var(--text-dim); font-style: italic; }
177
+
178
+ /* Readability */
179
+ .readability-info { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
180
+ .readability-info p { width: 100%; margin-top: 0.3rem; font-size: 0.8rem; color: var(--text-dim); }
181
+
182
+ /* Doc meta */
183
+ .doc-meta { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border); }
184
+
185
+ /* Empty note */
186
+ .empty-note { color: var(--text-dim); font-size: 0.82rem; font-style: italic; text-align: center; padding: 1rem; }
187
+
188
  /* Chat */
189
  .chat-area { display: flex; flex-direction: column; min-height: 500px; }
190
 
191
  .messages {
192
  flex: 1; background: var(--bg-card); border-radius: 12px; padding: 1rem;
193
+ overflow-y: auto; max-height: 600px; margin-bottom: 1rem;
194
  }
195
 
196
  .message { margin-bottom: 1rem; }
 
201
  }
202
  .message.assistant .bubble {
203
  background: var(--bg); padding: 0.7rem 1rem; border-radius: 12px 12px 12px 0;
204
+ line-height: 1.6; max-width: 90%; white-space: pre-wrap;
205
  }
206
  .message .sources {
207
  font-size: 0.8rem; color: var(--text-dim); margin-top: 0.4rem; font-style: italic;
 
221
 
222
  .empty-state { text-align: center; padding: 3rem; color: var(--text-dim); }
223
  .empty-state .icon { font-size: 3rem; margin-bottom: 1rem; }
224
+ .empty-sub { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.7; }
225
 
226
  .uploading { text-align: center; padding: 1rem; color: var(--text-dim); font-size: 0.9rem; }
227
 
 
231
  }
232
  @keyframes spin { to { transform: rotate(360deg); } }
233
 
234
+ /* Scrollbar */
235
+ ::-webkit-scrollbar { width: 6px; }
236
+ ::-webkit-scrollbar-track { background: transparent; }
237
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
238
+ ::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
239
+
240
+ @media (max-width: 800px) {
241
  .layout { grid-template-columns: 1fr; }
242
  .header h1 { font-size: 1.6rem; }
243
+ .sidebar { max-height: none; }
244
+ .analysis-card { max-height: 400px; }
245
  }