internomega-terrablue commited on
Commit
e90d887
·
1 Parent(s): 4bc41f5

change to gradio

Browse files
Dockerfile DELETED
@@ -1,21 +0,0 @@
1
- FROM python:3.12-slim
2
-
3
- WORKDIR /app
4
-
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- curl \
8
- && rm -rf /var/lib/apt/lists/*
9
-
10
- COPY requirements.txt ./
11
- RUN pip install --no-cache-dir -r requirements.txt
12
-
13
- COPY . .
14
-
15
- RUN chmod +x /app/entrypoint.sh
16
-
17
- EXPOSE 8501
18
-
19
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
20
-
21
- ENTRYPOINT ["/app/entrypoint.sh"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,19 +1,21 @@
1
  ---
2
  title: NotebookLM
3
  emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
 
 
8
  hf_oauth: true
9
  hf_oauth_expiration_minutes: 480
10
  tags:
11
- - streamlit
12
  pinned: false
13
- short_description: Notebook LM clone
14
  license: mit
15
  ---
16
 
17
  # NotebookLM Clone
18
 
19
- AI-powered study companion built with Streamlit on Hugging Face Spaces.
 
1
  ---
2
  title: NotebookLM
3
  emoji: 🚀
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: "5.12.0"
8
+ python_version: "3.12"
9
+ app_file: app.py
10
  hf_oauth: true
11
  hf_oauth_expiration_minutes: 480
12
  tags:
13
+ - gradio
14
  pinned: false
15
+ short_description: NotebookLM - AI-Powered Study Companion
16
  license: mit
17
  ---
18
 
19
  # NotebookLM Clone
20
 
21
+ AI-powered study companion built with Gradio on Hugging Face Spaces.
app.py CHANGED
@@ -1,721 +1,474 @@
1
- import streamlit as st
2
- import uuid
3
- import base64
4
- import os
5
- from datetime import datetime
6
- from auth import require_auth
7
-
8
- # ── Logo ─────────────────────────────────────────────────────────────────────
9
- LOGO_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 60">
10
- <defs>
11
- <linearGradient id="lg1" x1="0%" y1="0%" x2="100%" y2="100%">
12
- <stop offset="0%" style="stop-color:#667eea"/>
13
- <stop offset="100%" style="stop-color:#764ba2"/>
14
- </linearGradient>
15
- <linearGradient id="lg2" x1="0%" y1="0%" x2="100%" y2="100%">
16
- <stop offset="0%" style="stop-color:#a78bfa"/>
17
- <stop offset="100%" style="stop-color:#667eea"/>
18
- </linearGradient>
19
- <linearGradient id="sp" x1="0%" y1="0%" x2="100%" y2="100%">
20
- <stop offset="0%" style="stop-color:#fbbf24"/>
21
- <stop offset="100%" style="stop-color:#f59e0b"/>
22
- </linearGradient>
23
- </defs>
24
- <g transform="translate(4,6)">
25
- <rect x="2" y="4" width="36" height="44" rx="4" fill="url(#lg1)"/>
26
- <rect x="2" y="4" width="8" height="44" rx="3" fill="url(#lg2)" opacity="0.7"/>
27
- <line x1="16" y1="16" x2="32" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="1.8" stroke-linecap="round"/>
28
- <line x1="16" y1="23" x2="30" y2="23" stroke="rgba(255,255,255,0.4)" stroke-width="1.8" stroke-linecap="round"/>
29
- <line x1="16" y1="30" x2="32" y2="30" stroke="rgba(255,255,255,0.5)" stroke-width="1.8" stroke-linecap="round"/>
30
- <line x1="16" y1="37" x2="28" y2="37" stroke="rgba(255,255,255,0.4)" stroke-width="1.8" stroke-linecap="round"/>
31
- <g transform="translate(32,2)">
32
- <path d="M6 0 L7.5 4.5 L12 6 L7.5 7.5 L6 12 L4.5 7.5 L0 6 L4.5 4.5 Z" fill="url(#sp)"/>
33
- <path d="M14 8 L14.8 10.2 L17 11 L14.8 11.8 L14 14 L13.2 11.8 L11 11 L13.2 10.2 Z" fill="#fbbf24" opacity="0.7"/>
34
- </g>
35
- </g>
36
- <text x="56" y="28" font-family="Inter,-apple-system,sans-serif" font-size="22" font-weight="700">
37
- <tspan fill="url(#lg1)">Notebook</tspan><tspan fill="#a78bfa" font-weight="800">LM</tspan>
38
- </text>
39
- <text x="57" y="46" font-family="Inter,-apple-system,sans-serif" font-size="10.5" fill="#8888aa" font-weight="400" letter-spacing="0.8">
40
- AI-Powered Study Companion
41
- </text>
42
- </svg>"""
43
-
44
- # Small icon-only version for headers
45
- LOGO_ICON_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 60">
46
- <defs>
47
- <linearGradient id="ig1" x1="0%" y1="0%" x2="100%" y2="100%">
48
- <stop offset="0%" style="stop-color:#667eea"/>
49
- <stop offset="100%" style="stop-color:#764ba2"/>
50
- </linearGradient>
51
- <linearGradient id="ig2" x1="0%" y1="0%" x2="100%" y2="100%">
52
- <stop offset="0%" style="stop-color:#a78bfa"/>
53
- <stop offset="100%" style="stop-color:#667eea"/>
54
- </linearGradient>
55
- <linearGradient id="isp" x1="0%" y1="0%" x2="100%" y2="100%">
56
- <stop offset="0%" style="stop-color:#fbbf24"/>
57
- <stop offset="100%" style="stop-color:#f59e0b"/>
58
- </linearGradient>
59
- </defs>
60
- <g transform="translate(2,4)">
61
- <rect x="2" y="4" width="36" height="44" rx="5" fill="url(#ig1)"/>
62
- <rect x="2" y="4" width="9" height="44" rx="4" fill="url(#ig2)" opacity="0.7"/>
63
- <line x1="16" y1="16" x2="32" y2="16" stroke="rgba(255,255,255,0.55)" stroke-width="2" stroke-linecap="round"/>
64
- <line x1="16" y1="23" x2="30" y2="23" stroke="rgba(255,255,255,0.4)" stroke-width="2" stroke-linecap="round"/>
65
- <line x1="16" y1="30" x2="32" y2="30" stroke="rgba(255,255,255,0.55)" stroke-width="2" stroke-linecap="round"/>
66
- <line x1="16" y1="37" x2="28" y2="37" stroke="rgba(255,255,255,0.4)" stroke-width="2" stroke-linecap="round"/>
67
- <g transform="translate(30,0)">
68
- <path d="M7 0 L8.8 5.3 L14 7 L8.8 8.8 L7 14 L5.2 8.8 L0 7 L5.2 5.3 Z" fill="url(#isp)"/>
69
- <path d="M16 9 L17 11.5 L19.5 12.5 L17 13.5 L16 16 L15 13.5 L12.5 12.5 L15 11.5 Z" fill="#fbbf24" opacity="0.7"/>
70
- </g>
71
- </g>
72
- </svg>"""
73
-
74
-
75
- def get_logo_b64(svg_str: str) -> str:
76
- return base64.b64encode(svg_str.encode()).decode()
77
-
78
-
79
- LOGO_B64 = get_logo_b64(LOGO_SVG)
80
- ICON_B64 = get_logo_b64(LOGO_ICON_SVG)
81
-
82
-
83
- # ── Page Config ──────────────────────────────────────────────────────────────
84
- st.set_page_config(
85
- page_title="NotebookLM",
86
- page_icon="📓",
87
- layout="wide",
88
- initial_sidebar_state="expanded",
89
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
- # ── Global Styles ────────────────────────────────────────────────────────────
92
- st.markdown("""
93
- <style>
94
- /* ── Import Google Font ── */
95
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
96
-
97
- /* ── Base ── */
98
- html, body, [class*="st-"] {
99
- font-family: 'Inter', sans-serif;
100
- }
101
-
102
- /* ── Sidebar ── */
103
- section[data-testid="stSidebar"] {
104
- background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
105
- border-right: 1px solid rgba(255,255,255,0.06);
106
- }
107
- section[data-testid="stSidebar"] .stMarkdown h1 {
108
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
109
- -webkit-background-clip: text;
110
- -webkit-text-fill-color: transparent;
111
- font-weight: 700;
112
- font-size: 1.8rem;
113
- letter-spacing: -0.5px;
114
- }
115
- section[data-testid="stSidebar"] p,
116
- section[data-testid="stSidebar"] span,
117
- section[data-testid="stSidebar"] label {
118
- color: #c0c0d0 !important;
119
- }
120
- section[data-testid="stSidebar"] .stDivider {
121
- border-color: rgba(255,255,255,0.08) !important;
122
- }
123
-
124
- /* ── Notebook button in sidebar ── */
125
- section[data-testid="stSidebar"] .stButton > button {
126
- border-radius: 10px !important;
127
- font-size: 0.85rem !important;
128
- font-weight: 500 !important;
129
- padding: 8px 14px !important;
130
- transition: all 0.2s ease !important;
131
- }
132
- section[data-testid="stSidebar"] .stButton > button[kind="primary"] {
133
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
134
- border: none !important;
135
- color: white !important;
136
- }
137
- section[data-testid="stSidebar"] .stButton > button[kind="secondary"] {
138
- background: rgba(255,255,255,0.05) !important;
139
- border: 1px solid rgba(255,255,255,0.1) !important;
140
- color: #d0d0e0 !important;
141
- }
142
- section[data-testid="stSidebar"] .stButton > button[kind="secondary"]:hover {
143
- background: rgba(255,255,255,0.1) !important;
144
- border-color: rgba(102,126,234,0.4) !important;
145
- }
146
-
147
- /* ── Tab styling ── */
148
- .stTabs [data-baseweb="tab-list"] {
149
- gap: 0px;
150
- background: rgba(255,255,255,0.03);
151
- border-radius: 12px;
152
- padding: 4px;
153
- border: 1px solid rgba(255,255,255,0.06);
154
- }
155
- .stTabs [data-baseweb="tab"] {
156
- border-radius: 10px;
157
- padding: 10px 24px;
158
- font-weight: 500;
159
- font-size: 0.9rem;
160
- color: #888;
161
- }
162
- .stTabs [aria-selected="true"] {
163
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
164
- color: white !important;
165
- font-weight: 600;
166
- }
167
- .stTabs [data-baseweb="tab-highlight"] {
168
- display: none;
169
- }
170
- .stTabs [data-baseweb="tab-border"] {
171
- display: none;
172
- }
173
-
174
- /* ── Main container ── */
175
- .main .block-container {
176
- padding-top: 2rem;
177
- max-width: 1100px;
178
- }
179
-
180
- /* ── Cards ── */
181
- div[data-testid="stVerticalBlock"] > div[data-testid="stContainer"] {
182
- border-radius: 14px !important;
183
- border: 1px solid rgba(255,255,255,0.08) !important;
184
- background: rgba(255,255,255,0.02) !important;
185
- transition: all 0.2s ease;
186
- }
187
- div[data-testid="stVerticalBlock"] > div[data-testid="stContainer"]:hover {
188
- border-color: rgba(102,126,234,0.3) !important;
189
- background: rgba(255,255,255,0.04) !important;
190
- }
191
-
192
- /* ── Chat messages ── */
193
- .stChatMessage {
194
- border-radius: 14px !important;
195
- padding: 16px 20px !important;
196
- margin-bottom: 8px !important;
197
- }
198
- div[data-testid="stChatMessageAvatarUser"] {
199
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
200
- }
201
- div[data-testid="stChatMessageAvatarAssistant"] {
202
- background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%) !important;
203
- }
204
-
205
- /* ── Chat input ── */
206
- .stChatInput > div {
207
- border-radius: 14px !important;
208
- border: 1px solid rgba(255,255,255,0.1) !important;
209
- background: rgba(255,255,255,0.03) !important;
210
- }
211
- .stChatInput > div:focus-within {
212
- border-color: #667eea !important;
213
- box-shadow: 0 0 0 2px rgba(102,126,234,0.2) !important;
214
- }
215
-
216
- /* ── Expanders ── */
217
- .streamlit-expanderHeader {
218
- border-radius: 10px !important;
219
- font-weight: 500 !important;
220
- }
221
-
222
- /* ── File uploader ── */
223
- section[data-testid="stFileUploader"] > div {
224
- border-radius: 14px !important;
225
- border: 2px dashed rgba(102,126,234,0.3) !important;
226
- background: rgba(102,126,234,0.03) !important;
227
- }
228
- section[data-testid="stFileUploader"] > div:hover {
229
- border-color: rgba(102,126,234,0.6) !important;
230
- background: rgba(102,126,234,0.06) !important;
231
- }
232
-
233
- /* ── Main content buttons ── */
234
- .main .stButton > button[kind="primary"] {
235
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
236
- border: none !important;
237
- border-radius: 10px !important;
238
- font-weight: 600 !important;
239
- padding: 8px 20px !important;
240
- transition: all 0.2s ease !important;
241
- }
242
- .main .stButton > button[kind="primary"]:hover {
243
- opacity: 0.9 !important;
244
- transform: translateY(-1px) !important;
245
- box-shadow: 0 4px 15px rgba(102,126,234,0.3) !important;
246
- }
247
- .main .stButton > button[kind="secondary"] {
248
- border-radius: 10px !important;
249
- border: 1px solid rgba(255,255,255,0.12) !important;
250
- background: rgba(255,255,255,0.04) !important;
251
- font-weight: 500 !important;
252
- transition: all 0.2s ease !important;
253
- }
254
- .main .stButton > button[kind="secondary"]:hover {
255
- background: rgba(255,255,255,0.08) !important;
256
- border-color: rgba(255,255,255,0.2) !important;
257
- }
258
-
259
- /* ── Download button ── */
260
- .stDownloadButton > button {
261
- border-radius: 10px !important;
262
- font-weight: 500 !important;
263
- }
264
-
265
- /* ── Text input ── */
266
- .stTextInput > div > div {
267
- border-radius: 10px !important;
268
- }
269
-
270
- /* ── Metrics / status badges ── */
271
- .stSuccess, .stWarning, .stError, .stInfo {
272
- border-radius: 10px !important;
273
- }
274
-
275
- /* ── Welcome hero ── */
276
- .welcome-hero {
277
- text-align: center;
278
- padding: 80px 40px;
279
- background: linear-gradient(135deg, rgba(102,126,234,0.08) 0%, rgba(118,75,162,0.08) 100%);
280
- border-radius: 20px;
281
- border: 1px solid rgba(102,126,234,0.15);
282
- margin: 20px 0;
283
- }
284
- .welcome-hero h1 {
285
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
286
- -webkit-background-clip: text;
287
- -webkit-text-fill-color: transparent;
288
- font-size: 2.5rem;
289
- font-weight: 700;
290
- margin-bottom: 12px;
291
- }
292
- .welcome-hero p {
293
- color: #9090a8;
294
- font-size: 1.1rem;
295
- line-height: 1.6;
296
- }
297
-
298
- /* ── Empty state ── */
299
- .empty-state {
300
- text-align: center;
301
- padding: 60px 30px;
302
- color: #707088;
303
- }
304
- .empty-state h3 {
305
- color: #a0a0b8;
306
- margin-bottom: 8px;
307
- font-weight: 600;
308
- }
309
- .empty-state p {
310
- font-size: 0.95rem;
311
- line-height: 1.5;
312
- }
313
-
314
- /* ── Source card ── */
315
- .source-card {
316
- display: flex;
317
- align-items: center;
318
- gap: 16px;
319
- padding: 16px 20px;
320
- background: rgba(255,255,255,0.02);
321
- border: 1px solid rgba(255,255,255,0.08);
322
- border-radius: 14px;
323
- margin-bottom: 10px;
324
- transition: all 0.2s ease;
325
- }
326
- .source-card:hover {
327
- border-color: rgba(102,126,234,0.3);
328
- background: rgba(255,255,255,0.04);
329
- }
330
- .source-icon {
331
- width: 48px;
332
- height: 48px;
333
- border-radius: 12px;
334
- display: flex;
335
- align-items: center;
336
- justify-content: center;
337
- font-size: 1.5rem;
338
- flex-shrink: 0;
339
- }
340
- .source-icon.pdf { background: rgba(239,68,68,0.15); }
341
- .source-icon.pptx { background: rgba(249,115,22,0.15); }
342
- .source-icon.txt { background: rgba(59,130,246,0.15); }
343
- .source-icon.url { background: rgba(34,197,94,0.15); }
344
- .source-icon.youtube { background: rgba(239,68,68,0.15); }
345
- .source-info { flex: 1; min-width: 0; }
346
- .source-info .name {
347
- font-weight: 600;
348
- font-size: 0.95rem;
349
- color: #e0e0f0;
350
- white-space: nowrap;
351
- overflow: hidden;
352
- text-overflow: ellipsis;
353
- }
354
- .source-info .meta {
355
- font-size: 0.8rem;
356
- color: #707088;
357
- margin-top: 2px;
358
- }
359
- .source-badge {
360
- padding: 4px 12px;
361
- border-radius: 20px;
362
- font-size: 0.75rem;
363
- font-weight: 600;
364
- letter-spacing: 0.3px;
365
- }
366
- .source-badge.ready {
367
- background: rgba(34,197,94,0.15);
368
- color: #22c55e;
369
- }
370
- .source-badge.processing {
371
- background: rgba(234,179,8,0.15);
372
- color: #eab308;
373
- }
374
- .source-badge.failed {
375
- background: rgba(239,68,68,0.15);
376
- color: #ef4444;
377
- }
378
-
379
- /* ── Artifact generation cards ── */
380
- .gen-card {
381
- text-align: center;
382
- padding: 30px 20px;
383
- background: rgba(255,255,255,0.02);
384
- border: 1px solid rgba(255,255,255,0.08);
385
- border-radius: 16px;
386
- transition: all 0.25s ease;
387
- cursor: default;
388
- }
389
- .gen-card:hover {
390
- border-color: rgba(102,126,234,0.3);
391
- background: rgba(102,126,234,0.04);
392
- transform: translateY(-2px);
393
- }
394
- .gen-card .icon {
395
- font-size: 2.5rem;
396
- margin-bottom: 12px;
397
- }
398
- .gen-card h4 {
399
- margin: 0 0 6px 0;
400
- font-weight: 600;
401
- color: #e0e0f0;
402
- }
403
- .gen-card p {
404
- font-size: 0.85rem;
405
- color: #808098;
406
- line-height: 1.4;
407
- margin: 0;
408
- }
409
-
410
- /* ── Citation chip ── */
411
- .citation-chip {
412
- display: inline-flex;
413
- align-items: center;
414
- gap: 6px;
415
- padding: 6px 14px;
416
- background: rgba(102,126,234,0.1);
417
- border: 1px solid rgba(102,126,234,0.2);
418
- border-radius: 20px;
419
- font-size: 0.8rem;
420
- color: #a0b0f0;
421
- margin: 3px 4px;
422
- }
423
-
424
- /* ── Notebook header ── */
425
- .notebook-header {
426
- padding: 0 0 16px 0;
427
- margin-bottom: 16px;
428
- border-bottom: 1px solid rgba(255,255,255,0.06);
429
- }
430
- .notebook-header h2 {
431
- font-weight: 700;
432
- font-size: 1.5rem;
433
- margin: 0;
434
- color: #e8e8f8;
435
- }
436
- .notebook-header .meta {
437
- font-size: 0.85rem;
438
- color: #707088;
439
- margin-top: 4px;
440
- }
441
-
442
- /* ── Hide ALL Streamlit default chrome ── */
443
- [data-testid="stSidebarCollapseButton"],
444
- [data-testid="collapsedControl"],
445
- .stDeployButton,
446
- [data-testid="stToolbar"],
447
- [data-testid="stBottomBlockContainer"],
448
- [data-testid="manage-app-button"] {
449
- display: none !important;
450
- }
451
- /* Kill all Material Icon text leaks */
452
- [data-testid="stSidebarCollapseButton"] *,
453
- [data-testid="collapsedControl"] *,
454
- [data-testid="stBottomBlockContainer"] * {
455
- display: none !important;
456
- }
457
-
458
- /* ── Force dark theme on main area ── */
459
- .stApp, .stApp > header {
460
- background-color: #0e1117 !important;
461
- }
462
- .main .block-container {
463
- background-color: #0e1117 !important;
464
- }
465
- .stApp [data-testid="stHeader"] {
466
- background-color: #0e1117 !important;
467
- }
468
- /* All text defaults to light */
469
- .stApp, .stApp p, .stApp span, .stApp li, .stApp td, .stApp th {
470
- color: #c8c8d8 !important;
471
- }
472
- .stApp h1, .stApp h2, .stApp h3, .stApp h4 {
473
- color: #e0e0f0 !important;
474
- }
475
- .stApp strong {
476
- color: #e8e8f8 !important;
477
- }
478
- /* Markdown inside containers */
479
- .stMarkdown, .stMarkdown p {
480
- color: #c8c8d8 !important;
481
- }
482
-
483
- /* ── Expander styling for dark ── */
484
- .streamlit-expanderHeader {
485
- background: rgba(255,255,255,0.03) !important;
486
- border-radius: 10px !important;
487
- color: #c0c0d0 !important;
488
- }
489
- .streamlit-expanderContent {
490
- background: rgba(255,255,255,0.01) !important;
491
- border-color: rgba(255,255,255,0.06) !important;
492
- }
493
-
494
- /* ── Table styling ── */
495
- .stApp table {
496
- border-collapse: collapse;
497
- }
498
- .stApp th {
499
- background: rgba(102,126,234,0.1) !important;
500
- border-bottom: 1px solid rgba(255,255,255,0.1) !important;
501
- }
502
- .stApp td {
503
- border-bottom: 1px solid rgba(255,255,255,0.05) !important;
504
- }
505
-
506
- /* ── Radio / select styling ── */
507
- .stRadio label, .stSelectbox label, .stSlider label {
508
- color: #a0a0b8 !important;
509
- }
510
-
511
- /* ── Alert boxes ── */
512
- [data-testid="stAlert"] {
513
- background: rgba(255,255,255,0.03) !important;
514
- border: 1px solid rgba(255,255,255,0.08) !important;
515
- border-radius: 12px !important;
516
- }
517
-
518
- /* ── Scrollbar ── */
519
- ::-webkit-scrollbar { width: 6px; }
520
- ::-webkit-scrollbar-track { background: transparent; }
521
- ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
522
- ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.25); }
523
- </style>
524
- """, unsafe_allow_html=True)
525
-
526
-
527
- # ── Authentication ───────────────────────────────────────────────────────────
528
- user = require_auth()
529
- user_id = user["id"]
530
-
531
- # ── Per-User Session State ───────────────────────────────────────────────────
532
- if "user_data" not in st.session_state:
533
- st.session_state.user_data = {}
534
-
535
- if user_id not in st.session_state.user_data:
536
- default_id = str(uuid.uuid4())
537
- st.session_state.user_data[user_id] = {
538
- "notebooks": {
539
- default_id: {
540
- "id": default_id,
541
- "title": "My First Notebook",
542
- "created_at": datetime.now().isoformat(),
543
- "sources": [],
544
- "messages": [],
545
- "artifacts": [],
546
- }
547
- },
548
- "active_notebook_id": default_id,
549
- }
550
-
551
- udata = st.session_state.user_data[user_id]
552
-
553
-
554
- # ── Helper Functions ─────────────────────────────────────────────────────────
555
- def get_active_notebook():
556
- nb_id = udata["active_notebook_id"]
557
- if nb_id and nb_id in udata["notebooks"]:
558
- return udata["notebooks"][nb_id]
559
- return None
560
-
561
-
562
- def create_notebook(title: str):
563
- nb_id = str(uuid.uuid4())
564
- udata["notebooks"][nb_id] = {
565
- "id": nb_id,
566
- "title": title,
567
- "created_at": datetime.now().isoformat(),
568
- "sources": [],
569
- "messages": [],
570
- "artifacts": [],
571
- }
572
- udata["active_notebook_id"] = nb_id
573
-
574
-
575
- def delete_notebook(nb_id: str):
576
- if nb_id in udata["notebooks"]:
577
- del udata["notebooks"][nb_id]
578
- remaining = list(udata["notebooks"].keys())
579
- udata["active_notebook_id"] = remaining[0] if remaining else None
580
-
581
-
582
- def rename_notebook(nb_id: str, new_title: str):
583
- if nb_id in udata["notebooks"]:
584
- udata["notebooks"][nb_id]["title"] = new_title
585
-
586
-
587
- # ── Sidebar ──────────────────────────────────────────────────────────────────
588
- with st.sidebar:
589
- st.markdown(
590
- f'<div style="padding: 8px 0 4px 0;">'
591
- f'<img src="data:image/svg+xml;base64,{LOGO_B64}" style="width:100%; max-width:240px;" />'
592
- f'</div>',
593
- unsafe_allow_html=True,
594
  )
595
- st.markdown(
596
- f'<p style="font-size:0.82rem; color:#7070888; margin: 4px 0 0 0;">'
597
- f'Signed in as <strong style="color:#a0a0f0;">{user["name"]}</strong></p>',
598
- unsafe_allow_html=True,
 
 
 
 
599
  )
600
- st.divider()
601
-
602
- # ── Create notebook ──
603
- with st.popover("+ New Notebook", use_container_width=True):
604
- new_title = st.text_input(
605
- "Name", placeholder="e.g. Biology 101", label_visibility="collapsed"
606
- )
607
- if st.button("Create Notebook", use_container_width=True, type="primary"):
608
- if new_title.strip():
609
- create_notebook(new_title.strip())
610
- st.rerun()
611
- else:
612
- st.warning("Enter a name.")
613
-
614
- st.markdown("") # spacer
615
-
616
- # ── Notebook list ──
617
- if not udata["notebooks"]:
618
- st.markdown(
619
- '<p style="text-align:center; color:#606078; padding:20px 0;">'
620
- "No notebooks yet</p>",
621
- unsafe_allow_html=True,
622
- )
623
- else:
624
- for nb_id, nb in udata["notebooks"].items():
625
- is_active = nb_id == udata["active_notebook_id"]
626
- source_count = len(nb["sources"])
627
- msg_count = len(nb["messages"])
628
-
629
- col1, col2 = st.columns([5, 1])
630
- with col1:
631
- label = nb["title"]
632
- if source_count > 0 or msg_count > 0:
633
- label += f" ({source_count}s, {msg_count}m)"
634
- if st.button(
635
- f"{'> ' if is_active else ' '}{label}",
636
- key=f"sel_{nb_id}",
637
- use_container_width=True,
638
- type="primary" if is_active else "secondary",
639
- ):
640
- udata["active_notebook_id"] = nb_id
641
- st.rerun()
642
- with col2:
643
- if st.button(
644
- "x", key=f"del_{nb_id}",
645
- help="Delete this notebook",
646
- use_container_width=True,
647
- ):
648
- delete_notebook(nb_id)
649
- st.rerun()
650
-
651
- st.divider()
652
-
653
- # ── Rename ──
654
- notebook = get_active_notebook()
655
- if notebook:
656
- with st.popover("Rename", use_container_width=True):
657
- rename_val = st.text_input(
658
- "New name", value=notebook["title"], key="rename_input"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  )
660
- if st.button("Save", use_container_width=True, type="primary"):
661
- if rename_val.strip():
662
- rename_notebook(notebook["id"], rename_val.strip())
663
- st.rerun()
664
-
665
- # ── Footer ──
666
- st.markdown("")
667
- st.markdown(
668
- '<p style="font-size:0.75rem; color:#50506a; text-align:center;">'
669
- "Built with Streamlit on HF Spaces</p>",
670
- unsafe_allow_html=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  )
672
 
 
 
 
 
 
 
 
 
 
 
 
 
673
 
674
- # ── Main Content ─────────────────────────────────────────────────────────────
675
- notebook = get_active_notebook()
676
-
677
- if not notebook:
678
- st.markdown(
679
- f"""
680
- <div class="welcome-hero">
681
- <img src="data:image/svg+xml;base64,{ICON_B64}" style="width:64px; margin-bottom:16px;" />
682
- <h1>NotebookLM</h1>
683
- <p>Your AI-powered study companion.<br>
684
- Create a notebook from the sidebar to get started.</p>
685
- </div>
686
- """,
687
- unsafe_allow_html=True,
688
  )
689
- st.stop()
690
-
691
- # ── Notebook header ──
692
- source_count = len(notebook["sources"])
693
- msg_count = len(notebook["messages"])
694
- artifact_count = len(notebook["artifacts"])
695
- st.markdown(
696
- f"""
697
- <div class="notebook-header">
698
- <h2>{notebook["title"]}</h2>
699
- <div class="meta">{source_count} sources &nbsp;&bull;&nbsp; {msg_count} messages &nbsp;&bull;&nbsp; {artifact_count} artifacts</div>
700
- </div>
701
- """,
702
- unsafe_allow_html=True,
703
- )
704
 
705
- # ── Tabs ──
706
- tab_chat, tab_sources, tab_artifacts = st.tabs(
707
- [" Chat ", " Sources ", " Artifacts "]
708
- )
 
 
 
 
 
 
 
 
709
 
710
- from ui.chat_page import render_chat
711
- from ui.upload_page import render_sources
712
- from ui.artifact_page import render_artifacts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
 
714
- with tab_chat:
715
- render_chat(notebook)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
 
717
- with tab_sources:
718
- render_sources(notebook)
719
 
720
- with tab_artifacts:
721
- render_artifacts(notebook)
 
 
1
+ """NotebookLM AI-Powered Study Companion (Gradio)."""
2
+
3
+ import gradio as gr
4
+
5
+ from state import (
6
+ UserData,
7
+ create_default_user_data,
8
+ create_notebook,
9
+ delete_notebook,
10
+ rename_notebook,
11
+ get_active_notebook,
12
+ get_notebook_choices,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  )
14
+ from theme import dark_theme, CUSTOM_CSS, SIDEBAR_LOGO_HTML, WELCOME_HTML, NO_NOTEBOOKS_HTML
15
+ from pages.chat import (
16
+ format_chatbot_messages,
17
+ render_no_sources_warning,
18
+ handle_chat_submit,
19
+ handle_clear_chat,
20
+ )
21
+ from pages.sources import (
22
+ render_source_header,
23
+ render_source_list,
24
+ get_source_choices,
25
+ handle_file_upload,
26
+ handle_url_add,
27
+ handle_source_delete,
28
+ )
29
+ from pages.artifacts import (
30
+ render_no_sources_gate,
31
+ has_sources,
32
+ render_conv_summary_section,
33
+ handle_gen_conv_summary,
34
+ render_doc_summary_section,
35
+ handle_gen_doc_summary,
36
+ render_podcast_section,
37
+ handle_gen_podcast,
38
+ render_quiz_section,
39
+ handle_gen_quiz,
40
+ has_any_summary,
41
+ )
42
+
43
 
44
+ # ── Helpers ──────────────────────────────────────────────────────────────────
45
+
46
+ def render_notebook_header(state: UserData) -> str:
47
+ nb = get_active_notebook(state)
48
+ if not nb:
49
+ return NO_NOTEBOOKS_HTML
50
+ src = len(nb.sources)
51
+ msg = len(nb.messages)
52
+ art = len(nb.artifacts)
53
+ return (
54
+ f'<div class="notebook-header">'
55
+ f'<h2>{nb.title}</h2>'
56
+ f'<div class="meta">{src} sources &bull; {msg} messages &bull; {art} artifacts</div>'
57
+ f'</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  )
59
+
60
+
61
+ def render_user_info(state: UserData) -> str:
62
+ if not state:
63
+ return ""
64
+ return (
65
+ f'<p style="font-size:0.82rem; color:#707088; margin:4px 0 0 0;">'
66
+ f'Signed in as <strong style="color:#a0a0f0;">{state.user_name}</strong></p>'
67
  )
68
+
69
+
70
+ def refresh_all(state: UserData):
71
+ """Refresh all display components after state change. Returns a tuple of all outputs."""
72
+ nb = get_active_notebook(state)
73
+ choices = get_notebook_choices(state) if state else []
74
+ active_id = state.active_notebook_id if state else None
75
+
76
+ has_nb = nb is not None
77
+ has_src = has_nb and len(nb.sources) > 0
78
+
79
+ return (
80
+ state,
81
+ # Sidebar
82
+ gr.update(choices=choices, value=active_id),
83
+ render_user_info(state),
84
+ # Header
85
+ render_notebook_header(state),
86
+ # Chat tab
87
+ format_chatbot_messages(state) if has_nb else [],
88
+ render_no_sources_warning(state),
89
+ # Sources tab
90
+ render_source_header(state),
91
+ render_source_list(state),
92
+ gr.update(choices=get_source_choices(state)),
93
+ # Artifacts tab
94
+ render_no_sources_gate(state),
95
+ gr.update(visible=has_src), # artifacts_content visible
96
+ gr.update(visible=not has_src), # no_sources_gate visible
97
+ # Artifact sub-sections
98
+ render_conv_summary_section(state),
99
+ render_doc_summary_section(state),
100
+ render_podcast_section(state),
101
+ render_quiz_section(state),
102
+ )
103
+
104
+
105
+ # ── Build the App ────────────────────────────────────────────────────────────
106
+
107
+ with gr.Blocks(css=CUSTOM_CSS, theme=dark_theme, title="NotebookLM") as demo:
108
+
109
+ user_state = gr.State(value=None)
110
+
111
+ # ══ Auth Gate ══════════���═════════════════════════════════════════════════
112
+ with gr.Column(visible=True, elem_id="auth-gate") as auth_gate:
113
+ gr.HTML(WELCOME_HTML)
114
+ gr.LoginButton(elem_id="login-btn")
115
+
116
+ # ══ Main App (hidden until login) ════════════════════════════════════════
117
+ with gr.Row(visible=False) as main_app:
118
+
119
+ # ── Sidebar ──────────────────────────────────────────────────────────
120
+ with gr.Column(scale=1, min_width=280, elem_id="sidebar"):
121
+ gr.HTML(SIDEBAR_LOGO_HTML)
122
+ user_info_html = gr.HTML("")
123
+
124
+ gr.Markdown("---")
125
+
126
+ # Create notebook
127
+ new_nb_name = gr.Textbox(
128
+ placeholder="e.g. Biology 101",
129
+ show_label=False,
130
+ container=False,
131
+ )
132
+ create_nb_btn = gr.Button("+ New Notebook", variant="primary", size="sm")
133
+
134
+ gr.HTML('<div style="height:8px;"></div>')
135
+
136
+ # Notebook selector
137
+ notebook_selector = gr.Radio(
138
+ choices=[],
139
+ label="Notebooks",
140
+ elem_id="notebook-selector",
141
+ )
142
+
143
+ gr.Markdown("---")
144
+
145
+ # Rename
146
+ rename_input = gr.Textbox(
147
+ placeholder="New name...",
148
+ show_label=False,
149
+ container=False,
150
+ )
151
+ rename_btn = gr.Button("Rename", size="sm")
152
+
153
+ # Delete
154
+ delete_btn = gr.Button("Delete Notebook", variant="stop", size="sm")
155
+
156
+ gr.HTML(
157
+ '<p style="font-size:0.75rem; color:#50506a; text-align:center; margin-top:16px;">'
158
+ 'Built with Gradio on HF Spaces</p>'
159
  )
160
+
161
+ # ── Main Content ─────────────────────────────────────────────────────
162
+ with gr.Column(scale=4, elem_id="main-content"):
163
+
164
+ notebook_header = gr.HTML(NO_NOTEBOOKS_HTML)
165
+
166
+ with gr.Tabs(elem_id="main-tabs") as main_tabs:
167
+
168
+ # ── Chat Tab ─────────────────────────────────────────────────
169
+ with gr.TabItem("Chat", id=0):
170
+ chat_warning = gr.HTML("")
171
+ chatbot = gr.Chatbot(
172
+ value=[],
173
+ type="messages",
174
+ height=480,
175
+ elem_id="chatbot",
176
+ show_label=False,
177
+ )
178
+ with gr.Row():
179
+ chat_input = gr.Textbox(
180
+ placeholder="Ask a question about your sources...",
181
+ show_label=False,
182
+ container=False,
183
+ scale=5,
184
+ )
185
+ clear_chat_btn = gr.Button("Clear", scale=1)
186
+
187
+ # ── Sources Tab ──────────────────────────────────────────────
188
+ with gr.TabItem("Sources", id=1):
189
+ source_header = gr.HTML("")
190
+
191
+ with gr.Row():
192
+ with gr.Column():
193
+ gr.HTML(
194
+ '<p style="font-weight:600; font-size:0.9rem; color:#b0b0c8; margin-bottom:8px;">'
195
+ 'Upload Files</p>'
196
+ )
197
+ file_uploader = gr.File(
198
+ file_count="multiple",
199
+ file_types=[".pdf", ".pptx", ".txt"],
200
+ label="Drop files here",
201
+ show_label=False,
202
+ )
203
+ with gr.Column():
204
+ gr.HTML(
205
+ '<p style="font-weight:600; font-size:0.9rem; color:#b0b0c8; margin-bottom:8px;">'
206
+ 'Add Web Source</p>'
207
+ )
208
+ url_input = gr.Textbox(
209
+ placeholder="https://example.com or YouTube link",
210
+ show_label=False,
211
+ container=False,
212
+ )
213
+ add_url_btn = gr.Button("Add URL", variant="primary")
214
+
215
+ gr.Markdown("---")
216
+ source_list_html = gr.HTML("")
217
+
218
+ with gr.Row():
219
+ source_selector = gr.Dropdown(
220
+ choices=[],
221
+ label="Select source to delete",
222
+ scale=3,
223
+ )
224
+ delete_source_btn = gr.Button("Delete Source", variant="stop", scale=1)
225
+
226
+ # ── Artifacts Tab ────────────────────────────────────────────
227
+ with gr.TabItem("Artifacts", id=2):
228
+
229
+ # No-sources gate
230
+ no_sources_msg = gr.HTML("", visible=True)
231
+
232
+ with gr.Column(visible=False) as artifacts_content:
233
+
234
+ with gr.Tabs(elem_id="artifact-tabs"):
235
+
236
+ # Summary sub-tab
237
+ with gr.TabItem("Summary"):
238
+ # Conversation Summary
239
+ gr.HTML(
240
+ '<div class="artifact-section-header">'
241
+ '<div class="artifact-section-icon" style="background:rgba(102,126,234,0.12);">💬</div>'
242
+ '<div><span style="font-weight:600; font-size:1rem; color:#e0e0f0;">Conversation Summary</span>'
243
+ '<p style="font-size:0.82rem; color:#808098; margin:2px 0 0 0;">'
244
+ 'Summarize your chat history.</p></div></div>'
245
+ )
246
+ with gr.Row():
247
+ conv_style_radio = gr.Radio(
248
+ choices=["brief", "detailed"],
249
+ value="detailed",
250
+ label="Style",
251
+ scale=2,
252
+ )
253
+ gen_conv_sum_btn = gr.Button(
254
+ "Generate Conversation Summary",
255
+ variant="primary",
256
+ scale=2,
257
+ )
258
+ conv_summary_html = gr.Markdown("")
259
+
260
+ gr.HTML('<div style="margin:30px 0; border-top:1px solid rgba(255,255,255,0.06);"></div>')
261
+
262
+ # Document Summary
263
+ gr.HTML(
264
+ '<div class="artifact-section-header">'
265
+ '<div class="artifact-section-icon" style="background:rgba(34,197,94,0.12);">📄</div>'
266
+ '<div><span style="font-weight:600; font-size:1rem; color:#e0e0f0;">Document Summary</span>'
267
+ '<p style="font-size:0.82rem; color:#808098; margin:2px 0 0 0;">'
268
+ 'Summarize content from your uploaded sources.</p></div></div>'
269
+ )
270
+ with gr.Row():
271
+ doc_style_radio = gr.Radio(
272
+ choices=["brief", "detailed"],
273
+ value="detailed",
274
+ label="Style",
275
+ scale=2,
276
+ )
277
+ gen_doc_sum_btn = gr.Button(
278
+ "Generate Document Summary",
279
+ variant="primary",
280
+ scale=2,
281
+ )
282
+ doc_summary_html = gr.Markdown("")
283
+
284
+ # Podcast sub-tab
285
+ with gr.TabItem("Podcast"):
286
+ gr.HTML(
287
+ '<div style="margin-bottom:20px;">'
288
+ '<span style="font-weight:600; font-size:1rem; color:#e0e0f0;">Generate Podcast</span>'
289
+ '<p style="font-size:0.85rem; color:#808098; margin-top:4px;">'
290
+ 'Create a conversational podcast episode from your summary.</p></div>'
291
+ )
292
+ gen_podcast_btn = gr.Button("Generate Podcast", variant="primary")
293
+ podcast_html = gr.Markdown("")
294
+
295
+ # Quiz sub-tab
296
+ with gr.TabItem("Quiz"):
297
+ gr.HTML(
298
+ '<div style="margin-bottom:20px;">'
299
+ '<span style="font-weight:600; font-size:1rem; color:#e0e0f0;">Generate Quiz</span>'
300
+ '<p style="font-size:0.85rem; color:#808098; margin-top:4px;">'
301
+ 'Create multiple-choice questions from your sources.</p></div>'
302
+ )
303
+ with gr.Row():
304
+ quiz_num_radio = gr.Radio(
305
+ choices=[5, 10],
306
+ value=5,
307
+ label="Number of questions",
308
+ scale=2,
309
+ )
310
+ gen_quiz_btn = gr.Button(
311
+ "Generate Quiz",
312
+ variant="primary",
313
+ scale=2,
314
+ )
315
+ quiz_html = gr.Markdown("")
316
+
317
+ # ── All refresh outputs (must match refresh_all return order) ─────────
318
+ refresh_outputs = [
319
+ user_state,
320
+ notebook_selector,
321
+ user_info_html,
322
+ notebook_header,
323
+ chatbot,
324
+ chat_warning,
325
+ source_header,
326
+ source_list_html,
327
+ source_selector,
328
+ no_sources_msg,
329
+ artifacts_content,
330
+ no_sources_msg,
331
+ conv_summary_html,
332
+ doc_summary_html,
333
+ podcast_html,
334
+ quiz_html,
335
+ ]
336
+
337
+ # ══ Event Handlers ═══════════════════════════════════════════════════════
338
+
339
+ # ── Auth: on page load ───────────────────────────────────────────────────
340
+ def on_app_load(profile: gr.OAuthProfile | None):
341
+ if profile is None:
342
+ return None, gr.update(visible=True), gr.update(visible=False)
343
+ state = create_default_user_data(profile.username, profile.name)
344
+ return state, gr.update(visible=False), gr.update(visible=True)
345
+
346
+ demo.load(
347
+ fn=on_app_load,
348
+ inputs=None,
349
+ outputs=[user_state, auth_gate, main_app],
350
+ ).then(
351
+ fn=refresh_all,
352
+ inputs=[user_state],
353
+ outputs=refresh_outputs,
354
  )
355
 
356
+ # ── Sidebar: Create notebook ─────────────────────────────────────────────
357
+ def handle_create_notebook(name, state):
358
+ if not name or not name.strip() or not state:
359
+ return (state,) + refresh_all(state)[1:] + ("",)
360
+ state = create_notebook(state, name.strip())
361
+ return (state,) + refresh_all(state)[1:] + ("",)
362
+
363
+ create_nb_btn.click(
364
+ fn=handle_create_notebook,
365
+ inputs=[new_nb_name, user_state],
366
+ outputs=refresh_outputs + [new_nb_name],
367
+ )
368
 
369
+ # ── Sidebar: Select notebook ─────────────────────────────────────────────
370
+ def handle_select_notebook(nb_id, state):
371
+ if not state or not nb_id:
372
+ return refresh_all(state)
373
+ state.active_notebook_id = nb_id
374
+ return refresh_all(state)
375
+
376
+ notebook_selector.change(
377
+ fn=handle_select_notebook,
378
+ inputs=[notebook_selector, user_state],
379
+ outputs=refresh_outputs,
 
 
 
380
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
 
382
+ # ── Sidebar: Delete notebook ───────────────────────────────────���─────────
383
+ def handle_delete_notebook(state):
384
+ if not state or not state.active_notebook_id:
385
+ return refresh_all(state)
386
+ state = delete_notebook(state, state.active_notebook_id)
387
+ return refresh_all(state)
388
+
389
+ delete_btn.click(
390
+ fn=handle_delete_notebook,
391
+ inputs=[user_state],
392
+ outputs=refresh_outputs,
393
+ )
394
 
395
+ # ── Sidebar: Rename notebook ─────────────────────────────────────────────
396
+ def handle_rename_notebook(new_name, state):
397
+ if not state or not state.active_notebook_id or not new_name or not new_name.strip():
398
+ return refresh_all(state)
399
+ state = rename_notebook(state, state.active_notebook_id, new_name.strip())
400
+ return refresh_all(state)
401
+
402
+ rename_btn.click(
403
+ fn=handle_rename_notebook,
404
+ inputs=[rename_input, user_state],
405
+ outputs=refresh_outputs,
406
+ )
407
+
408
+ # ── Chat: Submit message ─────────────────────────────────────────────────
409
+ chat_input.submit(
410
+ fn=handle_chat_submit,
411
+ inputs=[chat_input, user_state],
412
+ outputs=[user_state, chatbot, chat_input, chat_warning],
413
+ )
414
 
415
+ # ── Chat: Clear ──────────────────────────────────────────────────────────
416
+ clear_chat_btn.click(
417
+ fn=handle_clear_chat,
418
+ inputs=[user_state],
419
+ outputs=[user_state, chatbot, chat_warning],
420
+ )
421
+
422
+ # ── Sources: File upload ─────────────────────────────────────────────────
423
+ file_uploader.upload(
424
+ fn=handle_file_upload,
425
+ inputs=[file_uploader, user_state],
426
+ outputs=[user_state, source_list_html, source_header, source_selector],
427
+ )
428
+
429
+ # ── Sources: Add URL ─────────────────────────────────────────────────────
430
+ add_url_btn.click(
431
+ fn=handle_url_add,
432
+ inputs=[url_input, user_state],
433
+ outputs=[user_state, source_list_html, source_header, url_input, source_selector],
434
+ )
435
+
436
+ # ── Sources: Delete source ───────────────────────────────────────────────
437
+ delete_source_btn.click(
438
+ fn=handle_source_delete,
439
+ inputs=[source_selector, user_state],
440
+ outputs=[user_state, source_list_html, source_header, source_selector],
441
+ )
442
+
443
+ # ── Artifacts: Conversation summary ──────────────────────────────────────
444
+ gen_conv_sum_btn.click(
445
+ fn=handle_gen_conv_summary,
446
+ inputs=[conv_style_radio, user_state],
447
+ outputs=[user_state, conv_summary_html],
448
+ )
449
+
450
+ # ── Artifacts: Document summary ──────────────────────────────────────────
451
+ gen_doc_sum_btn.click(
452
+ fn=handle_gen_doc_summary,
453
+ inputs=[doc_style_radio, user_state],
454
+ outputs=[user_state, doc_summary_html],
455
+ )
456
+
457
+ # ── Artifacts: Podcast ───────────────────────────────────────────────────
458
+ gen_podcast_btn.click(
459
+ fn=handle_gen_podcast,
460
+ inputs=[user_state],
461
+ outputs=[user_state, podcast_html],
462
+ )
463
+
464
+ # ── Artifacts: Quiz ──────────────────────────────────────────────────────
465
+ gen_quiz_btn.click(
466
+ fn=handle_gen_quiz,
467
+ inputs=[quiz_num_radio, user_state],
468
+ outputs=[user_state, quiz_html],
469
+ )
470
 
 
 
471
 
472
+ # ── Launch ───────────────────────────────────────────────────────────────────
473
+ if __name__ == "__main__":
474
+ demo.launch()
auth.py DELETED
@@ -1,60 +0,0 @@
1
- import streamlit as st
2
- import os
3
-
4
-
5
- def get_current_user() -> dict | None:
6
- """
7
- Get the currently logged-in user via Streamlit's native auth (st.user).
8
-
9
- On HF Spaces: secrets.toml is auto-generated from HF OAuth env vars
10
- by entrypoint.sh, so st.login()/st.user just works.
11
- Locally: uses a dev user so the app is testable without OAuth.
12
-
13
- Returns dict with 'id' and 'name', or None if not authenticated.
14
- """
15
- is_hf_space = os.environ.get("SPACE_ID") is not None
16
-
17
- if is_hf_space:
18
- if st.user.is_logged_in:
19
- return {
20
- "id": st.user.get("sub", st.user.get("name", "unknown")),
21
- "name": st.user.get("name", st.user.get("preferred_username", "User")),
22
- }
23
- return None
24
- else:
25
- return {
26
- "id": "dev_user",
27
- "name": "Dev User (local)",
28
- }
29
-
30
-
31
- def _do_login():
32
- st.login("huggingface")
33
-
34
-
35
- def require_auth() -> dict:
36
- """
37
- Gate the app behind authentication.
38
- Returns user dict if authenticated, otherwise shows login prompt and stops.
39
- """
40
- user = get_current_user()
41
-
42
- if user is None:
43
- st.markdown(
44
- '<div style="text-align:center; padding:60px 20px;">'
45
- '<h1>NotebookLM</h1>'
46
- '<p style="color:#888; font-size:1.1rem;">Sign in with your Hugging Face account to continue.</p>'
47
- "</div>",
48
- unsafe_allow_html=True,
49
- )
50
- _col1, col2, _col3 = st.columns([1, 1, 1])
51
- with col2:
52
- st.button(
53
- "Sign in with Hugging Face",
54
- on_click=_do_login,
55
- use_container_width=True,
56
- type="primary",
57
- )
58
- st.stop()
59
-
60
- return user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
entrypoint.sh DELETED
@@ -1,26 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
-
4
- # Generate .streamlit/secrets.toml from HF OAuth environment variables
5
- mkdir -p /app/.streamlit
6
-
7
- REDIRECT="https://${SPACE_HOST}/oauth2callback"
8
-
9
- cat > /app/.streamlit/secrets.toml <<EOF
10
- [auth]
11
- redirect_uri = "${REDIRECT}"
12
- cookie_secret = "$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
13
-
14
- [auth.huggingface]
15
- client_id = "${OAUTH_CLIENT_ID}"
16
- client_secret = "${OAUTH_CLIENT_SECRET}"
17
- server_metadata_url = "${OPENID_PROVIDER_URL}/.well-known/openid-configuration"
18
- EOF
19
-
20
- echo "Generated secrets.toml with redirect_uri=${REDIRECT}"
21
-
22
- exec streamlit run app.py \
23
- --server.port=8501 \
24
- --server.address=0.0.0.0 \
25
- --server.enableCORS=false \
26
- --server.enableXsrfProtection=false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ui/artifact_page.py → mock_data.py RENAMED
@@ -1,10 +1,69 @@
1
- import streamlit as st
 
 
 
2
  import uuid
3
  from datetime import datetime
4
- import time
5
-
6
-
7
- # ── Mock Content ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  MOCK_CONVERSATION_SUMMARY = {
10
  "brief": """## Conversation Summary (Brief)
