ktejeshnaidu commited on
Commit
08220ff
Β·
verified Β·
1 Parent(s): 950ba0f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +717 -281
app.py CHANGED
@@ -1,359 +1,795 @@
1
  """
2
- DocuMind - Streamlit Frontend for HuggingFace Spaces
3
- Updated visually to match the specific dark-sidebar & green-chat UI mockup.
 
 
 
 
 
 
 
 
4
  """
5
 
6
- import streamlit as st
 
 
7
  import json
8
  import time
 
 
9
  import requests
 
 
10
 
11
  # ============================================================================
12
- # STREAMLIT CONFIG (Must be first)
13
  # ============================================================================
14
-
15
  st.set_page_config(
16
- page_title="DocuMind AI - Digital Curator",
17
  page_icon="🧠",
18
  layout="wide",
19
- initial_sidebar_state="expanded"
20
  )
21
 
 
22
  # ============================================================================
23
- # CUSTOM CSS INJECTION (Theming to match the new mockup)
24
  # ============================================================================
25
- custom_css = """
26
- <style>
27
- /* Global Font and Backgrounds */
28
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
29
-
30
- html, body, [class*="css"] {
31
- font-family: 'Inter', sans-serif;
32
- }
33
-
34
- /* Main App Background */
35
- .stApp {
36
- background-color: #F8F9FA;
37
- }
38
-
39
- /* Sidebar Background & Styling (Dark Theme) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  [data-testid="stSidebar"] {
41
- background-color: #172430 !important;
42
- border-right: none;
43
- }
44
- [data-testid="stSidebar"] * {
45
- color: #FFFFFF !important;
46
- }
47
-
48
- /* Hide default header and footer */
49
- header {visibility: hidden;}
50
- footer {visibility: hidden;}
51
-
52
- /* Sidebar Headers & Text */
53
- .sidebar-title {
54
- font-size: 1.25rem;
55
- font-weight: 700;
56
- color: #FFFFFF;
57
- margin-bottom: 2px;
58
  }
59
- .sidebar-subtitle {
60
- font-size: 0.8rem;
61
- color: #8A9CA8;
62
- margin-bottom: 25px;
63
- }
64
- .recent-header {
65
- font-size: 0.75rem;
66
- font-weight: 600;
67
- color: #8A9CA8;
68
- margin-top: 20px;
69
- margin-bottom: 15px;
70
- letter-spacing: 1px;
71
- }
72
- .recent-item {
73
- color: #D1D8DD;
74
- font-size: 0.95rem;
75
- padding: 8px 0;
76
- display: flex;
77
- align-items: center;
78
- gap: 10px;
79
- cursor: pointer;
80
  }
81
-
82
- /* Upload Box override for Dark Sidebar */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  [data-testid="stFileUploader"] {
84
- border: 1px solid #2B3B4A;
85
- border-radius: 12px;
86
- padding: 15px 10px;
87
- background-color: transparent;
88
- text-align: center;
89
  }
90
- [data-testid="stFileUploader"] > div > div {
91
- background-color: transparent;
 
 
 
92
  }
93
-
94
- /* Chat bubbles */
95
- [data-testid="stChatMessage"] {
96
- border-radius: 12px;
97
- padding: 15px 20px;
98
- margin-bottom: 10px;
99
- max-width: 85%;
100
  }
101
- /* Assistant Bubble */
102
- [data-testid="stChatMessage"]:has([data-testid="assistantAvatar"]) {
103
- background-color: #EEF0F2;
104
- color: #111827;
105
- margin-right: auto;
 
 
 
106
  }
107
- /* User Bubble */
108
- [data-testid="stChatMessage"]:has([data-testid="userAvatar"]) {
109
- background-color: #0C4A42;
110
- color: white !important;
111
- margin-left: auto;
112
  }
113
- [data-testid="stChatMessage"]:has([data-testid="userAvatar"]) p {
114
- color: white !important;
 
 
115
  }
116
 
117
- /* Welcome Header */
118
- .welcome-header {
119
- text-align: center;
120
- color: #111827;
121
- font-size: 2.5rem;
122
- font-weight: 700;
123
- margin-top: 60px;
124
- margin-bottom: 15px;
125
  }
126
- .welcome-sub {
 
 
 
 
 
 
 
 
 
 
 
 
127
  text-align: center;
128
- color: #4B5563;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  font-size: 1.1rem;
130
- margin-bottom: 50px;
131
- line-height: 1.5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  }
133
 
134
- /* Top Nav Badge */
135
- .top-badge-container {
136
  display: flex;
137
  justify-content: flex-end;
138
- padding: 10px 20px 0 0;
139
- }
140
- .status-badge {
141
- background-color: #E2E8F0;
142
- color: #475569;
143
- padding: 6px 12px;
144
- border-radius: 6px;
145
- font-size: 0.75rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  font-weight: 700;
147
- letter-spacing: 0.5px;
148
  }
149
- </style>
150
- """
151
- st.markdown(custom_css, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
 
154
  # ============================================================================
155
- # API CONFIGURATION & UTILITIES
156
  # ============================================================================
157
-
158
  API_URL = "http://127.0.0.1:8000"
159
- API_TIMEOUT = 30 # seconds
160
 
161
- def check_backend_health(retries=5):
162
- """Check if backend API is running"""
163
- for attempt in range(retries):
164
- try:
165
- response = requests.get(f"{API_URL}/docs", timeout=5)
166
- if response.status_code == 200:
167
- return True
168
- except:
169
- if attempt < retries - 1:
170
- time.sleep(1)
171
- return False
172
-
173
- def safe_api_call(method, endpoint, **kwargs):
174
- """Safely call API with error handling"""
 
 
 
 
 
 
 
 
 
 
 
 
175
  try:
 
 
 
 
 
 
 
 
 
176
  url = f"{API_URL}{endpoint}"
177
- kwargs.setdefault('timeout', API_TIMEOUT)
178
-
179
- if method == "GET":
180
- response = requests.get(url, **kwargs)
181
- elif method == "POST":
182
- response = requests.post(url, **kwargs)
183
- else:
184
- return None, "Invalid method"
185
-
186
- return response, None
187
  except requests.exceptions.Timeout:
188
  return None, "⏱️ Request timed out"
189
  except requests.exceptions.ConnectionError:
190
  return None, "⚠️ Backend not responding"
191
  except Exception as e:
192
- return None, f"❌ Error: {str(e)}"
193
 
194
- # Initialize Session States
195
- if "messages" not in st.session_state:
196
- st.session_state.messages = []
197
- if "backend_checked" not in st.session_state:
198
- st.session_state.backend_checked = False
199
- st.session_state.backend_healthy = False
200
 
201
- # Health check on load
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  if not st.session_state.backend_checked:
203
- st.session_state.backend_healthy = check_backend_health(retries=1)
 
 
204
  st.session_state.backend_checked = True
205
 
 
 
 
 
 
 
 
 
206
  # ============================================================================
207
- # UI: SIDEBAR
208
  # ============================================================================
209
  with st.sidebar:
210
- # Logo & Title
211
- st.markdown("""
212
- <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
213
- <div style="font-size: 2rem;">β—§</div>
214
- <div>
215
- <div class="sidebar-title">DocuMind AI</div>
216
- <div class="sidebar-subtitle">Digital Curator</div>
 
 
217
  </div>
218
  </div>
219
- """, unsafe_allow_html=True)
220
-
221
- # New Chat Button
222
- if st.button("οΌ‹ New Chat", use_container_width=True, type="primary"):
 
 
223
  st.session_state.messages = []
224
  st.rerun()
225
-
226
- st.write("") # Spacer
227
-
228
- # Upload Area
229
- uploaded_file = st.file_uploader("Upload Document", type=["txt", "pdf", "docx"], label_visibility="collapsed")
230
- # Custom badge for the limit underneath the uploader
231
- st.markdown("""
232
- <div style="text-align: center; margin-top: -15px; margin-bottom: 20px;">
233
- <span style="background-color: #2B3B4A; color: #8A9CA8; font-size: 0.7rem; padding: 3px 8px; border-radius: 12px; font-weight: 600;">200MB LIMIT</span>
234
- </div>
235
- """, unsafe_allow_html=True)
236
-
237
- if uploaded_file and st.button("Process Document", use_container_width=True):
238
- with st.spinner("Analyzing document..."):
239
  files = {"file": (uploaded_file.name, uploaded_file.getvalue())}
240
- response, error = safe_api_call("POST", "/ingest", files=files)
241
- if error:
242
- st.error(error)
243
- elif response and response.status_code == 200:
244
- st.success("βœ… Added to knowledge base!")
 
245
  else:
246
- st.error("❌ Upload failed.")
247
-
248
- st.divider()
249
-
250
- # Recents List
251
- st.markdown("<div class='recent-header'>RECENTS</div>", unsafe_allow_html=True)
252
-
253
- # Try fetching real recents, otherwise show static UI
254
- response, error = safe_api_call("GET", "/sources")
255
- if not error and response and response.status_code == 200:
256
- documents = response.json().get("documents", [])
257
- for doc in documents[:5]: # Show top 5
258
- st.markdown(f"<div class='recent-item'>πŸ•’ {doc[:20]}{'...' if len(doc)>20 else ''}</div>", unsafe_allow_html=True)
259
- else:
260
- # Static Mockup UI items based on the image
261
- st.markdown("<div class='recent-item'>πŸ•’ Q3 Financial Report</div>", unsafe_allow_html=True)
262
- st.markdown("<div class='recent-item'>πŸ•’ Product Roadmap 2024</div>", unsafe_allow_html=True)
263
- st.markdown("<div class='recent-item'>πŸ•’ Research Summary</div>", unsafe_allow_html=True)
 
 
264
 
265
  # ============================================================================
266
- # UI: MAIN INTERFACE
267
  # ============================================================================
268
-
269
- # Top Navigation Badge
270
- st.markdown("""
271
- <div class="top-badge-container">
272
- <span class="status-badge">CURRENT SESSION ACTIVE</span>
273
  </div>
274
- """, unsafe_allow_html=True)
 
 
 
275
 
276
- # Show backend warning cleanly if down
277
- if not st.session_state.backend_healthy:
278
- st.warning("⚠️ Backend API is offline. Starting up or unreachable.")
 
 
 
 
 
 
 
 
279
 
280
- # Welcome Screen (If no messages)
281
- if len(st.session_state.messages) == 0:
282
- st.markdown("<div class='welcome-header'>How can I assist your<br>research today?</div>", unsafe_allow_html=True)
283
- st.markdown("<div class='welcome-sub'>I can analyze documents, summarize findings, or<br>answer specific questions about your archived data.</div>", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
284
 
285
- # Container for chat messages
286
- chat_container = st.container()
 
 
 
 
287
 
288
- with chat_container:
289
- for msg in st.session_state.messages:
290
- is_user = msg["role"] == "user"
291
- # Hide the default user avatar to match the clean dark bubble look, use logo for assistant
292
- avatar_icon = "β—§" if not is_user else None
293
-
294
- with st.chat_message(msg["role"], avatar=avatar_icon):
295
- st.markdown(msg["content"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
- # ============================================================================
298
- # UI: CHAT INPUT
299
- # ============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
 
301
- st.write("") # Vertical spacing
302
 
 
 
303
  user_input = st.chat_input("Ask a question...")
304
 
305
  if user_input:
306
- # Add and display user message immediately
307
- st.session_state.messages.append({"role": "user", "content": user_input})
308
- with chat_container:
309
- with st.chat_message("user", avatar=None):
310
- st.markdown(user_input)
311
-
312
- # Get and display assistant response
313
- with chat_container:
314
- with st.chat_message("assistant", avatar="β—§"):
315
- placeholder = st.empty()
316
- full_response = ""
317
- sources = []
318
-
319
- response, error = safe_api_call(
320
- "POST",
321
- "/query",
322
- json={"question": user_input},
323
- stream=True
324
- )
325
-
326
- if error:
327
- st.error(error)
328
- full_response = error
329
- elif response:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  try:
331
- for line in response.iter_lines():
332
- if line:
333
- try:
334
- decoded_line = line.decode('utf-8')
335
- data = json.loads(decoded_line)
336
-
337
- if data.get("type") == "sources":
338
- sources = data.get("data", [])
339
- elif data.get("type") == "token":
340
- full_response += data.get("content", "")
341
- placeholder.markdown(full_response + "β–Œ")
342
- except json.JSONDecodeError:
343
- continue
344
-
345
- # Convert bullet points to checkmarks to match mockup design style
346
- styled_response = full_response.replace("- ", "βœ… ")
347
- placeholder.markdown(styled_response)
348
-
349
- except Exception as e:
350
- error_msg = f"❌ Error processing response: {str(e)}"
351
- placeholder.markdown(error_msg)
352
- full_response = error_msg
353
-
354
- # Save to state
355
- st.session_state.messages.append({
356
- "role": "assistant",
357
- "content": full_response,
358
- "sources": sources
359
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ DocuMind AI - Minimal Enterprise RAG Chat UI
3
+ Single-file Streamlit app matching a clean document-intelligence interface.
4
+
5
+ Features:
6
+ - Minimal light UI inspired by the provided reference image
7
+ - Left sidebar with New Chat + Upload
8
+ - Native Streamlit sidebar open/close toggle (top-left)
9
+ - Chat history with user/assistant bubbles
10
+ - Optional source citations from backend
11
+ - Works with an existing FastAPI backend
12
  """
13
 
14
+ from __future__ import annotations
15
+
16
+ import html
17
  import json
18
  import time
19
+ from datetime import datetime
20
+
21
  import requests
22
+ import streamlit as st
23
+
24
 
25
  # ============================================================================
26
+ # PAGE CONFIG
27
  # ============================================================================
 
28
  st.set_page_config(
29
+ page_title="DocuMind AI",
30
  page_icon="🧠",
31
  layout="wide",
32
+ initial_sidebar_state="expanded",
33
  )
34
 
35
+
36
  # ============================================================================
37
+ # THEME + MINIMAL CSS
38
  # ============================================================================
39
+ st.markdown(
40
+ """
41
+ <style>
42
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
43
+
44
+ :root {
45
+ --bg: #f6f8fb;
46
+ --panel: #ffffff;
47
+ --panel-soft: #f2f5f8;
48
+ --panel-dark: #0f4c5c;
49
+ --panel-dark-2: #0b3d49;
50
+ --text: #0f172a;
51
+ --muted: #667085;
52
+ --muted-2: #94a3b8;
53
+ --border: rgba(15, 23, 42, 0.08);
54
+ --shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
55
+ --radius-xl: 28px;
56
+ --radius-lg: 22px;
57
+ --radius-md: 16px;
58
+ --radius-sm: 12px;
59
+ --accent: #0f4c5c;
60
+ --accent-soft: rgba(15, 76, 92, 0.10);
61
+ }
62
+
63
+ html, body, [data-testid="stAppViewContainer"] {
64
+ background: var(--bg) !important;
65
+ color: var(--text) !important;
66
+ font-family: 'Inter', sans-serif !important;
67
+ }
68
+
69
+ *, *::before, *::after { box-sizing: border-box; }
70
+
71
+ /* Hide Streamlit chrome */
72
+ #MainMenu, footer, header, [data-testid="stToolbar"], [data-testid="stDecoration"] {
73
+ display: none !important;
74
+ }
75
+
76
+ /* Sidebar */
77
  [data-testid="stSidebar"] {
78
+ background: linear-gradient(180deg, #f8fafc 0%, #f5f7fa 100%) !important;
79
+ border-right: 1px solid var(--border) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  }
81
+
82
+ [data-testid="stSidebarContent"] {
83
+ padding-top: 0 !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  }
85
+
86
+ /* Main container */
87
+ .main .block-container {
88
+ padding: 0 !important;
89
+ max-width: 100% !important;
90
+ }
91
+
92
+ /* Sidebar collapse button */
93
+ [data-testid="collapsedControl"] {
94
+ color: #0f172a !important;
95
+ background: rgba(255,255,255,0.75) !important;
96
+ border: 1px solid var(--border) !important;
97
+ border-radius: 999px !important;
98
+ box-shadow: var(--shadow) !important;
99
+ }
100
+
101
+ /* Buttons */
102
+ .stButton > button {
103
+ border-radius: 18px !important;
104
+ border: 1px solid var(--border) !important;
105
+ background: var(--panel) !important;
106
+ color: var(--text) !important;
107
+ font-family: 'Inter', sans-serif !important;
108
+ font-weight: 600 !important;
109
+ transition: 0.18s ease !important;
110
+ box-shadow: none !important;
111
+ width: 100% !important;
112
+ padding: 0.8rem 1rem !important;
113
+ }
114
+ .stButton > button:hover {
115
+ transform: translateY(-1px) !important;
116
+ border-color: rgba(15, 76, 92, 0.18) !important;
117
+ background: rgba(15, 76, 92, 0.04) !important;
118
+ }
119
+
120
+ /* Sidebar file uploader */
121
  [data-testid="stFileUploader"] {
122
+ background: var(--panel) !important;
123
+ border: 1px dashed rgba(15, 23, 42, 0.10) !important;
124
+ border-radius: 18px !important;
125
+ padding: 10px !important;
126
+ box-shadow: none !important;
127
  }
128
+ [data-testid="stFileUploader"] label,
129
+ [data-testid="stFileUploadDropzone"] p,
130
+ [data-testid="stFileUploadDropzone"] span {
131
+ color: var(--muted) !important;
132
+ font-family: 'Inter', sans-serif !important;
133
  }
134
+
135
+ /* Chat input */
136
+ [data-testid="stChatInput"] {
137
+ background: transparent !important;
 
 
 
138
  }
139
+ [data-testid="stChatInput"] textarea {
140
+ background: var(--panel) !important;
141
+ border-radius: 24px !important;
142
+ border: 1px solid rgba(15, 23, 42, 0.08) !important;
143
+ box-shadow: var(--shadow) !important;
144
+ font-family: 'Inter', sans-serif !important;
145
+ padding: 1rem 1rem 1rem 1rem !important;
146
+ color: var(--text) !important;
147
  }
148
+ [data-testid="stChatInput"] textarea::placeholder {
149
+ color: var(--muted-2) !important;
 
 
 
150
  }
151
+ [data-testid="stChatInput"] button {
152
+ background: var(--accent) !important;
153
+ color: #fff !important;
154
+ border-radius: 999px !important;
155
  }
156
 
157
+ /* Spacers */
158
+ hr {
159
+ border: none !important;
160
+ border-top: 1px solid rgba(15, 23, 42, 0.06) !important;
161
+ margin: 0 !important;
 
 
 
162
  }
163
+
164
+ /* Scrollbar */
165
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
166
+ ::-webkit-scrollbar-track { background: transparent; }
167
+ ::-webkit-scrollbar-thumb { background: rgba(100, 116, 139, 0.25); border-radius: 999px; }
168
+ ::-webkit-scrollbar-thumb:hover { background: rgba(100, 116, 139, 0.45); }
169
+
170
+ /* Hero */
171
+ .hero-wrap {
172
+ display: flex;
173
+ flex-direction: column;
174
+ align-items: center;
175
+ justify-content: center;
176
  text-align: center;
177
+ padding: 5rem 1.25rem 2rem;
178
+ min-height: 48vh;
179
+ }
180
+ .hero-title {
181
+ font-size: clamp(2rem, 3.5vw, 3.5rem);
182
+ line-height: 1.08;
183
+ font-weight: 800;
184
+ letter-spacing: -0.04em;
185
+ color: #0f172a;
186
+ margin: 0 0 1rem 0;
187
+ }
188
+ .hero-subtitle {
189
+ max-width: 760px;
190
+ font-size: 1.02rem;
191
+ line-height: 1.65;
192
+ color: var(--muted);
193
+ margin: 0;
194
+ }
195
+
196
+ /* Session header */
197
+ .session-row {
198
+ display: flex;
199
+ align-items: center;
200
+ gap: 0.75rem;
201
+ padding: 1rem 1.25rem 0.85rem 1.25rem;
202
+ }
203
+ .session-label {
204
+ font-size: 0.95rem;
205
+ font-weight: 800;
206
+ letter-spacing: 0.02em;
207
+ color: #0f4c5c;
208
+ text-transform: uppercase;
209
+ }
210
+ .session-badge {
211
+ display: inline-flex;
212
+ align-items: center;
213
+ padding: 0.28rem 0.65rem;
214
+ border-radius: 999px;
215
+ background: rgba(15, 76, 92, 0.10);
216
+ color: #0f4c5c;
217
+ font-size: 0.7rem;
218
+ font-weight: 800;
219
+ letter-spacing: 0.08em;
220
+ }
221
+
222
+ /* Sidebar content blocks */
223
+ .sidebar-brand {
224
+ padding: 1.1rem 1rem 1rem;
225
+ border-bottom: 1px solid rgba(15, 23, 42, 0.06);
226
+ }
227
+ .brand-row {
228
+ display: flex;
229
+ align-items: center;
230
+ gap: 0.75rem;
231
+ }
232
+ .brand-icon {
233
+ width: 44px;
234
+ height: 44px;
235
+ border-radius: 14px;
236
+ background: linear-gradient(135deg, #0f4c5c 0%, #116579 100%);
237
+ color: white;
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: center;
241
  font-size: 1.1rem;
242
+ box-shadow: var(--shadow);
243
+ }
244
+ .brand-title {
245
+ font-size: 1.05rem;
246
+ font-weight: 800;
247
+ color: #0f172a;
248
+ line-height: 1.1;
249
+ }
250
+ .brand-subtitle {
251
+ font-size: 0.78rem;
252
+ color: var(--muted-2);
253
+ margin-top: 0.15rem;
254
+ }
255
+
256
+ .sidebar-section {
257
+ padding: 1rem;
258
+ }
259
+ .sidebar-heading {
260
+ font-size: 0.72rem;
261
+ font-weight: 800;
262
+ letter-spacing: 0.12em;
263
+ text-transform: uppercase;
264
+ color: #64748b;
265
+ margin: 0 0 0.6rem 0;
266
+ }
267
+ .recents-item {
268
+ display: flex;
269
+ align-items: center;
270
+ gap: 0.6rem;
271
+ padding: 0.65rem 0.2rem;
272
+ color: #334155;
273
+ font-size: 0.92rem;
274
+ border-radius: 12px;
275
+ }
276
+ .recents-item span.icon {
277
+ color: #64748b;
278
+ font-size: 0.85rem;
279
  }
280
 
281
+ /* Message bubbles */
282
+ .msg-user-wrap {
283
  display: flex;
284
  justify-content: flex-end;
285
+ margin: 0.8rem 0 0.25rem;
286
+ }
287
+ .msg-user {
288
+ max-width: min(720px, 66%);
289
+ background: linear-gradient(180deg, #0f4c5c 0%, #0b3d49 100%);
290
+ color: #fff;
291
+ border-radius: 22px 22px 6px 22px;
292
+ padding: 1rem 1.1rem;
293
+ box-shadow: var(--shadow);
294
+ font-size: 0.98rem;
295
+ line-height: 1.65;
296
+ border: 1px solid rgba(255,255,255,0.08);
297
+ white-space: pre-wrap;
298
+ word-break: break-word;
299
+ }
300
+ .msg-bot-row {
301
+ display: flex;
302
+ gap: 0.75rem;
303
+ align-items: flex-start;
304
+ margin: 1rem 0 0.3rem;
305
+ }
306
+ .bot-avatar {
307
+ width: 36px;
308
+ height: 36px;
309
+ border-radius: 12px;
310
+ background: linear-gradient(135deg, #0f4c5c 0%, #116579 100%);
311
+ color: #fff;
312
+ display: flex;
313
+ align-items: center;
314
+ justify-content: center;
315
+ font-size: 1rem;
316
+ flex-shrink: 0;
317
+ box-shadow: var(--shadow);
318
+ }
319
+ .msg-bot {
320
+ max-width: min(820px, 72%);
321
+ background: #eef2f6;
322
+ color: #0f172a;
323
+ border: 1px solid rgba(15, 23, 42, 0.06);
324
+ border-radius: 8px 22px 22px 22px;
325
+ padding: 1rem 1.1rem;
326
+ font-size: 0.98rem;
327
+ line-height: 1.7;
328
+ white-space: normal;
329
+ word-break: break-word;
330
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.03);
331
+ }
332
+ .msg-time {
333
+ font-size: 0.72rem;
334
+ color: #94a3b8;
335
+ margin-top: 0.35rem;
336
+ }
337
+ .typing-cursor {
338
+ opacity: 0.85;
339
+ }
340
+
341
+ /* Source cards */
342
+ .source-card {
343
+ background: #fff;
344
+ border: 1px solid rgba(15, 23, 42, 0.07);
345
+ border-radius: 16px;
346
+ padding: 0.9rem 1rem;
347
+ margin: 0.45rem 0;
348
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.03);
349
+ }
350
+ .source-head {
351
+ display: flex;
352
+ justify-content: space-between;
353
+ align-items: center;
354
+ margin-bottom: 0.35rem;
355
+ font-size: 0.8rem;
356
  font-weight: 700;
 
357
  }
358
+ .source-title { color: #0f4c5c; }
359
+ .source-score {
360
+ color: #64748b;
361
+ background: #f1f5f9;
362
+ padding: 0.1rem 0.45rem;
363
+ border-radius: 999px;
364
+ font-weight: 700;
365
+ }
366
+ .source-file {
367
+ font-size: 0.78rem;
368
+ color: #64748b;
369
+ margin-bottom: 0.4rem;
370
+ }
371
+ .source-snippet {
372
+ font-size: 0.86rem;
373
+ line-height: 1.55;
374
+ color: #334155;
375
+ border-left: 2px solid rgba(15, 76, 92, 0.18);
376
+ padding-left: 0.7rem;
377
+ }
378
+
379
+ /* Bottom spacing so chat input doesn't overlap content */
380
+ .bottom-space {
381
+ height: 1.75rem;
382
+ }
383
+ </style>
384
+ """,
385
+ unsafe_allow_html=True,
386
+ )
387
 
388
 
389
  # ============================================================================
390
+ # CONFIG
391
  # ============================================================================
 
392
  API_URL = "http://127.0.0.1:8000"
393
+ API_TIMEOUT = 60
394
 
395
+
396
+ # ============================================================================
397
+ # SESSION STATE
398
+ # ============================================================================
399
+ if "messages" not in st.session_state:
400
+ st.session_state.messages = []
401
+ if "backend_ok" not in st.session_state:
402
+ st.session_state.backend_ok = False
403
+ if "backend_checked" not in st.session_state:
404
+ st.session_state.backend_checked = False
405
+ if "chat_sessions" not in st.session_state:
406
+ st.session_state.chat_sessions = [
407
+ "Q3 Financial Report",
408
+ "Product Roadmap 2024",
409
+ "Research Summary",
410
+ ]
411
+ if "ingested_docs" not in st.session_state:
412
+ st.session_state.ingested_docs = []
413
+ if "show_sources" not in st.session_state:
414
+ st.session_state.show_sources = True
415
+
416
+
417
+ # ============================================================================
418
+ # HELPERS
419
+ # ============================================================================
420
+ def check_backend() -> bool:
421
  try:
422
+ r = requests.get(f"{API_URL}/docs", timeout=5)
423
+ return r.status_code == 200
424
+ except Exception:
425
+ return False
426
+
427
+
428
+ def api_call(method: str, endpoint: str, **kwargs):
429
+ try:
430
+ kwargs.setdefault("timeout", API_TIMEOUT)
431
  url = f"{API_URL}{endpoint}"
432
+ fn = requests.get if method.upper() == "GET" else requests.post
433
+ return fn(url, **kwargs), None
 
 
 
 
 
 
 
 
434
  except requests.exceptions.Timeout:
435
  return None, "⏱️ Request timed out"
436
  except requests.exceptions.ConnectionError:
437
  return None, "⚠️ Backend not responding"
438
  except Exception as e:
439
+ return None, f"Error: {e}"
440
 
 
 
 
 
 
 
441
 
442
+ def now_time() -> str:
443
+ return datetime.now().strftime("%-I:%M %p")
444
+
445
+
446
+ def esc(text: str) -> str:
447
+ return html.escape(text or "")
448
+
449
+
450
+ def render_text_block(text: str) -> str:
451
+ """Convert plain text to safe HTML for display in a bubble."""
452
+ safe = esc(text).replace("\n", "<br>")
453
+ return safe
454
+
455
+
456
+ def render_assistant_text(text: str) -> str:
457
+ """Render assistant content with lightweight bullet styling."""
458
+ lines = (text or "").splitlines()
459
+ out = []
460
+ for raw in lines:
461
+ line = raw.strip()
462
+ if not line:
463
+ continue
464
+
465
+ if line.startswith(("- ", "β€’ ", "* ")):
466
+ item = esc(line[2:])
467
+ if ":" in item:
468
+ key, val = item.split(":", 1)
469
+ out.append(
470
+ f"<div style='display:flex;gap:0.6rem;align-items:flex-start;margin:0.35rem 0;'>"
471
+ f"<span style='color:#0f4c5c;font-weight:900;line-height:1.2;'>βœ“</span>"
472
+ f"<span><strong>{key.strip()}:</strong> {val.strip()}</span>"
473
+ f"</div>"
474
+ )
475
+ else:
476
+ out.append(
477
+ f"<div style='display:flex;gap:0.6rem;align-items:flex-start;margin:0.35rem 0;'>"
478
+ f"<span style='color:#0f4c5c;font-weight:900;line-height:1.2;'>βœ“</span>"
479
+ f"<span>{item}</span>"
480
+ f"</div>"
481
+ )
482
+ else:
483
+ out.append(f"<div style='margin:0.22rem 0;'>{esc(line)}</div>")
484
+ return "".join(out)
485
+
486
+
487
+ # ============================================================================
488
+ # BOOTSTRAP
489
+ # ============================================================================
490
  if not st.session_state.backend_checked:
491
+ with st.spinner("Starting up..."):
492
+ time.sleep(1.2)
493
+ st.session_state.backend_ok = check_backend()
494
  st.session_state.backend_checked = True
495
 
496
+ try:
497
+ resp, _ = api_call("GET", "/sources")
498
+ if resp and resp.status_code == 200:
499
+ st.session_state.ingested_docs = resp.json().get("documents", [])
500
+ except Exception:
501
+ pass
502
+
503
+
504
  # ============================================================================
505
+ # SIDEBAR
506
  # ============================================================================
507
  with st.sidebar:
508
+ st.markdown(
509
+ """
510
+ <div class="sidebar-brand">
511
+ <div class="brand-row">
512
+ <div class="brand-icon">🧠</div>
513
+ <div>
514
+ <div class="brand-title">DocuMind AI</div>
515
+ <div class="brand-subtitle">Digital Curator</div>
516
+ </div>
517
  </div>
518
  </div>
519
+ """,
520
+ unsafe_allow_html=True,
521
+ )
522
+
523
+ st.markdown('<div class="sidebar-section">', unsafe_allow_html=True)
524
+ if st.button("οΌ‹ New Chat", key="new_chat"):
525
  st.session_state.messages = []
526
  st.rerun()
527
+ st.markdown("</div>", unsafe_allow_html=True)
528
+
529
+ st.markdown('<div class="sidebar-section" style="padding-top:0;">', unsafe_allow_html=True)
530
+ st.markdown('<div class="sidebar-heading">Upload</div>', unsafe_allow_html=True)
531
+ uploaded_file = st.file_uploader(
532
+ "Upload document",
533
+ type=["txt", "pdf", "docx"],
534
+ label_visibility="collapsed",
535
+ )
536
+ if uploaded_file and st.button("Ingest Document", key="ingest_btn"):
537
+ with st.spinner("Processing..."):
 
 
 
538
  files = {"file": (uploaded_file.name, uploaded_file.getvalue())}
539
+ resp, err = api_call("POST", "/ingest", files=files)
540
+ if err:
541
+ st.error(err)
542
+ elif resp and resp.status_code == 200:
543
+ st.success("Ingested successfully")
544
+ st.session_state.ingested_docs.append(uploaded_file.name)
545
  else:
546
+ detail = "Unknown error"
547
+ try:
548
+ detail = resp.json().get("detail", resp.text)
549
+ except Exception:
550
+ if resp is not None:
551
+ detail = resp.text
552
+ st.error(detail)
553
+ st.markdown("</div>", unsafe_allow_html=True)
554
+
555
+ st.markdown('<div class="sidebar-section">', unsafe_allow_html=True)
556
+ st.markdown('<div class="sidebar-heading">Recents</div>', unsafe_allow_html=True)
557
+ recent_items = st.session_state.ingested_docs or st.session_state.chat_sessions
558
+ for item in recent_items[:6]:
559
+ label = item if len(item) <= 26 else item[:23] + "..."
560
+ st.markdown(
561
+ f"<div class='recents-item'><span class='icon'>πŸ•˜</span><span>{esc(label)}</span></div>",
562
+ unsafe_allow_html=True,
563
+ )
564
+ st.markdown("</div>", unsafe_allow_html=True)
565
+
566
 
567
  # ============================================================================
568
+ # MAIN UI
569
  # ============================================================================
570
+ st.markdown(
571
+ """
572
+ <div class="session-row">
573
+ <div class="session-label">CURRENT SESSION</div>
574
+ <div class="session-badge">ACTIVE</div>
575
  </div>
576
+ <hr>
577
+ """,
578
+ unsafe_allow_html=True,
579
+ )
580
 
581
+ if not st.session_state.backend_ok:
582
+ st.markdown(
583
+ """
584
+ <div style="margin:1rem 1.25rem 0; padding:0.85rem 1rem; border-radius:16px;
585
+ border:1px solid rgba(245, 158, 11, 0.18); background: rgba(245, 158, 11, 0.08);
586
+ color:#b45309; font-size:0.9rem;">
587
+ ⚠️ Backend is starting up. If it still shows after a minute, refresh the page.
588
+ </div>
589
+ """,
590
+ unsafe_allow_html=True,
591
+ )
592
 
593
+ # Empty state
594
+ if not st.session_state.messages:
595
+ st.markdown(
596
+ """
597
+ <div class="hero-wrap">
598
+ <h1 class="hero-title">How can I assist your research today?</h1>
599
+ <p class="hero-subtitle">
600
+ I can analyze documents, summarize findings, or answer specific questions about your archived data.
601
+ </p>
602
+ </div>
603
+ """,
604
+ unsafe_allow_html=True,
605
+ )
606
 
607
+ # Chat history
608
+ for msg in st.session_state.messages:
609
+ role = msg.get("role", "assistant")
610
+ content = msg.get("content", "")
611
+ ts = msg.get("time", "")
612
+ sources = msg.get("sources", [])
613
 
614
+ if role == "user":
615
+ st.markdown(
616
+ f"""
617
+ <div class="msg-user-wrap">
618
+ <div>
619
+ <div class="msg-user">{render_text_block(content)}</div>
620
+ <div class="msg-time" style="text-align:right; padding-right:0.4rem;">{esc(ts)}</div>
621
+ </div>
622
+ </div>
623
+ """,
624
+ unsafe_allow_html=True,
625
+ )
626
+ else:
627
+ st.markdown(
628
+ f"""
629
+ <div class="msg-bot-row">
630
+ <div class="bot-avatar">🧠</div>
631
+ <div>
632
+ <div class="msg-bot">{render_assistant_text(content)}</div>
633
+ <div class="msg-time">{esc(ts)}</div>
634
+ </div>
635
+ </div>
636
+ """,
637
+ unsafe_allow_html=True,
638
+ )
639
 
640
+ if sources and st.session_state.show_sources:
641
+ with st.expander(f"πŸ“š {len(sources)} source(s) cited"):
642
+ for i, src in enumerate(sources, start=1):
643
+ score = float(src.get("score", 0) or 0)
644
+ source_name = esc(str(src.get("source", "Unknown")))
645
+ snippet = esc(str(src.get("content", ""))[:400])
646
+ if len(str(src.get("content", ""))) > 400:
647
+ snippet += "..."
648
+
649
+ st.markdown(
650
+ f"""
651
+ <div class="source-card">
652
+ <div class="source-head">
653
+ <span class="source-title">Source {i}</span>
654
+ <span class="source-score">{score:.0%} match</span>
655
+ </div>
656
+ <div class="source-file">πŸ“„ {source_name}</div>
657
+ <div class="source-snippet">{snippet}</div>
658
+ </div>
659
+ """,
660
+ unsafe_allow_html=True,
661
+ )
662
 
 
663
 
664
+ # Chat input
665
+ st.markdown('<div class="bottom-space"></div>', unsafe_allow_html=True)
666
  user_input = st.chat_input("Ask a question...")
667
 
668
  if user_input:
669
+ user_ts = now_time()
670
+ st.session_state.messages.append({"role": "user", "content": user_input, "time": user_ts})
671
+
672
+ # Render user bubble immediately
673
+ st.markdown(
674
+ f"""
675
+ <div class="msg-user-wrap">
676
+ <div>
677
+ <div class="msg-user">{render_text_block(user_input)}</div>
678
+ <div class="msg-time" style="text-align:right; padding-right:0.4rem;">{esc(user_ts)}</div>
679
+ </div>
680
+ </div>
681
+ """,
682
+ unsafe_allow_html=True,
683
+ )
684
+
685
+ full_response = ""
686
+ sources: list[dict] = []
687
+ assistant_placeholder = st.empty()
688
+
689
+ # Assistant shell
690
+ st.markdown(
691
+ """
692
+ <div class="msg-bot-row">
693
+ <div class="bot-avatar">🧠</div>
694
+ <div style="width:100%;">
695
+ """,
696
+ unsafe_allow_html=True,
697
+ )
698
+
699
+ resp, err = api_call("POST", "/query", json={"question": user_input}, stream=True)
700
+
701
+ if err:
702
+ full_response = err
703
+ assistant_placeholder.markdown(
704
+ f"""
705
+ <div class="msg-bot" style="border-color: rgba(239, 68, 68, 0.25); color:#b91c1c; background:#fff5f5;">
706
+ {esc(err)}
707
+ </div>
708
+ """,
709
+ unsafe_allow_html=True,
710
+ )
711
+ elif resp:
712
+ try:
713
+ buf = ""
714
+ for line in resp.iter_lines():
715
+ if not line:
716
+ continue
717
  try:
718
+ data = json.loads(line.decode("utf-8"))
719
+ except json.JSONDecodeError:
720
+ continue
721
+
722
+ if data.get("type") == "sources":
723
+ sources = data.get("data", []) or []
724
+ elif data.get("type") == "token":
725
+ buf += data.get("content", "")
726
+ full_response = buf
727
+ assistant_placeholder.markdown(
728
+ f"""
729
+ <div class="msg-bot">{render_assistant_text(buf)}<span class="typing-cursor">β–Œ</span></div>
730
+ """,
731
+ unsafe_allow_html=True,
732
+ )
733
+
734
+ assistant_placeholder.markdown(
735
+ f"""
736
+ <div class="msg-bot">{render_assistant_text(full_response)}</div>
737
+ """,
738
+ unsafe_allow_html=True,
739
+ )
740
+ except Exception as e:
741
+ full_response = f"Error: {e}"
742
+ assistant_placeholder.markdown(
743
+ f"""
744
+ <div class="msg-bot" style="border-color: rgba(239, 68, 68, 0.25); color:#b91c1c; background:#fff5f5;">
745
+ {esc(full_response)}
746
+ </div>
747
+ """,
748
+ unsafe_allow_html=True,
749
+ )
750
+
751
+ st.markdown("</div></div>", unsafe_allow_html=True)
752
+
753
+ if sources and st.session_state.show_sources:
754
+ with st.expander(f"πŸ“š {len(sources)} source(s) cited"):
755
+ for i, src in enumerate(sources, start=1):
756
+ score = float(src.get("score", 0) or 0)
757
+ source_name = esc(str(src.get("source", "Unknown")))
758
+ snippet = esc(str(src.get("content", ""))[:400])
759
+ if len(str(src.get("content", ""))) > 400:
760
+ snippet += "..."
761
+
762
+ st.markdown(
763
+ f"""
764
+ <div class="source-card">
765
+ <div class="source-head">
766
+ <span class="source-title">Source {i}</span>
767
+ <span class="source-score">{score:.0%} match</span>
768
+ </div>
769
+ <div class="source-file">πŸ“„ {source_name}</div>
770
+ <div class="source-snippet">{snippet}</div>
771
+ </div>
772
+ """,
773
+ unsafe_allow_html=True,
774
+ )
775
+
776
+ bot_ts = now_time()
777
+ st.session_state.messages.append(
778
+ {
779
+ "role": "assistant",
780
+ "content": full_response,
781
+ "sources": sources,
782
+ "time": bot_ts,
783
+ }
784
+ )
785
+
786
+
787
+ # ============================================================================
788
+ # FOOTER NOTE
789
+ # ============================================================================
790
+ st.markdown(
791
+ """
792
+ <div style="height:1.5rem;"></div>
793
+ """,
794
+ unsafe_allow_html=True,
795
+ )