github-actions[bot] commited on
Commit
363fde4
·
1 Parent(s): 5b42e02

Sync from GitHub 0d5a3f5b657940732e673ece22eeb9f89cf08968

Browse files
Files changed (1) hide show
  1. frontend/app.py +332 -30
frontend/app.py CHANGED
@@ -11,6 +11,20 @@ st.set_page_config(page_title="NotebookLM Clone", page_icon="📚", layout="wide
11
  BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")
12
  REQUEST_TIMEOUT_SECONDS = 30
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  def get_http_session() -> requests.Session:
16
  session = st.session_state.get("http_session")
@@ -141,8 +155,186 @@ def remove_query_param(name: str) -> None:
141
  st.experimental_set_query_params(**params)
142
 
143
 
144
- st.title("NotebookLM Clone")
145
- st.caption("Streamlit frontend shell")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  if "page" not in st.session_state:
148
  st.session_state["page"] = "Home"
@@ -166,7 +358,8 @@ if bridge_token and bridge_token != st.session_state["processed_bridge_token"]:
166
  st.rerun()
167
 
168
  with st.sidebar:
169
- st.header("Navigation")
 
170
  st.text_input("Backend URL", value=BACKEND_URL, key="backend_url")
171
 
172
  auth_ok, auth_result, _ = api_get("/auth/status")
@@ -181,7 +374,7 @@ with st.sidebar:
181
  if login_ok:
182
  st.rerun()
183
 
184
- st.subheader("Auth")
185
  st.caption(f"Mode: {auth_mode}")
186
  bridge_error = st.session_state.get("bridge_error")
187
  if bridge_error:
@@ -240,6 +433,25 @@ with st.sidebar:
240
  st.session_state.pop("selected_thread_id", None)
241
  st.rerun()
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  if not auth_ok:
244
  st.error(f"Backend auth check failed: {auth_result}")
245
  st.stop()
@@ -250,9 +462,20 @@ if not authenticated:
250
 
251
  if page == "Home":
252
  st.subheader("Home")
253
- st.write("Frontend is connected to your FastAPI backend.")
 
 
 
254
 
255
  ok, result, _ = api_get("/health")
 
 
 
 
 
 
 
 
256
  if ok:
257
  st.success("Backend health check passed.")
258
  st.json(result)
@@ -262,10 +485,21 @@ if page == "Home":
262
 
263
  elif page == "Notebooks":
264
  st.subheader("Notebooks")
265
-
266
- with st.form("create_notebook_form"):
267
- notebook_title = st.text_input("Notebook title", placeholder="e.g., AI Research Notes")
268
- submitted = st.form_submit_button("Create notebook")
 
 
 
 
 
 
 
 
 
 
 
269
 
270
  if submitted:
271
  if notebook_title.strip():
@@ -282,15 +516,23 @@ elif page == "Notebooks":
282
  else:
283
  st.error("Notebook title is required.")
284
 
285
- if st.button("Refresh notebooks"):
286
- st.rerun()
287
-
288
  ok, result = fetch_notebooks()
289
  if ok:
290
  notebooks = result if isinstance(result, list) else []
 
 
 
 
 
 
 
 
 
 
 
291
  if notebooks:
292
  st.write("Your notebooks")
293
- st.dataframe(notebooks, use_container_width=True)
294
 
295
  notebook_options = {
296
  f"{n['id']} - {n['title']}": n
@@ -298,7 +540,12 @@ elif page == "Notebooks":
298
  if isinstance(n, dict) and "id" in n and "title" in n
299
  }
300
  labels = list(notebook_options.keys())
301
- selected_label = st.selectbox("Select notebook to open", options=labels)
 
 
 
 
 
302
  selected = notebook_options[selected_label]
303
  if st.button("Open notebook"):
304
  st.session_state["selected_notebook_id"] = selected["id"]
@@ -311,6 +558,7 @@ elif page == "Notebooks":
311
  if selected_notebook_id:
312
  st.divider()
313
  st.subheader(f"Notebook: {selected_notebook_title}")
 
314
 
315
  manage_left, manage_right = st.columns(2)
316
  with manage_left:
@@ -359,9 +607,15 @@ elif page == "Notebooks":
359
  st.error("Failed to delete notebook.")
360
  st.code(str(delete_result))
361
 
362
- source_tab, chat_tab, artifacts_tab = st.tabs(["Sources", "Chat", "Artifacts"])
 
 
363
 
364
  with source_tab:
 
 
 
 
365
  ok, notebook_result = fetch_notebooks()
366
  if not ok:
367
  st.error("Failed to load notebooks.")
@@ -462,8 +716,23 @@ elif page == "Notebooks":
462
  if ok:
463
  sources = source_result if isinstance(source_result, list) else []
464
  if sources:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  st.write("Sources")
466
- st.dataframe(sources, use_container_width=True)
467
  else:
468
  st.info("No sources yet for this notebook.")
469
  else:
@@ -471,11 +740,16 @@ elif page == "Notebooks":
471
  st.code(str(source_result))
472
 
473
  with chat_tab:
 
 
 
 
474
  st.write("Chat threads")
475
  ok, thread_result, _ = api_get(
476
  f"/notebooks/{selected_notebook_id}/threads",
477
  )
478
  threads = thread_result if (ok and isinstance(thread_result, list)) else []
 
479
 
480
  with st.form("create_thread_form"):
481
  thread_title = st.text_input("Thread title (optional)")
@@ -527,13 +801,12 @@ elif page == "Notebooks":
527
  role = msg.get("role", "unknown")
528
  content = msg.get("content", "")
529
  citations = msg.get("citations", [])
530
- if role == "assistant":
531
- st.markdown(f"**Assistant:** {content}")
532
- if isinstance(citations, list) and citations:
 
533
  with st.expander("Citations", expanded=False):
534
- st.dataframe(citations, use_container_width=True)
535
- else:
536
- st.markdown(f"**You:** {content}")
537
  else:
538
  st.error("Failed to fetch messages.")
539
  st.code(str(message_result))
@@ -568,7 +841,10 @@ elif page == "Notebooks":
568
  st.info("Create a thread to start chatting.")
569
 
570
  with artifacts_tab:
571
- st.write("Artifact generation")
 
 
 
572
 
573
  report_col, quiz_col, podcast_col = st.columns(3)
574
 
@@ -700,9 +976,30 @@ elif page == "Notebooks":
700
  value=bool(st.session_state.get(auto_refresh_key, True)),
701
  key=auto_refresh_key,
702
  )