@@ -211,7 +270,6 @@ What is the recommended learning order for the three methods?
211
 
212
  ### Question 1
213
  What is the primary advantage of the direct method?
214
-
215
  - A) It works with incomplete data
216
  - B) It provides exact results in a single pass
217
  - C) It is the fastest method
@@ -223,7 +281,6 @@ What is the primary advantage of the direct method?
223
 
224
  ### Question 2
225
  Which stage comes immediately after initialization?
226
-
227
  - A) Validation
228
  - B) Optimization
229
  - C) Processing
@@ -235,7 +292,6 @@ Which stage comes immediately after initialization?
235
 
236
  ### Question 3
237
  When should the approximation method be preferred?
238
-
239
  - A) When working with critical systems
240
  - B) When precision is not the primary concern
241
  - C) When the dataset is very small
@@ -247,7 +303,6 @@ When should the approximation method be preferred?
247
 
248
  ### Question 4
249
  What does "convergence" mean in the iterative approach?
250
-
251
  - A) The point where the algorithm starts
252
  - B) When iterations produce negligible differences
253
  - C) The final validation step
@@ -259,7 +314,6 @@ What does "convergence" mean in the iterative approach?
259
 
260
  ### Question 5
261
  What is the recommended learning order?
262
-
263
  - A) Approximation → Iterative → Direct
264
  - B) Iterative → Direct → Approximation
265
  - C) Direct → Iterative → Approximation
@@ -271,7 +325,6 @@ What is the recommended learning order?
271
 
272
  ### Question 6
273
  What does the direct method require?
274
-
275
  - A) Multiple iterations
276
  - B) Complete data
277
  - C) A confidence threshold
@@ -283,7 +336,6 @@ What does the direct method require?
283
 
284
  ### Question 7
285
  What is a "confidence threshold"?
286
-
287
  - A) How sure you are about your method choice
288
  - B) The minimum acceptable certainty for a result
289
  - C) The maximum number of iterations
@@ -295,7 +347,6 @@ What is a "confidence threshold"?
295
 
296
  ### Question 8
297
  How do lecture notes differ from the textbook?
298
-
299
  - A) Lectures focus on theory, textbook on application
300
  - B) Lectures focus on application, textbook on theory
301
  - C) They cover different topics entirely
@@ -307,7 +358,6 @@ How do lecture notes differ from the textbook?
307
 
308
  ### Question 9
309
  What is a "base case" in this context?
310
-
311
  - A) The most complex problem instance
312
  - B) The simplest problem instance used as a starting point
313
  - C) The final validated result
@@ -319,7 +369,6 @@ What is a "base case" in this context?
319
 