703
- st.dataframe(artifacts, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  artifact_options = {
705
- f"{a['id']} - {a.get('type', 'unknown')} - {a.get('status', '')}": a
706
  for a in artifacts
707
  if isinstance(a, dict) and "id" in a
708
  }
@@ -716,6 +1013,16 @@ elif page == "Notebooks":
716
  artifact_type = str(selected_artifact.get("type", ""))
717
  artifact_status = str(selected_artifact.get("status", ""))
718
  artifact_content = selected_artifact.get("content")
 
 
 
 
 
 
 
 
 
 
719
 
720
  if artifact_type == "report" and artifact_content:
721
  st.markdown("### Report Preview")
@@ -764,11 +1071,6 @@ elif page == "Notebooks":
764
  else:
765
  st.info("Select an artifact to preview.")
766
 
767
- in_flight = sum(
768
- 1
769
- for a in artifacts
770
- if str(a.get("status", "")).lower() in {"pending", "processing"}
771
- )
772
  if auto_refresh and in_flight > 0:
773
  st.caption(
774
  f"{in_flight} artifact(s) still processing. "
 
11
  BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")
12
  REQUEST_TIMEOUT_SECONDS = 30
13
 
14
+ STATUS_LABELS = {
15
+ "ready": "Ready",
16
+ "failed": "Failed",
17
+ "processing": "Processing",
18
+ "pending": "Pending",
19
+ }
20
+
21
+ STATUS_ICONS = {
22
+ "ready": "✓",
23
+ "failed": "!",
24
+ "processing": "~",
25
+ "pending": "…",
26
+ }
27
+
28
 
29
  def get_http_session() -> requests.Session:
30
  session = st.session_state.get("http_session")
 
155
  st.experimental_set_query_params(**params)
156
 
157
 
158
+ def inject_theme() -> None:
159
+ st.markdown(
160
+ """
161
+ <style>
162
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
163
+
164
+ :root {
165
+ --ink: #162525;
166
+ --ink-muted: #4f6362;
167
+ --card: #fefdf9;
168
+ --card-border: #d2dad3;
169
+ --accent: #146f67;
170
+ --accent-soft: #d6efeb;
171
+ --warn-soft: #fff2dd;
172
+ --warn-border: #f0bd69;
173
+ --ok-soft: #def5ec;
174
+ --ok-border: #53a27f;
175
+ --error-soft: #ffe6e3;
176
+ --error-border: #d2675a;
177
+ --pending-soft: #eceeef;
178
+ --pending-border: #9aa7ad;
179
+ }
180
+
181
+ .stApp {
182
+ background:
183
+ radial-gradient(circle at 15% -5%, #fff3de 0%, rgba(255, 243, 222, 0) 40%),
184
+ radial-gradient(circle at 88% 8%, #d9efeb 0%, rgba(217, 239, 235, 0) 42%),
185
+ linear-gradient(180deg, #f8faf8 0%, #f1f5f2 100%);
186
+ color: var(--ink);
187
+ font-family: "IBM Plex Sans", sans-serif;
188
+ }
189
+
190
+ [data-testid="stSidebar"] {
191
+ background: linear-gradient(180deg, #173436 0%, #11292b 100%);
192
+ }
193
+
194
+ [data-testid="stSidebar"] * {
195
+ color: #e9f1ef !important;
196
+ font-family: "IBM Plex Sans", sans-serif;
197
+ }
198
+
199
+ h1, h2, h3, h4 {
200
+ font-family: "Space Grotesk", sans-serif;
201
+ letter-spacing: 0.02em;
202
+ color: var(--ink);
203
+ }
204
+
205
+ .app-hero {
206
+ background: linear-gradient(125deg, rgba(20, 111, 103, 0.13) 0%, rgba(255, 245, 228, 0.95) 56%, rgba(205, 231, 226, 0.9) 100%);
207
+ border: 1px solid #c9d9cf;
208
+ border-radius: 18px;
209
+ padding: 18px 22px;
210
+ margin-bottom: 14px;
211
+ box-shadow: 0 8px 24px rgba(17, 45, 42, 0.08);
212
+ }
213
+
214
+ .hero-kicker {
215
+ display: inline-block;
216
+ font-family: "Space Grotesk", sans-serif;
217
+ font-size: 0.75rem;
218
+ letter-spacing: 0.08em;
219
+ text-transform: uppercase;
220
+ color: #0f625b;
221
+ background: rgba(255, 255, 255, 0.7);
222
+ border: 1px solid rgba(15, 98, 91, 0.22);
223
+ border-radius: 999px;
224
+ padding: 3px 10px;
225
+ margin-bottom: 8px;
226
+ }
227
+
228
+ .hero-title {
229
+ margin: 0;
230
+ font-family: "Space Grotesk", sans-serif;
231
+ font-size: clamp(1.45rem, 2.4vw, 2rem);
232
+ line-height: 1.2;
233
+ }
234
+
235
+ .hero-sub {
236
+ margin: 6px 0 0 0;
237
+ color: var(--ink-muted);
238
+ font-size: 0.95rem;
239
+ }
240
+
241
+ .soft-card {
242
+ background: rgba(255, 255, 255, 0.82);
243
+ border: 1px solid var(--card-border);
244
+ border-radius: 14px;
245
+ padding: 12px 14px;
246
+ }
247
+
248
+ .metric-card {
249
+ background: var(--card);
250
+ border: 1px solid var(--card-border);
251
+ border-radius: 14px;
252
+ padding: 10px 12px;
253
+ }
254
+
255
+ .metric-label {
256
+ color: var(--ink-muted);
257
+ font-size: 0.78rem;
258
+ text-transform: uppercase;
259
+ letter-spacing: 0.06em;
260
+ }
261
+
262
+ .metric-value {
263
+ font-family: "Space Grotesk", sans-serif;
264
+ font-size: 1.4rem;
265
+ color: var(--ink);
266
+ margin-top: 2px;
267
+ }
268
+
269
+ .status-pill {
270
+ display: inline-block;
271
+ border-radius: 999px;
272
+ padding: 4px 10px;
273
+ font-size: 0.78rem;
274
+ font-weight: 600;
275
+ border: 1px solid transparent;
276
+ }
277
+ .status-ready {
278
+ background: var(--ok-soft);
279
+ border-color: var(--ok-border);
280
+ color: #1c6b4d;
281
+ }
282
+ .status-failed {
283
+ background: var(--error-soft);
284
+ border-color: var(--error-border);
285
+ color: #8d3329;
286
+ }
287
+ .status-processing {
288
+ background: var(--warn-soft);
289
+ border-color: var(--warn-border);
290
+ color: #7d581a;
291
+ }
292
+ .status-pending {
293
+ background: var(--pending-soft);
294
+ border-color: var(--pending-border);
295
+ color: #485a63;
296
+ }
297
+ </style>
298
+ """,
299
+ unsafe_allow_html=True,
300
+ )
301
+
302
+
303
+ def status_key(value: str | None) -> str:
304
+ return str(value or "").strip().lower()
305
+
306
+
307
+ def render_status_pill(status: str | None, *, prefix: str = "") -> None:
308
+ key = status_key(status)
309
+ css = f"status-{key}" if key in STATUS_LABELS else "status-pending"
310
+ icon = STATUS_ICONS.get(key, "•")
311
+ label = STATUS_LABELS.get(key, str(status or "Unknown").title())
312
+ text = f"{prefix}{icon} {label}".strip()
313
+ st.markdown(f"<span class='status-pill {css}'>{text}</span>", unsafe_allow_html=True)
314
+
315
+
316
+ def render_metric_card(label: str, value: str | int) -> None:
317
+ st.markdown(
318
+ (
319
+ "<div class='metric-card'>"
320
+ f"<div class='metric-label'>{label}</div>"
321
+ f"<div class='metric-value'>{value}</div>"
322
+ "</div>"
323
+ ),
324
+ unsafe_allow_html=True,
325
+ )
326
+
327
+
328
+ def build_artifact_option_label(artifact: dict[str, Any]) -> str:
329
+ art_id = artifact.get("id", "?")
330
+ art_type = str(artifact.get("type", "artifact")).title()
331
+ status = status_key(str(artifact.get("status", "")))
332
+ icon = STATUS_ICONS.get(status, "•")
333
+ label = STATUS_LABELS.get(status, str(artifact.get("status", "unknown")).title())
334
+ return f"#{art_id} · {art_type} · {icon} {label}"
335
+
336
+
337
+ inject_theme()
338
 
339
  if "page" not in st.session_state:
340
  st.session_state["page"] = "Home"
 
358
  st.rerun()
359
 
360
  with st.sidebar:
361
+ st.markdown("### Workspace")
362
+ st.caption("Connected client settings")
363
  st.text_input("Backend URL", value=BACKEND_URL, key="backend_url")
364
 
365
  auth_ok, auth_result, _ = api_get("/auth/status")
 
374
  if login_ok:
375
  st.rerun()
376
 
377
+ st.subheader("Authentication")
378
  st.caption(f"Mode: {auth_mode}")
379
  bridge_error = st.session_state.get("bridge_error")
380
  if bridge_error:
 
433
  st.session_state.pop("selected_thread_id", None)
434
  st.rerun()
435
 
436
+ hero_mode = auth_mode if isinstance(auth_mode, str) else "unknown"
437
+ hero_identity = "Not signed in"
438
+ if authenticated and isinstance(auth_user, dict):
439
+ hero_identity = str(auth_user.get("display_name") or auth_user.get("email") or "Signed in")
440
+ st.markdown(
441
+ (
442
+ "<div class='app-hero'>"
443
+ "<span class='hero-kicker'>Notebook Workspace</span>"
444
+ "<h1 class='hero-title'>NotebookLM Clone</h1>"
445
+ "<p class='hero-sub'>"
446
+ "Ingest sources, chat with citations, and generate reports, quizzes, and podcasts."
447
+ f" &nbsp;|&nbsp; Auth mode: <strong>{hero_mode}</strong>"
448
+ f" &nbsp;|&nbsp; User: <strong>{hero_identity}</strong>"
449
+ "</p>"
450
+ "</div>"
451
+ ),
452
+ unsafe_allow_html=True,
453
+ )
454
+
455
  if not auth_ok:
456
  st.error(f"Backend auth check failed: {auth_result}")
457
  st.stop()
 
462
 
463
  if page == "Home":
464
  st.subheader("Home")
465
+ st.markdown(
466
+ "<div class='soft-card'>Use this page to verify backend connectivity and auth status before working in notebooks.</div>",
467
+ unsafe_allow_html=True,
468
+ )
469
 
470
  ok, result, _ = api_get("/health")
471
+ metric_col_1, metric_col_2, metric_col_3 = st.columns(3)
472
+ with metric_col_1:
473
+ render_metric_card("Auth Mode", hero_mode)
474
+ with metric_col_2:
475
+ render_metric_card("Session", "Signed in" if authenticated else "Signed out")
476
+ with metric_col_3:
477
+ render_metric_card("Backend", "Healthy" if ok else "Unavailable")
478
+
479
  if ok:
480
  st.success("Backend health check passed.")
481
  st.json(result)
 
485
 
486
  elif page == "Notebooks":
487
  st.subheader("Notebooks")
488
+ st.markdown(
489
+ "<div class='soft-card'>Create or open a notebook, ingest sources, then use chat and artifact tools from one workspace.</div>",
490
+ unsafe_allow_html=True,
491
+ )
492
+
493
+ create_col, refresh_col = st.columns([3, 1])
494
+ with create_col:
495
+ with st.form("create_notebook_form"):
496
+ notebook_title = st.text_input("Notebook title", placeholder="e.g., AI Research Notes")
497
+ submitted = st.form_submit_button("Create notebook")
498
+ with refresh_col:
499
+ st.write("")
500
+ st.write("")
501
+ if st.button("Refresh notebooks", use_container_width=True):
502
+ st.rerun()
503
 
504
  if submitted:
505
  if notebook_title.strip():
 
516
  else:
517
  st.error("Notebook title is required.")
518
 
 
 
 
519
  ok, result = fetch_notebooks()
520
  if ok:
521
  notebooks = result if isinstance(result, list) else []
522
+ selected_notebook_id = st.session_state.get("selected_notebook_id")
523
+ selected_notebook_title = st.session_state.get("selected_notebook_title")
524
+
525
+ metrics_left, metrics_mid, metrics_right = st.columns(3)
526
+ with metrics_left:
527
+ render_metric_card("Notebook Count", len(notebooks))
528
+ with metrics_mid:
529
+ render_metric_card("Selected Notebook", selected_notebook_id or "None")
530
+ with metrics_right:
531
+ render_metric_card("Workspace State", "Ready" if notebooks else "Empty")
532
+
533
  if notebooks:
534
  st.write("Your notebooks")
535
+ st.dataframe(notebooks, use_container_width=True, hide_index=True)
536
 
537
  notebook_options = {
538
  f"{n['id']} - {n['title']}": n
 
540
  if isinstance(n, dict) and "id" in n and "title" in n
541
  }
542
  labels = list(notebook_options.keys())
543
+ default_index = 0
544
+ for idx, label in enumerate(labels):
545
+ if notebook_options[label]["id"] == selected_notebook_id:
546
+ default_index = idx
547
+ break
548
+ selected_label = st.selectbox("Select notebook to open", options=labels, index=default_index)
549
  selected = notebook_options[selected_label]
550
  if st.button("Open notebook"):
551
  st.session_state["selected_notebook_id"] = selected["id"]
 
558
  if selected_notebook_id:
559
  st.divider()
560
  st.subheader(f"Notebook: {selected_notebook_title}")
561
+ render_status_pill("ready", prefix="Notebook ")
562
 
563
  manage_left, manage_right = st.columns(2)
564
  with manage_left:
 
607
  st.error("Failed to delete notebook.")
608
  st.code(str(delete_result))
609
 
610
+ source_tab, chat_tab, artifacts_tab = st.tabs(
611
+ ["Source Library", "Chat Workspace", "Artifact Studio"]
612
+ )
613
 
614
  with source_tab:
615
+ st.markdown(
616
+ "<div class='soft-card'>Upload files or add links. Ingested sources are scoped to this notebook.</div>",
617
+ unsafe_allow_html=True,
618
+ )
619
  ok, notebook_result = fetch_notebooks()
620
  if not ok:
621
  st.error("Failed to load notebooks.")
 
716
  if ok:
717
  sources = source_result if isinstance(source_result, list) else []
718
  if sources:
719
+ ready_sources = sum(
720
+ 1 for src in sources if status_key(str(src.get("status", ""))) == "ready"
721
+ )
722
+ processing_sources = sum(
723
+ 1
724
+ for src in sources
725
+ if status_key(str(src.get("status", ""))) in {"processing", "pending"}
726
+ )
727
+ source_metrics_1, source_metrics_2, source_metrics_3 = st.columns(3)
728
+ with source_metrics_1:
729
+ render_metric_card("Source Count", len(sources))
730
+ with source_metrics_2:
731
+ render_metric_card("Ready Sources", ready_sources)
732
+ with source_metrics_3:
733
+ render_metric_card("In Flight", processing_sources)
734
  st.write("Sources")
735
+ st.dataframe(sources, use_container_width=True, hide_index=True)
736
  else:
737
  st.info("No sources yet for this notebook.")
738
  else:
 
740
  st.code(str(source_result))
741
 
742
  with chat_tab:
743
+ st.markdown(
744
+ "<div class='soft-card'>Ask questions grounded in your selected notebook sources. Citations are shown per assistant response.</div>",
745
+ unsafe_allow_html=True,
746
+ )
747
  st.write("Chat threads")
748
  ok, thread_result, _ = api_get(
749
  f"/notebooks/{selected_notebook_id}/threads",
750
  )
751
  threads = thread_result if (ok and isinstance(thread_result, list)) else []
752
+ render_metric_card("Thread Count", len(threads))
753
 
754
  with st.form("create_thread_form"):
755
  thread_title = st.text_input("Thread title (optional)")
 
801
  role = msg.get("role", "unknown")
802
  content = msg.get("content", "")
803
  citations = msg.get("citations", [])
804
+ message_type = "assistant" if role == "assistant" else "user"
805
+ with st.chat_message(message_type):
806
+ st.markdown(str(content))
807
+ if role == "assistant" and isinstance(citations, list) and citations:
808
  with st.expander("Citations", expanded=False):
809
+ st.dataframe(citations, use_container_width=True, hide_index=True)
 
 
810
  else:
811
  st.error("Failed to fetch messages.")
812
  st.code(str(message_result))
 
841
  st.info("Create a thread to start chatting.")
842
 
843
  with artifacts_tab:
844
+ st.markdown(
845
+ "<div class='soft-card'>Generate structured outputs from notebook context and track progress here.</div>",
846
+ unsafe_allow_html=True,
847
+ )
848
 
849
  report_col, quiz_col, podcast_col = st.columns(3)
850
 
 
976
  value=bool(st.session_state.get(auto_refresh_key, True)),
977
  key=auto_refresh_key,
978
  )
979
+ ready_count = sum(
980
+ 1 for artifact in artifacts if status_key(str(artifact.get("status", ""))) == "ready"
981
+ )
982
+ failed_count = sum(
983
+ 1 for artifact in artifacts if status_key(str(artifact.get("status", ""))) == "failed"
984
+ )
985
+ in_flight = sum(
986
+ 1
987
+ for artifact in artifacts
988
+ if status_key(str(artifact.get("status", ""))) in {"pending", "processing"}
989
+ )
990
+ artifact_metric_1, artifact_metric_2, artifact_metric_3, artifact_metric_4 = st.columns(4)
991
+ with artifact_metric_1:
992
+ render_metric_card("Artifacts", len(artifacts))
993
+ with artifact_metric_2:
994
+ render_metric_card("Ready", ready_count)
995
+ with artifact_metric_3:
996
+ render_metric_card("Processing", in_flight)
997
+ with artifact_metric_4:
998
+ render_metric_card("Failed", failed_count)
999
+
1000
+ st.dataframe(artifacts, use_container_width=True, hide_index=True)
1001
  artifact_options = {
1002
+ build_artifact_option_label(a): a
1003
  for a in artifacts
1004
  if isinstance(a, dict) and "id" in a
1005
  }
 
1013
  artifact_type = str(selected_artifact.get("type", ""))
1014
  artifact_status = str(selected_artifact.get("status", ""))
1015
  artifact_content = selected_artifact.get("content")
1016
+ artifact_error = selected_artifact.get("error_message")
1017
+
1018
+ status_col, type_col = st.columns([1, 4])
1019
+ with status_col:
1020
+ render_status_pill(artifact_status)
1021
+ with type_col:
1022
+ st.caption(f"Artifact type: {artifact_type}")
1023
+
1024
+ if artifact_error:
1025
+ st.error(str(artifact_error))
1026
 
1027
  if artifact_type == "report" and artifact_content:
1028
  st.markdown("### Report Preview")
 
1071
  else:
1072
  st.info("Select an artifact to preview.")
1073
 
 
 
 
 
 
1074
  if auto_refresh and in_flight > 0:
1075
  st.caption(
1076
  f"{in_flight} artifact(s) still processing. "