320
  ### Question 10
321
  The three-stage process applies to which methods?
322
-
323
  - A) Only the direct method
324
  - B) Only the iterative method
325
  - C) Direct and iterative only
@@ -330,21 +379,7 @@ The three-stage process applies to which methods?
330
  }
331
 
332
 
333
- def get_latest_artifact(notebook: dict, artifact_type: str) -> dict | None:
334
- """Get the most recent artifact of a given type."""
335
- for artifact in reversed(notebook["artifacts"]):
336
- if artifact["type"] == artifact_type:
337
- return artifact
338
- return None
339
-
340
-
341
- def get_all_artifacts(notebook: dict, artifact_type: str) -> list[dict]:
342
- """Get all artifacts of a given type, newest first."""
343
- return [a for a in reversed(notebook["artifacts"]) if a["type"] == artifact_type]
344
-
345
-
346
- def generate_mock_artifact(artifact_type: str, **kwargs) -> dict:
347
- """Simulate artifact generation."""
348
  time.sleep(1.5)
349
 
350
  if artifact_type == "conversation_summary":
@@ -366,465 +401,11 @@ def generate_mock_artifact(artifact_type: str, **kwargs) -> dict:
366
  content = ""
367
  title = artifact_type
368
 
369
- return {
370
- "id": str(uuid.uuid4()),
371
- "type": artifact_type,
372
- "title": title,
373
- "content": content,
374
- "audio_path": None,
375
- "created_at": datetime.now().isoformat(),
376
- }
377
-
378
-
379
- def _render_artifact_card(artifact: dict, index: int, notebook: dict):
380
- """Render a single artifact with actions."""
381
- try:
382
- dt = datetime.fromisoformat(artifact["created_at"])
383
- time_str = dt.strftime("%b %d at %H:%M")
384
- except (ValueError, KeyError):
385
- time_str = ""
386
-
387
- st.markdown(
388
- f"""
389
- <div style="
390
- padding: 4px 0 8px 0;
391
- font-size: 0.8rem;
392
- color: #707088;
393
- ">Generated {time_str}</div>
394
- """,
395
- unsafe_allow_html=True,
396
  )
397
-
398
- # Content
399
- with st.container(height=400, border=True):
400
- st.markdown(artifact["content"])
401
-
402
- # Audio player for podcast
403
- if artifact["type"] == "podcast":
404
- if artifact.get("audio_path"):
405
- st.audio(artifact["audio_path"])
406
- else:
407
- st.markdown(
408
- """
409
- <div style="
410
- display: flex; align-items: center; gap: 10px;
411
- padding: 12px 16px;
412
- background: rgba(102,126,234,0.06);
413
- border: 1px solid rgba(102,126,234,0.15);
414
- border-radius: 10px;
415
- margin-top: 8px;
416
- ">
417
- <span style="font-size: 1.3rem;">🔇</span>
418
- <span style="font-size: 0.85rem; color: #8888aa;">
419
- Audio player will appear here when TTS is connected.
420
- </span>
421
- </div>
422
- """,
423
- unsafe_allow_html=True,
424
- )
425
-
426
- # Actions
427
- st.markdown('<div style="margin-top: 8px;"></div>', unsafe_allow_html=True)
428
- c1, c2, c3 = st.columns([1, 1, 3])
429
- with c1:
430
- st.download_button(
431
- "Download .md",
432
- data=artifact["content"],
433
- file_name=f"{artifact['title'].lower().replace(' ', '_')}.md",
434
- mime="text/markdown",
435
- key=f"dl_{artifact['id']}",
436
- use_container_width=True,
437
- )
438
- with c2:
439
- if st.button("Delete", key=f"del_{artifact['id']}", use_container_width=True):
440
- notebook["artifacts"].remove(artifact)
441
- st.rerun()
442
-
443
-
444
- def _render_history(artifacts: list[dict], label: str):
445
- """Show older artifacts in a collapsed section."""
446
- if len(artifacts) > 1:
447
- with st.expander(f"Previous {label} ({len(artifacts) - 1})"):
448
- for a in artifacts[1:]:
449
- try:
450
- dt = datetime.fromisoformat(a["created_at"])
451
- time_str = dt.strftime("%b %d at %H:%M")
452
- except (ValueError, KeyError):
453
- time_str = ""
454
- st.markdown(f"**{a['title']}** — {time_str}")
455
- with st.container(height=200, border=True):
456
- st.markdown(a["content"])
457
- c1, c2, c3 = st.columns([1, 1, 4])
458
- with c1:
459
- st.download_button(
460
- "Download",
461
- data=a["content"],
462
- file_name=f"{a['title'].lower().replace(' ', '_')}.md",
463
- mime="text/markdown",
464
- key=f"dl_hist_{a['id']}",
465
- use_container_width=True,
466
- )
467
- with c2:
468
- # No delete in history to keep things simple
469
- pass
470
-
471
-
472
- # ── Main Render ──────────────────────────────────────────────────────────────
473
-
474
- def render_artifacts(notebook: dict):
475
- """Render artifact generation with sub-tabs: Summary | Podcast | Quiz."""
476
-
477
- if not notebook["sources"]:
478
- st.markdown(
479
- """
480
- <div class="empty-state">
481
- <div style="font-size: 3rem; margin-bottom: 16px;">🎯</div>
482
- <h3>Add sources first</h3>
483
- <p>Upload documents in the <strong>Sources</strong> tab to unlock<br>
484
- summary, quiz, and podcast generation.</p>
485
- </div>
486
- """,
487
- unsafe_allow_html=True,
488
- )
489
- return
490
-
491
- tab_summary, tab_podcast, tab_quiz = st.tabs(
492
- [" Summary ", " Podcast ", " Quiz "]
493
- )
494
-
495
- # ── SUMMARY TAB ──────────────────────────────────────────────────────────
496
- with tab_summary:
497
-
498
- # ── Section 1: Conversation Summary ──
499
- st.markdown(
500
- """
501
- <div style="
502
- display: flex; align-items: center; gap: 10px;
503
- margin-bottom: 12px;
504
- ">
505
- <div style="
506
- width: 36px; height: 36px; border-radius: 10px;
507
- background: rgba(102,126,234,0.12);
508
- display: flex; align-items: center; justify-content: center;
509
- font-size: 1.1rem;
510
- ">💬</div>
511
- <div>
512
- <span style="font-weight:600; font-size:1rem; color:#e0e0f0;">
513
- Conversation Summary
514
- </span>
515
- <p style="font-size:0.82rem; color:#808098; margin:2px 0 0 0;">
516
- Summarize your chat history — topics discussed, key insights, and citations used.
517
- </p>
518
- </div>
519
- </div>
520
- """,
521
- unsafe_allow_html=True,
522
- )
523
-
524
- has_messages = len(notebook["messages"]) > 0
525
-
526
- if not has_messages:
527
- st.markdown(
528
- """
529
- <div style="
530
- text-align: center; padding: 30px 20px;
531
- color: #606078;
532
- border: 1px dashed rgba(255,255,255,0.08);
533
- border-radius: 14px;
534
- ">
535
- <p style="margin:0;">No conversation yet. Start chatting in the <strong>Chat</strong> tab first.</p>
536
- </div>
537
- """,
538
- unsafe_allow_html=True,
539
- )
540
- else:
541
- col_cs1, col_cs2, col_cs3 = st.columns([2, 2, 2])
542
- with col_cs1:
543
- conv_style = st.radio(
544
- "Style",
545
- ["brief", "detailed"],
546
- format_func=lambda x: "Brief" if x == "brief" else "Detailed",
547
- horizontal=True,
548
- key=f"conv_sum_style_{notebook['id']}",
549
- )
550
- with col_cs3:
551
- st.markdown('<div style="margin-top: 24px;"></div>', unsafe_allow_html=True)
552
- gen_conv_sum = st.button(
553
- "Generate Conversation Summary",
554
- type="primary",
555
- use_container_width=True,
556
- key=f"gen_conv_sum_{notebook['id']}",
557
- )
558
-
559
- if gen_conv_sum:
560
- with st.spinner("Summarizing conversation..."):
561
- artifact = generate_mock_artifact("conversation_summary", style=conv_style)
562
- notebook["artifacts"].append(artifact)
563
- st.rerun()
564
-
565
- conv_summaries = get_all_artifacts(notebook, "conversation_summary")
566
- if conv_summaries:
567
- _render_artifact_card(conv_summaries[0], 0, notebook)
568
- _render_history(conv_summaries, "conversation summaries")
569
-
570
- # ── Divider between sections ──
571
- st.markdown(
572
- '<div style="margin: 30px 0; border-top: 1px solid rgba(255,255,255,0.06);"></div>',
573
- unsafe_allow_html=True,
574
- )
575
-
576
- # ── Section 2: Document Summary ──
577
- st.markdown(
578
- """
579
- <div style="
580
- display: flex; align-items: center; gap: 10px;
581
- margin-bottom: 12px;
582
- ">
583
- <div style="
584
- width: 36px; height: 36px; border-radius: 10px;
585
- background: rgba(34,197,94,0.12);
586
- display: flex; align-items: center; justify-content: center;
587
- font-size: 1.1rem;
588
- ">📄</div>
589
- <div>
590
- <span style="font-weight:600; font-size:1rem; color:#e0e0f0;">
591
- Document Summary
592
- </span>
593
- <p style="font-size:0.82rem; color:#808098; margin:2px 0 0 0;">
594
- Summarize content from your uploaded sources — key concepts, themes, and connections.
595
- </p>
596
- </div>
597
- </div>
598
- """,
599
- unsafe_allow_html=True,
600
- )
601
-
602
- col_ds1, col_ds2, col_ds3 = st.columns([2, 2, 2])
603
- with col_ds1:
604
- doc_style = st.radio(
605
- "Style",
606
- ["brief", "detailed"],
607
- format_func=lambda x: "Brief (1 page)" if x == "brief" else "Detailed (full analysis)",
608
- horizontal=True,
609
- key=f"doc_sum_style_{notebook['id']}",
610
- )
611
- with col_ds3:
612
- st.markdown('<div style="margin-top: 24px;"></div>', unsafe_allow_html=True)
613
- gen_doc_sum = st.button(
614
- "Generate Document Summary",
615
- type="primary",
616
- use_container_width=True,
617
- key=f"gen_doc_sum_{notebook['id']}",
618
- )
619
-
620
- if gen_doc_sum:
621
- with st.spinner("Analyzing sources and generating summary..."):
622
- artifact = generate_mock_artifact("document_summary", style=doc_style)
623
- notebook["artifacts"].append(artifact)
624
- st.rerun()
625
-
626
- doc_summaries = get_all_artifacts(notebook, "document_summary")
627
- if doc_summaries:
628
- _render_artifact_card(doc_summaries[0], 0, notebook)
629
- _render_history(doc_summaries, "document summaries")
630
-
631
- # ── PODCAST TAB ──────────────────────────────────────────────────────────
632
- with tab_podcast:
633
- # Podcast depends on having any summary (conversation or document)
634
- latest_doc_summary = get_latest_artifact(notebook, "document_summary")
635
- latest_conv_summary = get_latest_artifact(notebook, "conversation_summary")
636
- latest_summary = latest_doc_summary or latest_conv_summary
637
- has_summary = latest_summary is not None
638
-
639
- st.markdown(
640
- """
641
- <div style="margin-bottom: 20px;">
642
- <span style="font-weight:600; font-size:1rem; color:#e0e0f0;">
643
- Generate Podcast
644
- </span>
645
- <p style="font-size:0.85rem; color:#808098; margin-top:4px;">
646
- Create a conversational podcast episode from your summary.
647
- </p>
648
- </div>
649
- """,
650
- unsafe_allow_html=True,
651
- )
652
-
653
- if not has_summary:
654
- # Locked state
655
- st.markdown(
656
- """
657
- <div style="
658
- text-align: center;
659
- padding: 50px 30px;
660
- background: rgba(255,255,255,0.02);
661
- border: 1px solid rgba(255,255,255,0.06);
662
- border-radius: 16px;
663
- ">
664
- <div style="font-size: 2.5rem; margin-bottom: 16px;">🔒</div>
665
- <h3 style="color: #a0a0b8; font-weight: 600; margin-bottom: 8px;">
666
- Summary Required
667
- </h3>
668
- <p style="color: #707088; font-size: 0.9rem; line-height: 1.6;">
669
- Generate a summary first in the <strong>Summary</strong> tab.<br>
670
- The podcast is created from your summary to ensure accuracy.
671
- </p>
672
- <div style="
673
- margin-top: 20px;
674
- display: inline-flex; align-items: center; gap: 8px;
675
- padding: 8px 18px;
676
- background: rgba(102,126,234,0.08);
677
- border: 1px solid rgba(102,126,234,0.15);
678
- border-radius: 20px;
679
- font-size: 0.82rem;
680
- color: #8090d0;
681
- ">
682
- 📝 Summary &nbsp;→&nbsp; 🎙️ Podcast
683
- </div>
684
- </div>
685
- """,
686
- unsafe_allow_html=True,
687
- )
688
- else:
689
- # Show which summary will be used
690
- sum_title = latest_summary["title"]
691
- try:
692
- dt = datetime.fromisoformat(latest_summary["created_at"])
693
- sum_time = dt.strftime("%b %d at %H:%M")
694
- except (ValueError, KeyError):
695
- sum_time = ""
696
-
697
- st.markdown(
698
- f"""
699
- <div style="
700
- display: flex; align-items: center; gap: 12px;
701
- padding: 12px 18px;
702
- background: rgba(34,197,94,0.06);
703
- border: 1px solid rgba(34,197,94,0.15);
704
- border-radius: 12px;
705
- margin-bottom: 16px;
706
- ">
707
- <span style="font-size: 1.2rem;">📝</span>
708
- <div>
709
- <span style="font-size: 0.85rem; color: #a0b8a0;">
710
- Based on:
711
- </span>
712
- <strong style="color: #c0e0c0;">{sum_title}</strong>
713
- <span style="color: #708070; font-size: 0.8rem;">
714
- &nbsp;({sum_time})
715
- </span>
716
- </div>
717
- </div>
718
- """,
719
- unsafe_allow_html=True,
720
- )
721
-
722
- col1, col2 = st.columns([3, 1])
723
- with col2:
724
- gen_podcast = st.button(
725
- "Generate Podcast",
726
- type="primary",
727
- use_container_width=True,
728
- key=f"gen_pod_{notebook['id']}",
729
- )
730
-
731
- if gen_podcast:
732
- with st.spinner("Creating podcast script and audio..."):
733
- artifact = generate_mock_artifact("podcast")
734
- notebook["artifacts"].append(artifact)
735
- st.rerun()
736
-
737
- # Display latest podcast
738
- podcasts = get_all_artifacts(notebook, "podcast")
739
- if podcasts:
740
- st.divider()
741
- st.markdown(
742
- f'<span style="font-weight:600; font-size:0.9rem; color:#b0b0c8;">'
743
- f'Latest Podcast</span>',
744
- unsafe_allow_html=True,
745
- )
746
- _render_artifact_card(podcasts[0], 0, notebook)
747
- _render_history(podcasts, "podcasts")
748
- else:
749
- st.markdown(
750
- """
751
- <div style="
752
- text-align: center; padding: 40px 20px;
753
- color: #606078; margin-top: 16px;
754
- border: 1px dashed rgba(255,255,255,0.08);
755
- border-radius: 14px;
756
- ">
757
- <div style="font-size: 2rem; margin-bottom: 10px;">🎙️</div>
758
- <p>No podcast generated yet.<br>
759
- Click <strong>Generate Podcast</strong> to create one from your summary.</p>
760
- </div>
761
- """,
762
- unsafe_allow_html=True,
763
- )
764
-
765
- # ── QUIZ TAB ─────────────────────────────────────────────────────────────
766
- with tab_quiz:
767
- st.markdown(
768
- """
769
- <div style="margin-bottom: 20px;">
770
- <span style="font-weight:600; font-size:1rem; color:#e0e0f0;">
771
- Generate Quiz
772
- </span>
773
- <p style="font-size:0.85rem; color:#808098; margin-top:4px;">
774
- Create multiple-choice questions from your sources to test your understanding.
775
- </p>
776
- </div>
777
- """,
778
- unsafe_allow_html=True,
779
- )
780
-
781
- col_q1, col_q2, col_q3 = st.columns([2, 2, 2])
782
- with col_q1:
783
- num_questions = st.select_slider(
784
- "Number of questions",
785
- options=[5, 10],
786
- value=5,
787
- key=f"quiz_num_{notebook['id']}",
788
- )
789
- with col_q3:
790
- st.markdown('<div style="margin-top: 24px;"></div>', unsafe_allow_html=True)
791
- gen_quiz = st.button(
792
- "Generate Quiz",
793
- type="primary",
794
- use_container_width=True,
795
- key=f"gen_quiz_{notebook['id']}",
796
- )
797
-
798
- if gen_quiz:
799
- with st.spinner(f"Generating {num_questions} questions..."):
800
- artifact = generate_mock_artifact("quiz", num_questions=num_questions)
801
- notebook["artifacts"].append(artifact)
802
- st.rerun()
803
-
804
- # Display latest quiz
805
- quizzes = get_all_artifacts(notebook, "quiz")
806
- if quizzes:
807
- st.divider()
808
- st.markdown(
809
- f'<span style="font-weight:600; font-size:0.9rem; color:#b0b0c8;">'
810
- f'Latest Quiz</span>',
811
- unsafe_allow_html=True,
812
- )
813
- _render_artifact_card(quizzes[0], 0, notebook)
814
- _render_history(quizzes, "quizzes")
815
- else:
816
- st.markdown(
817
- """
818
- <div style="
819
- text-align: center; padding: 40px 20px;
820
- color: #606078; margin-top: 16px;
821
- border: 1px dashed rgba(255,255,255,0.08);
822
- border-radius: 14px;
823
- ">
824
- <div style="font-size: 2rem; margin-bottom: 10px;">❓</div>
825
- <p>No quiz generated yet.<br>
826
- Choose the number of questions and click <strong>Generate Quiz</strong>.</p>
827
- </div>
828
- """,
829
- unsafe_allow_html=True,
830
- )
 
1
+ """All mock responses and content for the NotebookLM prototype."""
2
+
3
+ import random
4
+ import time
5
  import uuid
6
  from datetime import datetime
7
+ from state import Artifact
8
+
9
+
10
+ # ── Chat Mock Responses ──────────────────────────────────────────────────────
11
+
12
+ MOCK_RESPONSES = [
13
+ {
14
+ "content": (
15
+ "Based on the uploaded sources, the key concept revolves around "
16
+ "the relationship between the variables discussed in Chapter 3. "
17
+ "The author emphasizes that understanding this foundation is critical "
18
+ "before moving to advanced topics."
19
+ ),
20
+ "citations": [
21
+ {"source": "lecture_notes.pdf", "page": 3, "text": "the relationship between variables..."},
22
+ {"source": "textbook_ch3.pdf", "page": 42, "text": "understanding this foundation..."},
23
+ ],
24
+ },
25
+ {
26
+ "content": (
27
+ "The sources indicate three main approaches to this problem:\n\n"
28
+ "1. **Direct method** — Apply the formula from Section 2.1\n"
29
+ "2. **Iterative approach** — Build up from base cases\n"
30
+ "3. **Approximation** — Use the simplified model when precision isn't critical\n\n"
31
+ "The textbook recommends starting with the direct method for beginners."
32
+ ),
33
+ "citations": [
34
+ {"source": "textbook_ch2.pdf", "page": 15, "text": "direct method... apply the formula"},
35
+ ],
36
+ },
37
+ {
38
+ "content": (
39
+ "I couldn't find specific information about that topic in your "
40
+ "uploaded sources. Try uploading additional materials that cover this "
41
+ "subject, or rephrase your question to relate more closely to the "
42
+ "content in your current sources."
43
+ ),
44
+ "citations": [],
45
+ },
46
+ {
47
+ "content": (
48
+ "Great question! According to the lecture slides, this concept "
49
+ "was introduced in Week 5. The key takeaway is that the process involves "
50
+ "three stages: **initialization**, **processing**, and **validation**. "
51
+ "Each stage has specific requirements that must be met before proceeding."
52
+ ),
53
+ "citations": [
54
+ {"source": "week5_slides.pptx", "page": 8, "text": "three stages: initialization..."},
55
+ {"source": "week5_slides.pptx", "page": 12, "text": "specific requirements..."},
56
+ ],
57
+ },
58
+ ]
59
+
60
+
61
+ def get_mock_response(query: str) -> dict:
62
+ time.sleep(1.2)
63
+ return random.choice(MOCK_RESPONSES)
64
+
65
+
66
+ # ── Artifact Mock Content ────────────────────────────────────────────────────
67
 
68
  MOCK_CONVERSATION_SUMMARY = {
69
  "brief": """## Conversation Summary (Brief)
 
270
 
271
  ### Question 1
272
  What is the primary advantage of the direct method?
 
273
  - A) It works with incomplete data
274
  - B) It provides exact results in a single pass
275
  - C) It is the fastest method
 
281
 
282
  ### Question 2
283
  Which stage comes immediately after initialization?
 
284
  - A) Validation
285
  - B) Optimization
286
  - C) Processing
 
292
 
293
  ### Question 3
294
  When should the approximation method be preferred?
 
295
  - A) When working with critical systems
296
  - B) When precision is not the primary concern
297
  - C) When the dataset is very small
 
303
 
304
  ### Question 4
305
  What does "convergence" mean in the iterative approach?
 
306
  - A) The point where the algorithm starts
307
  - B) When iterations produce negligible differences
308
  - C) The final validation step
 
314
 
315
  ### Question 5
316
  What is the recommended learning order?
 
317
  - A) Approximation → Iterative → Direct
318
  - B) Iterative → Direct → Approximation
319
  - C) Direct → Iterative → Approximation
 
325
 
326
  ### Question 6
327
  What does the direct method require?
 
328
  - A) Multiple iterations
329
  - B) Complete data
330
  - C) A confidence threshold
 
336
 
337
  ### Question 7
338
  What is a "confidence threshold"?
 
339
  - A) How sure you are about your method choice
340
  - B) The minimum acceptable certainty for a result
341
  - C) The maximum number of iterations
 
347
 
348
  ### Question 8
349
  How do lecture notes differ from the textbook?
 
350
  - A) Lectures focus on theory, textbook on application
351
  - B) Lectures focus on application, textbook on theory
352
  - C) They cover different topics entirely
 
358
 
359
  ### Question 9
360
  What is a "base case" in this context?
 
361
  - A) The most complex problem instance
362
  - B) The simplest problem instance used as a starting point
363
  - C) The final validated result
 
369
 
370
  ### Question 10
371
  The three-stage process applies to which methods?
 
372
  - A) Only the direct method
373
  - B) Only the iterative method
374
  - C) Direct and iterative only
 
379
  }
380
 
381
 
382
+ def generate_mock_artifact(artifact_type: str, **kwargs) -> Artifact:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  time.sleep(1.5)
384
 
385
  if artifact_type == "conversation_summary":
 
401
  content = ""
402
  title = artifact_type
403
 
404
+ return Artifact(
405
+ id=str(uuid.uuid4()),
406
+ type=artifact_type,
407
+ title=title,
408
+ content=content,
409
+ audio_path=None,
410
+ created_at=datetime.now().isoformat(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
{ui → pages}/__init__.py RENAMED
File without changes
pages/artifacts.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Artifacts tab: Summary, Podcast, and Quiz generation with mock data."""
2
+
3
+ from datetime import datetime
4
+ from state import UserData, get_active_notebook, get_all_artifacts, get_latest_artifact
5
+ from mock_data import generate_mock_artifact
6
+
7
+
8
+ def _format_time(iso_str: str) -> str:
9
+ try:
10
+ dt = datetime.fromisoformat(iso_str)
11
+ return dt.strftime("%b %d at %H:%M")
12
+ except (ValueError, KeyError):
13
+ return ""
14
+
15
+
16
+ def _render_artifact_content(artifact) -> str:
17
+ """Render a single artifact as HTML with metadata."""
18
+ time_str = _format_time(artifact.created_at)
19
+ html = (
20
+ f'<div style="padding:4px 0 8px 0; font-size:0.8rem; color:#707088;">'
21
+ f'Generated {time_str}</div>'
22
+ )
23
+ html += (
24
+ f'<div style="max-height:400px; overflow-y:auto; padding:16px; '
25
+ f'background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.08); '
26
+ f'border-radius:14px;">'
27
+ )
28
+ # Convert markdown to basic display (Gradio HTML will render markdown in gr.Markdown)
29
+ html += f'<div class="artifact-content">{artifact.content}</div>'
30
+ html += '</div>'
31
+
32
+ if artifact.type == "podcast" and not artifact.audio_path:
33
+ html += (
34
+ '<div style="display:flex; align-items:center; gap:10px; padding:12px 16px; '
35
+ 'background:rgba(102,126,234,0.06); border:1px solid rgba(102,126,234,0.15); '
36
+ 'border-radius:10px; margin-top:8px;">'
37
+ '<span style="font-size:1.3rem;">🔇</span>'
38
+ '<span style="font-size:0.85rem; color:#8888aa;">Audio player will appear here when TTS is connected.</span>'
39
+ '</div>'
40
+ )
41
+ return html
42
+
43
+
44
+ def _render_history(artifacts: list, label: str) -> str:
45
+ if len(artifacts) <= 1:
46
+ return ""
47
+ html = f'<details><summary style="cursor:pointer; color:#a0a0b8; font-size:0.85rem; margin-top:12px;">Previous {label} ({len(artifacts) - 1})</summary>'
48
+ for a in artifacts[1:]:
49
+ time_str = _format_time(a.created_at)
50
+ html += f'<div style="margin-top:12px; padding:12px; border:1px solid rgba(255,255,255,0.06); border-radius:10px;">'
51
+ html += f'<strong>{a.title}</strong> — {time_str}'
52
+ html += f'<div style="max-height:200px; overflow-y:auto; margin-top:8px; font-size:0.85rem;">{a.content}</div>'
53
+ html += '</div>'
54
+ html += '</details>'
55
+ return html
56
+
57
+
58
+ # ── No-sources gate ──────────────────────────────────────────────────────────
59
+
60
+ def render_no_sources_gate(state: UserData) -> str:
61
+ nb = get_active_notebook(state)
62
+ if not nb or not nb.sources:
63
+ return (
64
+ '<div class="empty-state">'
65
+ '<div style="font-size:3rem; margin-bottom:16px;">🎯</div>'
66
+ '<h3>Add sources first</h3>'
67
+ '<p>Upload documents in the <strong>Sources</strong> tab to unlock '
68
+ 'summary, quiz, and podcast generation.</p>'
69
+ '</div>'
70
+ )
71
+ return ""
72
+
73
+
74
+ def has_sources(state: UserData) -> bool:
75
+ nb = get_active_notebook(state)
76
+ return nb is not None and len(nb.sources) > 0
77
+
78
+
79
+ # ── Conversation Summary ─────────────────────────────────────────────────────
80
+
81
+ def render_conv_summary_section(state: UserData) -> str:
82
+ nb = get_active_notebook(state)
83
+ if not nb:
84
+ return ""
85
+ if not nb.messages:
86
+ return (
87
+ '<div style="text-align:center; padding:30px 20px; color:#606078; '
88
+ 'border:1px dashed rgba(255,255,255,0.08); border-radius:14px;">'
89
+ '<p style="margin:0;">No conversation yet. Start chatting in the <strong>Chat</strong> tab first.</p>'
90
+ '</div>'
91
+ )
92
+ summaries = get_all_artifacts(nb, "conversation_summary")
93
+ if not summaries:
94
+ return '<p style="color:#606078; text-align:center; padding:20px;">Click "Generate" to create a conversation summary.</p>'
95
+ html = _render_artifact_content(summaries[0])
96
+ html += _render_history(summaries, "conversation summaries")
97
+ return html
98
+
99
+
100
+ def handle_gen_conv_summary(style: str, state: UserData) -> tuple[UserData, str]:
101
+ nb = get_active_notebook(state)
102
+ if not nb or not nb.messages:
103
+ return state, render_conv_summary_section(state)
104
+ artifact = generate_mock_artifact("conversation_summary", style=style or "detailed")
105
+ nb.artifacts.append(artifact)
106
+ return state, render_conv_summary_section(state)
107
+
108
+
109
+ # ── Document Summary ─────────────────────────────────────────────────────────
110
+
111
+ def render_doc_summary_section(state: UserData) -> str:
112
+ nb = get_active_notebook(state)
113
+ if not nb:
114
+ return ""
115
+ summaries = get_all_artifacts(nb, "document_summary")
116
+ if not summaries:
117
+ return '<p style="color:#606078; text-align:center; padding:20px;">Click "Generate" to create a document summary.</p>'
118
+ html = _render_artifact_content(summaries[0])
119
+ html += _render_history(summaries, "document summaries")
120
+ return html
121
+
122
+
123
+ def handle_gen_doc_summary(style: str, state: UserData) -> tuple[UserData, str]:
124
+ nb = get_active_notebook(state)
125
+ if not nb:
126
+ return state, render_doc_summary_section(state)
127
+ artifact = generate_mock_artifact("document_summary", style=style or "detailed")
128
+ nb.artifacts.append(artifact)
129
+ return state, render_doc_summary_section(state)
130
+
131
+
132
+ # ── Podcast ──────────────────────────────────────────────────────────────────
133
+
134
+ def has_any_summary(state: UserData) -> bool:
135
+ nb = get_active_notebook(state)
136
+ if not nb:
137
+ return False
138
+ return (
139
+ get_latest_artifact(nb, "document_summary") is not None
140
+ or get_latest_artifact(nb, "conversation_summary") is not None
141
+ )
142
+
143
+
144
+ def render_podcast_locked() -> str:
145
+ return (
146
+ '<div class="locked-state">'
147
+ '<div style="font-size:2.5rem; margin-bottom:16px;">🔒</div>'
148
+ '<h3 style="color:#a0a0b8; font-weight:600; margin-bottom:8px;">Summary Required</h3>'
149
+ '<p style="color:#707088; font-size:0.9rem; line-height:1.6;">'
150
+ 'Generate a summary first in the <strong>Summary</strong> tab.<br>'
151
+ 'The podcast is created from your summary to ensure accuracy.</p>'
152
+ '<div style="margin-top:20px; display:inline-flex; align-items:center; gap:8px; '
153
+ 'padding:8px 18px; background:rgba(102,126,234,0.08); '
154
+ 'border:1px solid rgba(102,126,234,0.15); border-radius:20px; '
155
+ 'font-size:0.82rem; color:#8090d0;">'
156
+ '📝 Summary &nbsp;→&nbsp; 🎙️ Podcast</div>'
157
+ '</div>'
158
+ )
159
+
160
+
161
+ def render_podcast_section(state: UserData) -> str:
162
+ nb = get_active_notebook(state)
163
+ if not nb:
164
+ return ""
165
+
166
+ if not has_any_summary(state):
167
+ return render_podcast_locked()
168
+
169
+ # Show which summary will be used
170
+ latest_doc = get_latest_artifact(nb, "document_summary")
171
+ latest_conv = get_latest_artifact(nb, "conversation_summary")
172
+ latest = latest_doc or latest_conv
173
+ sum_title = latest.title
174
+ sum_time = _format_time(latest.created_at)
175
+
176
+ html = (
177
+ f'<div style="display:flex; align-items:center; gap:12px; padding:12px 18px; '
178
+ f'background:rgba(34,197,94,0.06); border:1px solid rgba(34,197,94,0.15); '
179
+ f'border-radius:12px; margin-bottom:16px;">'
180
+ f'<span style="font-size:1.2rem;">📝</span>'
181
+ f'<div><span style="font-size:0.85rem; color:#a0b8a0;">Based on: </span>'
182
+ f'<strong style="color:#c0e0c0;">{sum_title}</strong>'
183
+ f'<span style="color:#708070; font-size:0.8rem;"> ({sum_time})</span></div>'
184
+ f'</div>'
185
+ )
186
+
187
+ podcasts = get_all_artifacts(nb, "podcast")
188
+ if podcasts:
189
+ html += _render_artifact_content(podcasts[0])
190
+ html += _render_history(podcasts, "podcasts")
191
+ else:
192
+ html += (
193
+ '<div style="text-align:center; padding:40px 20px; color:#606078; margin-top:16px; '
194
+ 'border:1px dashed rgba(255,255,255,0.08); border-radius:14px;">'
195
+ '<div style="font-size:2rem; margin-bottom:10px;">🎙️</div>'
196
+ '<p>No podcast generated yet.<br>Click <strong>Generate Podcast</strong> to create one.</p>'
197
+ '</div>'
198
+ )
199
+ return html
200
+
201
+
202
+ def handle_gen_podcast(state: UserData) -> tuple[UserData, str]:
203
+ nb = get_active_notebook(state)
204
+ if not nb or not has_any_summary(state):
205
+ return state, render_podcast_section(state)
206
+ artifact = generate_mock_artifact("podcast")
207
+ nb.artifacts.append(artifact)
208
+ return state, render_podcast_section(state)
209
+
210
+
211
+ # ── Quiz ─────────────────────────────────────────────────────────────────────
212
+
213
+ def render_quiz_section(state: UserData) -> str:
214
+ nb = get_active_notebook(state)
215
+ if not nb:
216
+ return ""
217
+ quizzes = get_all_artifacts(nb, "quiz")
218
+ if not quizzes:
219
+ return (
220
+ '<div style="text-align:center; padding:40px 20px; color:#606078; margin-top:16px; '
221
+ 'border:1px dashed rgba(255,255,255,0.08); border-radius:14px;">'
222
+ '<div style="font-size:2rem; margin-bottom:10px;">❓</div>'
223
+ '<p>No quiz generated yet.<br>Choose the number of questions and click <strong>Generate Quiz</strong>.</p>'
224
+ '</div>'
225
+ )
226
+ html = _render_artifact_content(quizzes[0])
227
+ html += _render_history(quizzes, "quizzes")
228
+ return html
229
+
230
+
231
+ def handle_gen_quiz(num_questions: int, state: UserData) -> tuple[UserData, str]:
232
+ nb = get_active_notebook(state)
233
+ if not nb:
234
+ return state, render_quiz_section(state)
235
+ num_q = int(num_questions) if num_questions else 5
236
+ artifact = generate_mock_artifact("quiz", num_questions=num_q)
237
+ nb.artifacts.append(artifact)
238
+ return state, render_quiz_section(state)
pages/chat.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat tab: message display with citations and mock RAG responses."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from state import UserData, Message, get_active_notebook
6
+ from mock_data import get_mock_response
7
+
8
+
9
+ FILE_TYPE_ICONS = {
10
+ "pdf": "📕", "pptx": "📊", "txt": "📝", "url": "🌐", "youtube": "🎬",
11
+ }
12
+
13
+
14
+ def format_chatbot_messages(state: UserData) -> list[dict]:
15
+ """Convert notebook messages to gr.Chatbot format with embedded citations."""
16
+ nb = get_active_notebook(state)
17
+ if not nb or not nb.messages:
18
+ return []
19
+
20
+ formatted = []
21
+ for msg in nb.messages:
22
+ content = msg.content
23
+ if msg.role == "assistant" and msg.citations:
24
+ # Add citation chips
25
+ chips = ""
26
+ for c in msg.citations:
27
+ chips += f'<span class="citation-chip">📄 {c["source"]} · p.{c["page"]}</span>'
28
+ content += f"\n\n{chips}"
29
+ # Add expandable passages
30
+ passages = ""
31
+ for c in msg.citations:
32
+ passages += f'> *"{c["text"]}"*\n>\n> — **{c["source"]}**, page {c["page"]}\n\n'
33
+ content += f"\n\n<details><summary>View cited passages</summary>\n\n{passages}</details>"
34
+ formatted.append({"role": msg.role, "content": content})
35
+ return formatted
36
+
37
+
38
+ def render_no_sources_warning(state: UserData) -> str:
39
+ nb = get_active_notebook(state)
40
+ if not nb or len(nb.sources) == 0:
41
+ return (
42
+ '<div style="padding:14px 20px; background:rgba(234,179,8,0.08); '
43
+ 'border:1px solid rgba(234,179,8,0.2); border-radius:12px; color:#d4a017; '
44
+ 'font-size:0.9rem; margin-bottom:16px;">'
45
+ 'Upload sources in the <strong>Sources</strong> tab to start chatting with your documents.'
46
+ '</div>'
47
+ )
48
+ return ""
49
+
50
+
51
+ def handle_chat_submit(message: str, state: UserData) -> tuple[UserData, list[dict], str, str]:
52
+ """Handle user sending a chat message. Returns (state, chatbot_messages, textbox_value, warning_html)."""
53
+ if not message or not message.strip():
54
+ return state, format_chatbot_messages(state), "", render_no_sources_warning(state)
55
+
56
+ nb = get_active_notebook(state)
57
+ if not nb:
58
+ return state, [], "", ""
59
+
60
+ # Add user message
61
+ user_msg = Message(
62
+ id=str(uuid.uuid4()),
63
+ role="user",
64
+ content=message.strip(),
65
+ citations=[],
66
+ created_at=datetime.now().isoformat(),
67
+ )
68
+ nb.messages.append(user_msg)
69
+
70
+ # Get mock response
71
+ response = get_mock_response(message)
72
+
73
+ # Add assistant message
74
+ assistant_msg = Message(
75
+ id=str(uuid.uuid4()),
76
+ role="assistant",
77
+ content=response["content"],
78
+ citations=response["citations"],
79
+ created_at=datetime.now().isoformat(),
80
+ )
81
+ nb.messages.append(assistant_msg)
82
+
83
+ return state, format_chatbot_messages(state), "", render_no_sources_warning(state)
84
+
85
+
86
+ def handle_clear_chat(state: UserData) -> tuple[UserData, list[dict], str]:
87
+ """Clear all messages from the active notebook."""
88
+ nb = get_active_notebook(state)
89
+ if nb:
90
+ nb.messages = []
91
+ return state, [], render_no_sources_warning(state)
pages/sources.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sources tab: file upload, URL input, and source list management."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from state import UserData, Source, get_active_notebook
6
+
7
+ ALLOWED_TYPES = ["pdf", "pptx", "txt"]
8
+ MAX_FILE_SIZE_MB = 15
9
+ MAX_SOURCES_PER_NOTEBOOK = 20
10
+
11
+ FILE_TYPE_CONFIG = {
12
+ "pdf": {"icon": "📕", "color": "239,68,68", "label": "PDF"},
13
+ "pptx": {"icon": "📊", "color": "249,115,22", "label": "PPTX"},
14
+ "txt": {"icon": "📝", "color": "59,130,246", "label": "TXT"},
15
+ "url": {"icon": "🌐", "color": "34,197,94", "label": "URL"},
16
+ "youtube": {"icon": "🎬", "color": "239,68,68", "label": "YouTube"},
17
+ }
18
+
19
+
20
+ def render_source_header(state: UserData) -> str:
21
+ nb = get_active_notebook(state)
22
+ if not nb:
23
+ return ""
24
+ total = len(nb.sources)
25
+ remaining = MAX_SOURCES_PER_NOTEBOOK - total
26
+ return (
27
+ f'<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:20px;">'
28
+ f'<div>'
29
+ f'<span style="font-size:1.1rem; font-weight:600; color:#e0e0f0;">Sources</span>'
30
+ f'<span style="margin-left:10px; padding:3px 10px; background:rgba(102,126,234,0.15); '
31
+ f'color:#8090d0; border-radius:12px; font-size:0.8rem; font-weight:600;">'
32
+ f'{total} / {MAX_SOURCES_PER_NOTEBOOK}</span>'
33
+ f'</div>'
34
+ f'<span style="font-size:0.8rem; color:#606078;">{remaining} slots remaining</span>'
35
+ f'</div>'
36
+ )
37
+
38
+
39
+ def render_source_list(state: UserData) -> str:
40
+ nb = get_active_notebook(state)
41
+ if not nb or not nb.sources:
42
+ return (
43
+ '<div style="text-align:center; padding:50px 20px; color:#606078;">'
44
+ '<div style="font-size:3rem; margin-bottom:16px;">📄</div>'
45
+ '<h3 style="color:#a0a0b8; font-weight:600;">No sources yet</h3>'
46
+ '<p style="font-size:0.9rem;">Upload documents or add web links above.<br>'
47
+ 'Your sources power the AI chat and artifact generation.</p>'
48
+ '</div>'
49
+ )
50
+
51
+ html = f'<p style="font-weight:600; font-size:0.9rem; color:#a0a0b8; margin-bottom:12px;">Your Sources ({len(nb.sources)})</p>'
52
+ for source in nb.sources:
53
+ ft = source.file_type
54
+ cfg = FILE_TYPE_CONFIG.get(ft, {"icon": "📄", "color": "150,150,170", "label": ft.upper()})
55
+ meta_parts = [cfg["label"]]
56
+ if source.size_mb:
57
+ meta_parts.append(f"{source.size_mb} MB")
58
+ if source.chunk_count > 0:
59
+ meta_parts.append(f"{source.chunk_count} chunks")
60
+ meta_str = " · ".join(meta_parts)
61
+
62
+ html += (
63
+ f'<div class="source-card">'
64
+ f'<div class="source-icon {ft}">{cfg["icon"]}</div>'
65
+ f'<div class="source-info">'
66
+ f'<div class="name">{source.filename}</div>'
67
+ f'<div class="meta">{meta_str}</div>'
68
+ f'</div>'
69
+ f'<span class="source-badge ready">Ready</span>'
70
+ f'</div>'
71
+ )
72
+ return html
73
+
74
+
75
+ def get_source_choices(state: UserData) -> list[str]:
76
+ nb = get_active_notebook(state)
77
+ if not nb:
78
+ return []
79
+ return [s.filename for s in nb.sources]
80
+
81
+
82
+ def handle_file_upload(files, state: UserData) -> tuple[UserData, str, str, list[str]]:
83
+ """Handle file upload. Returns (state, source_list_html, header_html, source_choices)."""
84
+ nb = get_active_notebook(state)
85
+ if not nb or not files:
86
+ return state, render_source_list(state), render_source_header(state), get_source_choices(state)
87
+
88
+ for f in files:
89
+ filename = f.name if hasattr(f, 'name') else str(f).rsplit("/", 1)[-1]
90
+ # Extract just the filename from the path
91
+ filename = filename.rsplit("/", 1)[-1] if "/" in filename else filename
92
+
93
+ existing_names = [s.filename for s in nb.sources]
94
+ if filename in existing_names:
95
+ continue
96
+ if len(nb.sources) >= MAX_SOURCES_PER_NOTEBOOK:
97
+ break
98
+
99
+ file_ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
100
+ if file_ext not in ALLOWED_TYPES:
101
+ continue
102
+
103
+ # Get file size
104
+ try:
105
+ import os
106
+ file_path = f.name if hasattr(f, 'name') else str(f)
107
+ size_bytes = os.path.getsize(file_path)
108
+ size_mb = round(size_bytes / (1024 * 1024), 2)
109
+ except Exception:
110
+ size_mb = 0
111
+
112
+ if size_mb > MAX_FILE_SIZE_MB:
113
+ continue
114
+
115
+ source = Source(
116
+ id=str(uuid.uuid4()),
117
+ filename=filename,
118
+ file_type=file_ext,
119
+ size_mb=size_mb,
120
+ source_url=None,
121
+ chunk_count=0,
122
+ status="ready",
123
+ error_message=None,
124
+ created_at=datetime.now().isoformat(),
125
+ )
126
+ nb.sources.append(source)
127
+
128
+ return state, render_source_list(state), render_source_header(state), get_source_choices(state)
129
+
130
+
131
+ def handle_url_add(url: str, state: UserData) -> tuple[UserData, str, str, str, list[str]]:
132
+ """Handle adding a URL source. Returns (state, source_list_html, header_html, url_textbox_value, source_choices)."""
133
+ nb = get_active_notebook(state)
134
+ if not nb or not url or not url.strip():
135
+ return state, render_source_list(state), render_source_header(state), "", get_source_choices(state)
136
+
137
+ url = url.strip()
138
+ if len(nb.sources) >= MAX_SOURCES_PER_NOTEBOOK:
139
+ return state, render_source_list(state), render_source_header(state), "", get_source_choices(state)
140
+
141
+ existing_urls = [s.source_url for s in nb.sources if s.source_url]
142
+ if url in existing_urls:
143
+ return state, render_source_list(state), render_source_header(state), "", get_source_choices(state)
144
+
145
+ is_youtube = "youtube.com" in url or "youtu.be" in url
146
+ file_type = "youtube" if is_youtube else "url"
147
+ display_name = url[:55] + "..." if len(url) > 55 else url
148
+
149
+ source = Source(
150
+ id=str(uuid.uuid4()),
151
+ filename=display_name,
152
+ file_type=file_type,
153
+ size_mb=None,
154
+ source_url=url,
155
+ chunk_count=0,
156
+ status="ready",
157
+ error_message=None,
158
+ created_at=datetime.now().isoformat(),
159
+ )
160
+ nb.sources.append(source)
161
+
162
+ return state, render_source_list(state), render_source_header(state), "", get_source_choices(state)
163
+
164
+
165
+ def handle_source_delete(source_name: str, state: UserData) -> tuple[UserData, str, str, list[str]]:
166
+ """Delete a source by filename. Returns (state, source_list_html, header_html, source_choices)."""
167
+ nb = get_active_notebook(state)
168
+ if not nb or not source_name:
169
+ return state, render_source_list(state), render_source_header(state), get_source_choices(state)
170
+
171
+ nb.sources = [s for s in nb.sources if s.filename != source_name]
172
+ return state, render_source_list(state), render_source_header(state), get_source_choices(state)
requirements.txt CHANGED
@@ -1,2 +1 @@
1
- streamlit>=1.42.0
2
- authlib>=1.3.2
 
1
+ gradio>=5.0.0
 
state.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-user state management with dataclasses and CRUD helpers."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ import uuid
6
+
7
+
8
+ @dataclass
9
+ class Source:
10
+ id: str
11
+ filename: str
12
+ file_type: str # "pdf", "pptx", "txt", "url", "youtube"
13
+ size_mb: float | None
14
+ source_url: str | None
15
+ chunk_count: int
16
+ status: str # "ready", "processing", "failed"
17
+ error_message: str | None
18
+ created_at: str
19
+
20
+
21
+ @dataclass
22
+ class Message:
23
+ id: str
24
+ role: str # "user" or "assistant"
25
+ content: str
26
+ citations: list[dict] # [{source, page, text}]
27
+ created_at: str
28
+
29
+
30
+ @dataclass
31
+ class Artifact:
32
+ id: str
33
+ type: str # "conversation_summary", "document_summary", "podcast", "quiz"
34
+ title: str
35
+ content: str
36
+ audio_path: str | None
37
+ created_at: str
38
+
39
+
40
+ @dataclass
41
+ class Notebook:
42
+ id: str
43
+ title: str
44
+ created_at: str
45
+ sources: list[Source] = field(default_factory=list)
46
+ messages: list[Message] = field(default_factory=list)
47
+ artifacts: list[Artifact] = field(default_factory=list)
48
+
49
+
50
+ @dataclass
51
+ class UserData:
52
+ user_id: str
53
+ user_name: str
54
+ notebooks: dict[str, Notebook] = field(default_factory=dict)
55
+ active_notebook_id: str | None = None
56
+
57
+
58
+ def create_default_user_data(user_id: str, user_name: str) -> UserData:
59
+ nb_id = str(uuid.uuid4())
60
+ default_nb = Notebook(
61
+ id=nb_id,
62
+ title="My First Notebook",
63
+ created_at=datetime.now().isoformat(),
64
+ )
65
+ return UserData(
66
+ user_id=user_id,
67
+ user_name=user_name,
68
+ notebooks={nb_id: default_nb},
69
+ active_notebook_id=nb_id,
70
+ )
71
+
72
+
73
+ def get_active_notebook(state: UserData) -> Notebook | None:
74
+ if state and state.active_notebook_id and state.active_notebook_id in state.notebooks:
75
+ return state.notebooks[state.active_notebook_id]
76
+ return None
77
+
78
+
79
+ def create_notebook(state: UserData, title: str) -> UserData:
80
+ nb_id = str(uuid.uuid4())
81
+ state.notebooks[nb_id] = Notebook(
82
+ id=nb_id,
83
+ title=title,
84
+ created_at=datetime.now().isoformat(),
85
+ )
86
+ state.active_notebook_id = nb_id
87
+ return state
88
+
89
+
90
+ def delete_notebook(state: UserData, nb_id: str) -> UserData:
91
+ if nb_id in state.notebooks:
92
+ del state.notebooks[nb_id]
93
+ remaining = list(state.notebooks.keys())
94
+ state.active_notebook_id = remaining[0] if remaining else None
95
+ return state
96
+
97
+
98
+ def rename_notebook(state: UserData, nb_id: str, new_title: str) -> UserData:
99
+ if nb_id in state.notebooks:
100
+ state.notebooks[nb_id].title = new_title
101
+ return state
102
+
103
+
104
+ def get_notebook_choices(state: UserData) -> list[tuple[str, str]]:
105
+ """Return list of (display_label, notebook_id) for gr.Radio."""
106
+ choices = []
107
+ for nb_id, nb in state.notebooks.items():
108
+ src_count = len(nb.sources)
109
+ msg_count = len(nb.messages)
110
+ label = nb.title
111
+ if src_count > 0 or msg_count > 0:
112
+ label += f" ({src_count}s, {msg_count}m)"
113
+ choices.append((label, nb_id))
114
+ return choices
115
+
116
+
117
+ def get_all_artifacts(notebook: Notebook, artifact_type: str) -> list[Artifact]:
118
+ return [a for a in reversed(notebook.artifacts) if a.type == artifact_type]
119
+
120
+
121
+ def get_latest_artifact(notebook: Notebook, artifact_type: str) -> Artifact | None:
122
+ for artifact in reversed(notebook.artifacts):
123
+ if artifact.type == artifact_type:
124
+ return artifact
125
+ return None
theme.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dark theme, custom CSS, and logo SVGs for the NotebookLM Gradio app."""
2
+
3
+ import base64
4
+ import gradio as gr
5
+
6
+ # ── Logo SVGs ────────────────────────────────────────────────────────────────
7
+
8
+ LOGO_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 60">
9
+ <defs>
10
+ <linearGradient id="lg1" x1="0%" y1="0%" x2="100%" y2="100%">
11
+ <stop offset="0%" style="stop-color:#667eea"/>
12
+ <stop offset="100%" style="stop-color:#764ba2"/>
13
+ </linearGradient>
14
+ <linearGradient id="lg2" x1="0%" y1="0%" x2="100%" y2="100%">
15
+ <stop offset="0%" style="stop-color:#a78bfa"/>
16
+ <stop offset="100%" style="stop-color:#667eea"/>
17
+ </linearGradient>
18
+ <linearGradient id="sp" x1="0%" y1="0%" x2="100%" y2="100%">
19
+ <stop offset="0%" style="stop-color:#fbbf24"/>
20
+ <stop offset="100%" style="stop-color:#f59e0b"/>
21
+ </linearGradient>
22
+ </defs>
23
+ <g transform="translate(4,6)">
24
+ <rect x="2" y="4" width="36" height="44" rx="4" fill="url(#lg1)"/>
25
+ <rect x="2" y="4" width="8" height="44" rx="3" fill="url(#lg2)" opacity="0.7"/>
26
+ <line x1="16" y1="16" x2="32" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="1.8" stroke-linecap="round"/>
27
+ <line x1="16" y1="23" x2="30" y2="23" stroke="rgba(255,255,255,0.4)" stroke-width="1.8" stroke-linecap="round"/>
28
+ <line x1="16" y1="30" x2="32" y2="30" stroke="rgba(255,255,255,0.5)" stroke-width="1.8" stroke-linecap="round"/>
29
+ <line x1="16" y1="37" x2="28" y2="37" stroke="rgba(255,255,255,0.4)" stroke-width="1.8" stroke-linecap="round"/>
30
+ <g transform="translate(32,2)">
31
+ <path d="M6 0 L7.5 4.5 L12 6 L7.5 7.5 L6 12 L4.5 7.5 L0 6 L4.5 4.5 Z" fill="url(#sp)"/>
32
+ <path d="M14 8 L14.8 10.2 L17 11 L14.8 11.8 L14 14 L13.2 11.8 L11 11 L13.2 10.2 Z" fill="#fbbf24" opacity="0.7"/>
33
+ </g>
34
+ </g>
35
+ <text x="56" y="28" font-family="Inter,-apple-system,sans-serif" font-size="22" font-weight="700">
36
+ <tspan fill="url(#lg1)">Notebook</tspan><tspan fill="#a78bfa" font-weight="800">LM</tspan>
37
+ </text>
38
+ <text x="57" y="46" font-family="Inter,-apple-system,sans-serif" font-size="10.5" fill="#8888aa" font-weight="400" letter-spacing="0.8">
39
+ AI-Powered Study Companion
40
+ </text>
41
+ </svg>"""
42
+
43
+ LOGO_ICON_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 60">
44
+ <defs>
45
+ <linearGradient id="ig1" x1="0%" y1="0%" x2="100%" y2="100%">
46
+ <stop offset="0%" style="stop-color:#667eea"/>
47
+ <stop offset="100%" style="stop-color:#764ba2"/>
48
+ </linearGradient>
49
+ <linearGradient id="ig2" x1="0%" y1="0%" x2="100%" y2="100%">
50
+ <stop offset="0%" style="stop-color:#a78bfa"/>
51
+ <stop offset="100%" style="stop-color:#667eea"/>
52
+ </linearGradient>
53
+ <linearGradient id="isp" x1="0%" y1="0%" x2="100%" y2="100%">
54
+ <stop offset="0%" style="stop-color:#fbbf24"/>
55
+ <stop offset="100%" style="stop-color:#f59e0b"/>
56
+ </linearGradient>
57
+ </defs>
58
+ <g transform="translate(2,4)">
59
+ <rect x="2" y="4" width="36" height="44" rx="5" fill="url(#ig1)"/>
60
+ <rect x="2" y="4" width="9" height="44" rx="4" fill="url(#ig2)" opacity="0.7"/>
61
+ <line x1="16" y1="16" x2="32" y2="16" stroke="rgba(255,255,255,0.55)" stroke-width="2" stroke-linecap="round"/>
62
+ <line x1="16" y1="23" x2="30" y2="23" stroke="rgba(255,255,255,0.4)" stroke-width="2" stroke-linecap="round"/>
63
+ <line x1="16" y1="30" x2="32" y2="30" stroke="rgba(255,255,255,0.55)" stroke-width="2" stroke-linecap="round"/>
64
+ <line x1="16" y1="37" x2="28" y2="37" stroke="rgba(255,255,255,0.4)" stroke-width="2" stroke-linecap="round"/>
65
+ <g transform="translate(30,0)">
66
+ <path d="M7 0 L8.8 5.3 L14 7 L8.8 8.8 L7 14 L5.2 8.8 L0 7 L5.2 5.3 Z" fill="url(#isp)"/>
67
+ <path d="M16 9 L17 11.5 L19.5 12.5 L17 13.5 L16 16 L15 13.5 L12.5 12.5 L15 11.5 Z" fill="#fbbf24" opacity="0.7"/>
68
+ </g>
69
+ </g>
70
+ </svg>"""
71
+
72
+
73
+ def get_logo_b64(svg_str: str) -> str:
74
+ return base64.b64encode(svg_str.encode()).decode()
75
+
76
+
77
+ LOGO_B64 = get_logo_b64(LOGO_SVG)
78
+ ICON_B64 = get_logo_b64(LOGO_ICON_SVG)
79
+
80
+ # ── Gradio Dark Theme ────────────────────────────────────────────────────────
81
+
82
+ dark_theme = gr.themes.Base(
83
+ primary_hue=gr.themes.colors.indigo,
84
+ secondary_hue=gr.themes.colors.purple,
85
+ neutral_hue=gr.themes.colors.slate,
86
+ font=gr.themes.GoogleFont("Inter"),
87
+ ).set(
88
+ body_background_fill="#0e1117",
89
+ body_background_fill_dark="#0e1117",
90
+ block_background_fill="rgba(255,255,255,0.02)",
91
+ block_background_fill_dark="rgba(255,255,255,0.02)",
92
+ block_border_color="rgba(255,255,255,0.08)",
93
+ block_border_color_dark="rgba(255,255,255,0.08)",
94
+ block_label_text_color="#a0a0b8",
95
+ block_label_text_color_dark="#a0a0b8",
96
+ block_title_text_color="#e0e0f0",
97
+ block_title_text_color_dark="#e0e0f0",
98
+ body_text_color="#c8c8d8",
99
+ body_text_color_dark="#c8c8d8",
100
+ button_primary_background_fill="linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
101
+ button_primary_background_fill_dark="linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
102
+ button_primary_text_color="white",
103
+ button_primary_text_color_dark="white",
104
+ button_secondary_background_fill="rgba(255,255,255,0.04)",
105
+ button_secondary_background_fill_dark="rgba(255,255,255,0.04)",
106
+ button_secondary_border_color="rgba(255,255,255,0.12)",
107
+ button_secondary_border_color_dark="rgba(255,255,255,0.12)",
108
+ button_secondary_text_color="#d0d0e0",
109
+ button_secondary_text_color_dark="#d0d0e0",
110
+ border_color_primary="rgba(255,255,255,0.08)",
111
+ border_color_primary_dark="rgba(255,255,255,0.08)",
112
+ input_background_fill="rgba(255,255,255,0.03)",
113
+ input_background_fill_dark="rgba(255,255,255,0.03)",
114
+ input_border_color="rgba(255,255,255,0.1)",
115
+ input_border_color_dark="rgba(255,255,255,0.1)",
116
+ shadow_drop="none",
117
+ shadow_drop_lg="none",
118
+ shadow_spread="none",
119
+ )
120
+
121
+ # ── Custom CSS ───────────────────────────────────────────────────────────────
122
+
123
+ CUSTOM_CSS = """
124
+ /* ── Import Google Font ── */
125
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
126
+
127
+ /* ── Sidebar ── */
128
+ #sidebar {
129
+ background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%) !important;
130
+ border-right: 1px solid rgba(255,255,255,0.06);
131
+ padding: 16px !important;
132
+ min-height: 100vh;
133
+ position: sticky;
134
+ top: 0;
135
+ align-self: flex-start;
136
+ max-height: 100vh;
137
+ overflow-y: auto;
138
+ border-radius: 0 !important;
139
+ }
140
+ #sidebar .gr-button-primary {
141
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
142
+ border: none !important;
143
+ border-radius: 10px !important;
144
+ }
145
+ #sidebar .gr-button-secondary {
146
+ background: rgba(255,255,255,0.05) !important;
147
+ border: 1px solid rgba(255,255,255,0.1) !important;
148
+ border-radius: 10px !important;
149
+ color: #d0d0e0 !important;
150
+ }
151
+
152
+ /* ── Notebook selector radio ── */
153
+ #notebook-selector label {
154
+ border-radius: 10px !important;
155
+ padding: 8px 14px !important;
156
+ transition: all 0.2s ease !important;
157
+ font-size: 0.85rem !important;
158
+ }
159
+ #notebook-selector label.selected {
160
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
161
+ color: white !important;
162
+ }
163
+
164
+ /* ── Tab styling ── */
165
+ .tabs > .tab-nav > button {
166
+ border-radius: 10px !important;
167
+ padding: 10px 24px !important;
168
+ font-weight: 500 !important;
169
+ font-size: 0.9rem !important;
170
+ }
171
+ .tabs > .tab-nav > button.selected {
172
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
173
+ color: white !important;
174
+ }
175
+
176
+ /* ── Chat ── */
177
+ #chatbot {
178
+ border-radius: 14px !important;
179
+ border: 1px solid rgba(255,255,255,0.08) !important;
180
+ }
181
+ #chatbot .message {
182
+ border-radius: 14px !important;
183
+ padding: 14px 18px !important;
184
+ }
185
+
186
+ /* ── Cards ── */
187
+ .source-card {
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 16px;
191
+ padding: 16px 20px;
192
+ background: rgba(255,255,255,0.02);
193
+ border: 1px solid rgba(255,255,255,0.08);
194
+ border-radius: 14px;
195
+ margin-bottom: 10px;
196
+ transition: all 0.2s ease;
197
+ }
198
+ .source-card:hover {
199
+ border-color: rgba(102,126,234,0.3);
200
+ background: rgba(255,255,255,0.04);
201
+ }
202
+ .source-icon {
203
+ width: 48px; height: 48px; border-radius: 12px;
204
+ display: flex; align-items: center; justify-content: center;
205
+ font-size: 1.5rem; flex-shrink: 0;
206
+ }
207
+ .source-icon.pdf { background: rgba(239,68,68,0.15); }
208
+ .source-icon.pptx { background: rgba(249,115,22,0.15); }
209
+ .source-icon.txt { background: rgba(59,130,246,0.15); }
210
+ .source-icon.url { background: rgba(34,197,94,0.15); }
211
+ .source-icon.youtube { background: rgba(239,68,68,0.15); }
212
+ .source-info { flex: 1; min-width: 0; }
213
+ .source-info .name {
214
+ font-weight: 600; font-size: 0.95rem; color: #e0e0f0;
215
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
216
+ }
217
+ .source-info .meta { font-size: 0.8rem; color: #707088; margin-top: 2px; }
218
+ .source-badge {
219
+ padding: 4px 12px; border-radius: 20px; font-size: 0.75rem;
220
+ font-weight: 600; letter-spacing: 0.3px;
221
+ }
222
+ .source-badge.ready { background: rgba(34,197,94,0.15); color: #22c55e; }
223
+
224
+ /* ── Welcome hero ── */
225
+ .welcome-hero {
226
+ text-align: center; padding: 80px 40px;
227
+ background: linear-gradient(135deg, rgba(102,126,234,0.08) 0%, rgba(118,75,162,0.08) 100%);
228
+ border-radius: 20px; border: 1px solid rgba(102,126,234,0.15); margin: 20px 0;
229
+ }
230
+ .welcome-hero h1 {
231
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
232
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
233
+ font-size: 2.5rem; font-weight: 700; margin-bottom: 12px;
234
+ }
235
+ .welcome-hero p { color: #9090a8; font-size: 1.1rem; line-height: 1.6; }
236
+
237
+ /* ─��� Empty state ── */
238
+ .empty-state {
239
+ text-align: center; padding: 60px 30px; color: #707088;
240
+ }
241
+ .empty-state h3 { color: #a0a0b8; margin-bottom: 8px; font-weight: 600; }
242
+ .empty-state p { font-size: 0.95rem; line-height: 1.5; }
243
+
244
+ /* ── Notebook header ── */
245
+ .notebook-header {
246
+ padding: 0 0 16px 0; margin-bottom: 16px;
247
+ border-bottom: 1px solid rgba(255,255,255,0.06);
248
+ }
249
+ .notebook-header h2 {
250
+ font-weight: 700; font-size: 1.5rem; margin: 0; color: #e8e8f8;
251
+ }
252
+ .notebook-header .meta { font-size: 0.85rem; color: #707088; margin-top: 4px; }
253
+
254
+ /* ── Citation chip ── */
255
+ .citation-chip {
256
+ display: inline-flex; align-items: center; gap: 6px;
257
+ padding: 6px 14px; background: rgba(102,126,234,0.1);
258
+ border: 1px solid rgba(102,126,234,0.2); border-radius: 20px;
259
+ font-size: 0.8rem; color: #a0b0f0; margin: 3px 4px;
260
+ }
261
+
262
+ /* ── Artifact section header ── */
263
+ .artifact-section-header {
264
+ display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
265
+ }
266
+ .artifact-section-icon {
267
+ width: 36px; height: 36px; border-radius: 10px;
268
+ display: flex; align-items: center; justify-content: center; font-size: 1.1rem;
269
+ }
270
+
271
+ /* ── Locked state ── */
272
+ .locked-state {
273
+ text-align: center; padding: 50px 30px;
274
+ background: rgba(255,255,255,0.02);
275
+ border: 1px solid rgba(255,255,255,0.06);
276
+ border-radius: 16px;
277
+ }
278
+
279
+ /* ── File uploader ── */
280
+ .gr-file-upload {
281
+ border-radius: 14px !important;
282
+ border: 2px dashed rgba(102,126,234,0.3) !important;
283
+ background: rgba(102,126,234,0.03) !important;
284
+ }
285
+
286
+ /* ── Primary button ── */
287
+ .gr-button-primary {
288
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
289
+ border: none !important; border-radius: 10px !important;
290
+ font-weight: 600 !important;
291
+ }
292
+ .gr-button-primary:hover {
293
+ opacity: 0.9 !important;
294
+ transform: translateY(-1px) !important;
295
+ box-shadow: 0 4px 15px rgba(102,126,234,0.3) !important;
296
+ }
297
+
298
+ /* ── Scrollbar ── */
299
+ ::-webkit-scrollbar { width: 6px; }
300
+ ::-webkit-scrollbar-track { background: transparent; }
301
+ ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
302
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.25); }
303
+
304
+ /* ── Hide Gradio footer ── */
305
+ footer { display: none !important; }
306
+
307
+ /* ── Auth gate ── */
308
+ #auth-gate { max-width: 500px; margin: 100px auto; }
309
+ """
310
+
311
+ # ── Reusable HTML Templates ──────────────────────────────────────────────────
312
+
313
+ WELCOME_HTML = f"""
314
+ <div class="welcome-hero">
315
+ <img src="data:image/svg+xml;base64,{ICON_B64}" style="width:64px; margin-bottom:16px;" />
316
+ <h1>NotebookLM</h1>
317
+ <p>Your AI-powered study companion.<br>
318
+ Sign in with your Hugging Face account to get started.</p>
319
+ </div>
320
+ """
321
+
322
+ NO_NOTEBOOKS_HTML = """
323
+ <div class="welcome-hero">
324
+ <h1>NotebookLM</h1>
325
+ <p>Create a notebook from the sidebar to get started.</p>
326
+ </div>
327
+ """
328
+
329
+ SIDEBAR_LOGO_HTML = f"""
330
+ <div style="padding: 8px 0 4px 0;">
331
+ <img src="data:image/svg+xml;base64,{LOGO_B64}" style="width:100%; max-width:240px;" />
332
+ </div>
333
+ """
ui/chat_page.py DELETED
@@ -1,165 +0,0 @@
1
- import streamlit as st
2
- import uuid
3
- from datetime import datetime
4
- import random
5
- import time
6
-
7
-
8
- # ── Mock responses ───────────────────────────────────────────────────────────
9
- MOCK_RESPONSES = [
10
- {
11
- "content": (
12
- "Based on the uploaded sources, the key concept revolves around "
13
- "the relationship between the variables discussed in Chapter 3. "
14
- "The author emphasizes that understanding this foundation is critical "
15
- "before moving to advanced topics."
16
- ),
17
- "citations": [
18
- {"source": "lecture_notes.pdf", "page": 3, "text": "the relationship between variables..."},
19
- {"source": "textbook_ch3.pdf", "page": 42, "text": "understanding this foundation..."},
20
- ],
21
- },
22
- {
23
- "content": (
24
- "The sources indicate three main approaches to this problem:\n\n"
25
- "1. **Direct method** — Apply the formula from Section 2.1\n"
26
- "2. **Iterative approach** — Build up from base cases\n"
27
- "3. **Approximation** — Use the simplified model when precision isn't critical\n\n"
28
- "The textbook recommends starting with the direct method for beginners."
29
- ),
30
- "citations": [
31
- {"source": "textbook_ch2.pdf", "page": 15, "text": "direct method... apply the formula"},
32
- ],
33
- },
34
- {
35
- "content": (
36
- "I couldn't find specific information about that topic in your "
37
- "uploaded sources. Try uploading additional materials that cover this "
38
- "subject, or rephrase your question to relate more closely to the "
39
- "content in your current sources."
40
- ),
41
- "citations": [],
42
- },
43
- {
44
- "content": (
45
- "Great question! According to the lecture slides, this concept "
46
- "was introduced in Week 5. The key takeaway is that the process involves "
47
- "three stages: **initialization**, **processing**, and **validation**. "
48
- "Each stage has specific requirements that must be met before proceeding."
49
- ),
50
- "citations": [
51
- {"source": "week5_slides.pptx", "page": 8, "text": "three stages: initialization..."},
52
- {"source": "week5_slides.pptx", "page": 12, "text": "specific requirements..."},
53
- ],
54
- },
55
- ]
56
-
57
-
58
- def get_mock_response(query: str) -> dict:
59
- """Simulate a RAG response. Will be replaced with actual RAG pipeline."""
60
- time.sleep(1.2)
61
- return random.choice(MOCK_RESPONSES)
62
-
63
-
64
- def render_chat(notebook: dict):
65
- """Render the chat interface."""
66
-
67
- source_count = len(notebook["sources"])
68
-
69
- # ── No sources warning ──
70
- if source_count == 0:
71
- st.markdown(
72
- """
73
- <div style="
74
- padding: 14px 20px;
75
- background: rgba(234,179,8,0.08);
76
- border: 1px solid rgba(234,179,8,0.2);
77
- border-radius: 12px;
78
- color: #d4a017;
79
- font-size: 0.9rem;
80
- margin-bottom: 16px;
81
- ">
82
- Upload sources in the <strong>Sources</strong> tab to start chatting with your documents.
83
- </div>
84
- """,
85
- unsafe_allow_html=True,
86
- )
87
-
88
- # ── Chat history ──
89
- chat_container = st.container(height=480)
90
-
91
- with chat_container:
92
- if not notebook["messages"]:
93
- st.markdown(
94
- """
95
- <div class="empty-state" style="padding: 80px 20px;">
96
- <div style="font-size: 3rem; margin-bottom: 16px;">💬</div>
97
- <h3>Start a conversation</h3>
98
- <p>Ask questions about your uploaded sources.<br>
99
- The AI will answer using only your documents<br>
100
- and provide citations for every claim.</p>
101
- </div>
102
- """,
103
- unsafe_allow_html=True,
104
- )
105
- else:
106
- for msg in notebook["messages"]:
107
- avatar = "🧑" if msg["role"] == "user" else "🤖"
108
- with st.chat_message(msg["role"], avatar=avatar):
109
- st.markdown(msg["content"])
110
-
111
- # Citations
112
- if msg["role"] == "assistant" and msg.get("citations"):
113
- citations_html = ""
114
- for cite in msg["citations"]:
115
- citations_html += (
116
- f'<span class="citation-chip">'
117
- f'📄 {cite["source"]} &middot; p.{cite["page"]}'
118
- f'</span>'
119
- )
120
- st.markdown(
121
- f'<div style="margin-top: 10px;">{citations_html}</div>',
122
- unsafe_allow_html=True,
123
- )
124
- # Expandable full citation text
125
- with st.expander("View cited passages"):
126
- for cite in msg["citations"]:
127
- st.markdown(
128
- f'> *"{cite["text"]}"*\n>\n'
129
- f'> — **{cite["source"]}**, page {cite["page"]}'
130
- )
131
-
132
- # ── Chat input ──
133
- if prompt := st.chat_input(
134
- "Ask a question about your sources...",
135
- key=f"chat_input_{notebook['id']}",
136
- ):
137
- user_msg = {
138
- "id": str(uuid.uuid4()),
139
- "role": "user",
140
- "content": prompt,
141
- "citations": [],
142
- "created_at": datetime.now().isoformat(),
143
- }
144
- notebook["messages"].append(user_msg)
145
-
146
- with st.spinner("Thinking..."):
147
- response = get_mock_response(prompt)
148
-
149
- assistant_msg = {
150
- "id": str(uuid.uuid4()),
151
- "role": "assistant",
152
- "content": response["content"],
153
- "citations": response["citations"],
154
- "created_at": datetime.now().isoformat(),
155
- }
156
- notebook["messages"].append(assistant_msg)
157
- st.rerun()
158
-
159
- # ── Controls ──
160
- if notebook["messages"]:
161
- cols = st.columns([5, 1])
162
- with cols[1]:
163
- if st.button("Clear chat", use_container_width=True):
164
- notebook["messages"] = []
165
- st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ui/upload_page.py DELETED
@@ -1,199 +0,0 @@
1
- import streamlit as st
2
- import uuid
3
- from datetime import datetime
4
-
5
-
6
- ALLOWED_TYPES = ["pdf", "pptx", "txt"]
7
- MAX_FILE_SIZE_MB = 15
8
- MAX_SOURCES_PER_NOTEBOOK = 20
9
-
10
- FILE_TYPE_CONFIG = {
11
- "pdf": {"icon": "📕", "color": "239,68,68", "label": "PDF"},
12
- "pptx": {"icon": "📊", "color": "249,115,22", "label": "PPTX"},
13
- "txt": {"icon": "📝", "color": "59,130,246", "label": "TXT"},
14
- "url": {"icon": "🌐", "color": "34,197,94", "label": "URL"},
15
- "youtube": {"icon": "🎬", "color": "239,68,68", "label": "YouTube"},
16
- }
17
-
18
-
19
- def render_sources(notebook: dict):
20
- """Render source upload and management."""
21
-
22
- total = len(notebook["sources"])
23
- remaining = MAX_SOURCES_PER_NOTEBOOK - total
24
-
25
- # ── Header with count ──
26
- st.markdown(
27
- f"""
28
- <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:20px;">
29
- <div>
30
- <span style="font-size:1.1rem; font-weight:600; color:#e0e0f0;">Sources</span>
31
- <span style="
32
- margin-left: 10px;
33
- padding: 3px 10px;
34
- background: rgba(102,126,234,0.15);
35
- color: #8090d0;
36
- border-radius: 12px;
37
- font-size: 0.8rem;
38
- font-weight: 600;
39
- ">{total} / {MAX_SOURCES_PER_NOTEBOOK}</span>
40
- </div>
41
- <span style="font-size:0.8rem; color:#606078;">{remaining} slots remaining</span>
42
- </div>
43
- """,
44
- unsafe_allow_html=True,
45
- )
46
-
47
- # ── Upload section ──
48
- col_upload, col_url = st.columns([1, 1], gap="large")
49
-
50
- with col_upload:
51
- st.markdown(
52
- '<p style="font-weight:600; font-size:0.9rem; color:#b0b0c8; margin-bottom:8px;">'
53
- "Upload Files</p>",
54
- unsafe_allow_html=True,
55
- )
56
- uploaded_files = st.file_uploader(
57
- "Drop files here",
58
- type=ALLOWED_TYPES,
59
- accept_multiple_files=True,
60
- help=f"PDF, PPTX, TXT — max {MAX_FILE_SIZE_MB}MB each",
61
- key=f"uploader_{notebook['id']}",
62
- label_visibility="collapsed",
63
- )
64
-
65
- if uploaded_files:
66
- for f in uploaded_files:
67
- existing_names = [s["filename"] for s in notebook["sources"]]
68
- if f.name in existing_names:
69
- continue
70
- if len(notebook["sources"]) >= MAX_SOURCES_PER_NOTEBOOK:
71
- st.error(f"Limit of {MAX_SOURCES_PER_NOTEBOOK} sources reached.")
72
- break
73
-
74
- file_size_mb = f.size / (1024 * 1024)
75
- if file_size_mb > MAX_FILE_SIZE_MB:
76
- st.error(f"**{f.name}** is too large ({file_size_mb:.1f}MB).")
77
- continue
78
-
79
- source = {
80
- "id": str(uuid.uuid4()),
81
- "filename": f.name,
82
- "file_type": f.name.rsplit(".", 1)[-1].lower(),
83
- "size_mb": round(file_size_mb, 2),
84
- "chunk_count": 0,
85
- "status": "ready",
86
- "error_message": None,
87
- "created_at": datetime.now().isoformat(),
88
- }
89
- notebook["sources"].append(source)
90
- st.toast(f"Added {f.name}", icon="✅")
91
-
92
- with col_url:
93
- st.markdown(
94
- '<p style="font-weight:600; font-size:0.9rem; color:#b0b0c8; margin-bottom:8px;">'
95
- "Add Web Source</p>",
96
- unsafe_allow_html=True,
97
- )
98
- url_input = st.text_input(
99
- "URL",
100
- placeholder="https://example.com or YouTube link",
101
- label_visibility="collapsed",
102
- key=f"url_input_{notebook['id']}",
103
- )
104
- if st.button("Add URL", use_container_width=True, type="primary"):
105
- if not url_input.strip():
106
- st.warning("Enter a URL.")
107
- elif len(notebook["sources"]) >= MAX_SOURCES_PER_NOTEBOOK:
108
- st.error(f"Limit of {MAX_SOURCES_PER_NOTEBOOK} sources reached.")
109
- else:
110
- url = url_input.strip()
111
- existing_urls = [s.get("source_url") for s in notebook["sources"]]
112
- if url in existing_urls:
113
- st.warning("Already added.")
114
- else:
115
- is_youtube = "youtube.com" in url or "youtu.be" in url
116
- file_type = "youtube" if is_youtube else "url"
117
- display_name = url[:55] + "..." if len(url) > 55 else url
118
-
119
- source = {
120
- "id": str(uuid.uuid4()),
121
- "filename": display_name,
122
- "file_type": file_type,
123
- "size_mb": None,
124
- "source_url": url,
125
- "chunk_count": 0,
126
- "status": "ready",
127
- "error_message": None,
128
- "created_at": datetime.now().isoformat(),
129
- }
130
- notebook["sources"].append(source)
131
- st.toast(f"Added {file_type} source", icon="✅")
132
- st.rerun()
133
-
134
- # ── Source list ──
135
- st.divider()
136
-
137
- if not notebook["sources"]:
138
- st.markdown(
139
- """
140
- <div style="
141
- text-align: center; padding: 50px 20px;
142
- color: #606078;
143
- ">
144
- <div style="font-size: 3rem; margin-bottom: 16px;">📄</div>
145
- <h3 style="color: #a0a0b8; font-weight: 600;">No sources yet</h3>
146
- <p style="font-size: 0.9rem;">Upload documents or add web links above.<br>
147
- Your sources power the AI chat and artifact generation.</p>
148
- </div>
149
- """,
150
- unsafe_allow_html=True,
151
- )
152
- return
153
-
154
- st.markdown(
155
- f'<p style="font-weight:600; font-size:0.9rem; color:#a0a0b8; margin-bottom:12px;">'
156
- f'Your Sources ({len(notebook["sources"])})</p>',
157
- unsafe_allow_html=True,
158
- )
159
-
160
- for i, source in enumerate(notebook["sources"]):
161
- ft = source["file_type"]
162
- cfg = FILE_TYPE_CONFIG.get(ft, {"icon": "📄", "color": "150,150,170", "label": ft.upper()})
163
-
164
- meta_parts = [cfg["label"]]
165
- if source.get("size_mb"):
166
- meta_parts.append(f"{source['size_mb']} MB")
167
- if source["chunk_count"] > 0:
168
- meta_parts.append(f"{source['chunk_count']} chunks")
169
- meta_str = " · ".join(meta_parts)
170
-
171
- status = source["status"]
172
-
173
- with st.container(border=True):
174
- col_icon, col_info, col_status, col_del = st.columns([0.5, 4, 1.2, 0.8])
175
-
176
- with col_icon:
177
- st.markdown(
178
- f'<div style="font-size:1.8rem; text-align:center; padding-top:4px;">'
179
- f'{cfg["icon"]}</div>',
180
- unsafe_allow_html=True,
181
- )
182
-
183
- with col_info:
184
- st.markdown(f"**{source['filename']}**")
185
- st.caption(meta_str)
186
-
187
- with col_status:
188
- if status == "ready":
189
- st.success("Ready")
190
- elif status == "processing":
191
- st.warning("Processing")
192
- elif status == "failed":
193
- st.error("Failed")
194
-
195
- with col_del:
196
- st.markdown('<div style="padding-top:8px;"></div>', unsafe_allow_html=True)
197
- if st.button("X", key=f"rm_{source['id']}", help="Remove source"):
198
- notebook["sources"].pop(i)
199
- st.rerun()