duongtruongbinh commited on
Commit
bc2d97e
·
1 Parent(s): 16fa4e7

Add support for multiple PDF uploads and enhanced citation handling

Browse files

- Implemented functionality to upload multiple PDF files and process them concurrently.
- Introduced new helper functions for reading uploaded PDFs and rendering citations with source text.
- Enhanced Markdown export to include detailed citation blocks.
- Updated UI components to reflect changes in file upload capabilities and citation display.
- Improved progress tracking during document processing.

Files changed (5) hide show
  1. app.py +85 -14
  2. src/export.py +54 -8
  3. src/rag.py +1 -0
  4. src/schemas.py +1 -0
  5. static/style.css +263 -69
app.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import base64
 
4
  import json
5
  from pathlib import Path
6
  from typing import Any
@@ -47,6 +48,26 @@ def _img_b64(path: str) -> str:
47
  def _status_html(message: str) -> str:
48
  return f'<div class="status-bar">{message}</div>'
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def _read_uploaded_pdf(file_obj: object) -> tuple[bytes, str]:
51
  """Normalize Gradio file payload into (bytes, filename).
52
 
@@ -76,6 +97,14 @@ def _read_uploaded_pdf(file_obj: object) -> tuple[bytes, str]:
76
 
77
  raise TypeError(f"Unsupported uploaded file type: {type(file_obj).__name__}")
78
 
 
 
 
 
 
 
 
 
79
 
80
  def _filters(filenames: list[str] | None, page: int | None) -> dict[str, object] | None:
81
  payload: dict[str, object] = {}
@@ -119,7 +148,8 @@ def _pages_for_selection(doc_map: dict[str, Any], selected: list[str]) -> gr.Dro
119
  def _upload_pdf(
120
  file: object | None,
121
  ) -> tuple[str, object, dict[str, Any], object, str, str]:
122
- if file is None:
 
123
  choices, doc_map, page_dropdown, summary, filenames_text = _refresh_docs()
124
  return (
125
  _status_html("⚠️ Vui lòng chọn file PDF."),
@@ -129,9 +159,31 @@ def _upload_pdf(
129
  summary,
130
  filenames_text,
131
  )
132
- file_bytes, filename = _read_uploaded_pdf(file)
133
- info = save_and_ingest_pdf(file_bytes, filename)
134
- message = _status_html(f"✅ Đã nạp **{info['filename']}** · {info['chunks_indexed']} đoạn")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  choices, doc_map, page_dropdown, summary, filenames_text = _refresh_docs()
136
  return message, choices, doc_map, page_dropdown, summary, filenames_text
137
 
@@ -142,13 +194,23 @@ def _ask(question: str, k: int, selected_docs: list[str], page: str, gemini_key:
142
  page_num = None if page == "(Tất cả trang)" else int(page)
143
  set_runtime_gemini_api_key(gemini_key)
144
  res = answer(question.strip(), k=int(k), filters=_filters(selected_docs, page_num))
145
- return res.answer, json.dumps(res.model_dump(), ensure_ascii=False, indent=2)
146
 
147
 
148
- def _summarize(query: str, k: int, selected_docs: list[str], page: str, gemini_key: str) -> tuple[str, str]:
 
 
 
 
 
 
 
149
  page_num = None if page == "(Tất cả trang)" else int(page)
150
  set_runtime_gemini_api_key(gemini_key)
 
 
151
  res = summarize(query=query.strip() or None, filters=_filters(selected_docs, page_num), k=int(k))
 
152
  return export(res, fmt="md"), res.model_dump_json(indent=2)
153
 
154
 
@@ -159,15 +221,19 @@ def _quiz(
159
  selected_docs: list[str],
160
  page: str,
161
  gemini_key: str,
 
162
  ) -> tuple[str, str]:
163
  page_num = None if page == "(Tất cả trang)" else int(page)
164
  set_runtime_gemini_api_key(gemini_key)
 
 
165
  res = generate_quiz(
166
  query=query.strip() or None,
167
  count=int(count),
168
  filters=_filters(selected_docs, page_num),
169
  k=int(k),
170
  )
 
171
  return export(res, fmt="md"), res.model_dump_json(indent=2)
172
 
173
 
@@ -178,15 +244,19 @@ def _flashcards(
178
  selected_docs: list[str],
179
  page: str,
180
  gemini_key: str,
 
181
  ) -> tuple[str, str]:
182
  page_num = None if page == "(Tất cả trang)" else int(page)
183
  set_runtime_gemini_api_key(gemini_key)
 
 
184
  res = generate_flashcards(
185
  query=query.strip() or None,
186
  count=int(count),
187
  filters=_filters(selected_docs, page_num),
188
  k=int(k),
189
  )
 
190
  return export(res, fmt="md"), res.model_dump_json(indent=2)
191
 
192
 
@@ -208,13 +278,13 @@ _theme = gr.themes.Base().set(
208
  input_background_fill="#ffffff",
209
  )
210
 
211
- with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) as demo:
212
  with gr.Row(elem_classes="header-row"):
213
  logo_b64 = _img_b64("static/aivn_logo.png")
214
  gr.HTML(f'<img src="data:image/png;base64,{logo_b64}" alt="AIVN">')
215
  gr.HTML(
216
  '<div class="header-meta">'
217
- '<p class="header-title">📚 RAG Learning System</p>'
218
  '<p class="header-sub">AIO2025 — Hỏi đáp · Tóm tắt · Quiz · Flashcards có trích dẫn nguồn</p>'
219
  '</div>'
220
  )
@@ -229,7 +299,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
229
  upload = gr.File(
230
  label="Chọn PDF",
231
  file_types=[".pdf"],
232
- file_count="single",
233
  type="filepath",
234
  )
235
  upload_btn = gr.Button("Nạp & Index", elem_classes="gen-btn")
@@ -238,7 +308,8 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
238
  # with gr.Group(elem_classes="control-card"):
239
  with gr.Accordion("🔑 Gemini API Key (tuỳ chọn)", open=False):
240
  gr.Markdown(
241
- "API key chỉ dùng trong phiên hiện tại và **không được lưu**.",
 
242
  elem_classes="help-markdown",
243
  )
244
  gemini_key_input = gr.Textbox(
@@ -291,7 +362,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
291
  )
292
  k_ask = gr.Slider(1, 32, value=6, step=1, label="Top-k retrieval")
293
  ask_btn = gr.Button("Trả lời", elem_classes="gen-btn")
294
- ask_md = gr.Markdown(elem_classes="result-markdown")
295
  with gr.Accordion("JSON debug", open=False):
296
  ask_raw = gr.Code(label="", language="json")
297
 
@@ -299,7 +370,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
299
  s_query = gr.Textbox(label="Chủ đề hướng dẫn (tuỳ chọn)", lines=1)
300
  s_k = gr.Slider(1, 64, value=10, step=1, label="Số đoạn truy xuất (k)")
301
  s_btn = gr.Button("Tạo tóm tắt", elem_classes="gen-btn")
302
- s_md = gr.Markdown(elem_classes="result-markdown")
303
  s_download = gr.File(label="Tải Markdown", interactive=False)
304
  with gr.Accordion("JSON debug", open=False):
305
  s_raw = gr.Code(label="", language="json")
@@ -309,7 +380,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
309
  z_count = gr.Slider(1, 30, value=3, step=1, label="Số câu hỏi")
310
  z_k = gr.Slider(1, 64, value=10, step=1, label="Số đoạn truy xuất (k)")
311
  z_btn = gr.Button("Tạo quiz", elem_classes="gen-btn")
312
- z_md = gr.Markdown(elem_classes="result-markdown")
313
  z_download = gr.File(label="Tải Markdown", interactive=False)
314
  with gr.Accordion("JSON debug", open=False):
315
  z_raw = gr.Code(label="", language="json")
@@ -319,7 +390,7 @@ with gr.Blocks(title="RAG Learning System", fill_width=True, fill_height=True) a
319
  f_count = gr.Slider(1, 40, value=15, step=1, label="Số thẻ")
320
  f_k = gr.Slider(1, 64, value=16, step=1, label="Số đoạn truy xuất (k)")
321
  f_btn = gr.Button("Tạo flashcards", elem_classes="gen-btn")
322
- f_md = gr.Markdown(elem_classes="result-markdown")
323
  f_download = gr.File(label="Tải Markdown", interactive=False)
324
  with gr.Accordion("JSON debug", open=False):
325
  f_raw = gr.Code(label="", language="json")
 
1
  from __future__ import annotations
2
 
3
  import base64
4
+ import html
5
  import json
6
  from pathlib import Path
7
  from typing import Any
 
48
  def _status_html(message: str) -> str:
49
  return f'<div class="status-bar">{message}</div>'
50
 
51
+ def _result_markdown() -> gr.Markdown:
52
+ """Create a result Markdown that allows safe HTML when supported.
53
+
54
+ We render citations using HTML <details> blocks for expand/collapse.
55
+ Gradio versions differ in how they gate HTML in Markdown, so we fall back
56
+ gracefully when a flag is unavailable.
57
+ """
58
+ candidates = (
59
+ {"sanitize_html": False},
60
+ {"sanitize": False},
61
+ {"unsafe_allow_html": True},
62
+ {},
63
+ )
64
+ for kwargs in candidates:
65
+ try:
66
+ return gr.Markdown(elem_classes="result-markdown", **kwargs)
67
+ except TypeError:
68
+ continue
69
+ return gr.Markdown(elem_classes="result-markdown")
70
+
71
  def _read_uploaded_pdf(file_obj: object) -> tuple[bytes, str]:
72
  """Normalize Gradio file payload into (bytes, filename).
73
 
 
97
 
98
  raise TypeError(f"Unsupported uploaded file type: {type(file_obj).__name__}")
99
 
100
+ def _read_uploaded_pdfs(file_obj: object) -> list[tuple[bytes, str]]:
101
+ """Normalize Gradio file payload into a list of (bytes, filename)."""
102
+ if file_obj is None:
103
+ return []
104
+ if isinstance(file_obj, (list, tuple)):
105
+ return [_read_uploaded_pdf(x) for x in file_obj]
106
+ return [_read_uploaded_pdf(file_obj)]
107
+
108
 
109
  def _filters(filenames: list[str] | None, page: int | None) -> dict[str, object] | None:
110
  payload: dict[str, object] = {}
 
148
  def _upload_pdf(
149
  file: object | None,
150
  ) -> tuple[str, object, dict[str, Any], object, str, str]:
151
+ payloads = _read_uploaded_pdfs(file)
152
+ if not payloads:
153
  choices, doc_map, page_dropdown, summary, filenames_text = _refresh_docs()
154
  return (
155
  _status_html("⚠️ Vui lòng chọn file PDF."),
 
159
  summary,
160
  filenames_text,
161
  )
162
+
163
+ successes: list[str] = []
164
+ failures: list[str] = []
165
+ chunks_total = 0
166
+ for file_bytes, filename in payloads:
167
+ try:
168
+ info = save_and_ingest_pdf(file_bytes, filename)
169
+ except Exception as e: # noqa: BLE001
170
+ failures.append(f"{filename}: {e}")
171
+ continue
172
+ successes.append(str(info["filename"]))
173
+ chunks_total += int(info.get("chunks_indexed") or 0)
174
+
175
+ parts: list[str] = []
176
+ if successes:
177
+ parts.append(f"✅ Đã nạp {len(successes)} file · {chunks_total} đoạn")
178
+ if failures:
179
+ parts.append(f"⚠️ Lỗi **{len(failures)}** file")
180
+ details = ""
181
+ if failures:
182
+ items = "".join(f"<li><code>{html.escape(x, quote=False)}</code></li>" for x in failures)
183
+ details = f"<details><summary>Xem lỗi</summary><ul>{items}</ul></details>"
184
+ message_body = (" · ".join(parts) if parts else "⚠️ Không có file hợp lệ.") + details
185
+ message = _status_html(message_body)
186
+
187
  choices, doc_map, page_dropdown, summary, filenames_text = _refresh_docs()
188
  return message, choices, doc_map, page_dropdown, summary, filenames_text
189
 
 
194
  page_num = None if page == "(Tất cả trang)" else int(page)
195
  set_runtime_gemini_api_key(gemini_key)
196
  res = answer(question.strip(), k=int(k), filters=_filters(selected_docs, page_num))
197
+ return export(res, fmt="md"), json.dumps(res.model_dump(), ensure_ascii=False, indent=2)
198
 
199
 
200
+ def _summarize(
201
+ query: str,
202
+ k: int,
203
+ selected_docs: list[str],
204
+ page: str,
205
+ gemini_key: str,
206
+ progress: gr.Progress = gr.Progress(),
207
+ ) -> tuple[str, str]:
208
  page_num = None if page == "(Tất cả trang)" else int(page)
209
  set_runtime_gemini_api_key(gemini_key)
210
+ progress(0.0, desc="Đang chuẩn bị…")
211
+ progress(0.2, desc="Đang truy xuất ngữ cảnh…")
212
  res = summarize(query=query.strip() or None, filters=_filters(selected_docs, page_num), k=int(k))
213
+ progress(0.85, desc="Đang định dạng kết quả…")
214
  return export(res, fmt="md"), res.model_dump_json(indent=2)
215
 
216
 
 
221
  selected_docs: list[str],
222
  page: str,
223
  gemini_key: str,
224
+ progress: gr.Progress = gr.Progress(),
225
  ) -> tuple[str, str]:
226
  page_num = None if page == "(Tất cả trang)" else int(page)
227
  set_runtime_gemini_api_key(gemini_key)
228
+ progress(0.0, desc="Đang chuẩn bị…")
229
+ progress(0.2, desc="Đang truy xuất ngữ cảnh…")
230
  res = generate_quiz(
231
  query=query.strip() or None,
232
  count=int(count),
233
  filters=_filters(selected_docs, page_num),
234
  k=int(k),
235
  )
236
+ progress(0.85, desc="Đang định dạng kết quả…")
237
  return export(res, fmt="md"), res.model_dump_json(indent=2)
238
 
239
 
 
244
  selected_docs: list[str],
245
  page: str,
246
  gemini_key: str,
247
+ progress: gr.Progress = gr.Progress(),
248
  ) -> tuple[str, str]:
249
  page_num = None if page == "(Tất cả trang)" else int(page)
250
  set_runtime_gemini_api_key(gemini_key)
251
+ progress(0.0, desc="Đang chuẩn bị…")
252
+ progress(0.2, desc="Đang truy xuất ngữ cảnh…")
253
  res = generate_flashcards(
254
  query=query.strip() or None,
255
  count=int(count),
256
  filters=_filters(selected_docs, page_num),
257
  k=int(k),
258
  )
259
+ progress(0.85, desc="Đang định dạng kết quả…")
260
  return export(res, fmt="md"), res.model_dump_json(indent=2)
261
 
262
 
 
278
  input_background_fill="#ffffff",
279
  )
280
 
281
+ with gr.Blocks(title="Building a Simple NotebookLM", fill_width=True, fill_height=True) as demo:
282
  with gr.Row(elem_classes="header-row"):
283
  logo_b64 = _img_b64("static/aivn_logo.png")
284
  gr.HTML(f'<img src="data:image/png;base64,{logo_b64}" alt="AIVN">')
285
  gr.HTML(
286
  '<div class="header-meta">'
287
+ '<p class="header-title">📚 Building a Simple NotebookLM</p>'
288
  '<p class="header-sub">AIO2025 — Hỏi đáp · Tóm tắt · Quiz · Flashcards có trích dẫn nguồn</p>'
289
  '</div>'
290
  )
 
299
  upload = gr.File(
300
  label="Chọn PDF",
301
  file_types=[".pdf"],
302
+ file_count="multiple",
303
  type="filepath",
304
  )
305
  upload_btn = gr.Button("Nạp & Index", elem_classes="gen-btn")
 
308
  # with gr.Group(elem_classes="control-card"):
309
  with gr.Accordion("🔑 Gemini API Key (tuỳ chọn)", open=False):
310
  gr.Markdown(
311
+ "Nhập API key để chạy Gemini. Lấy key tại: "
312
+ "[Google AI Studio](https://aistudio.google.com/app/api-keys). ",
313
  elem_classes="help-markdown",
314
  )
315
  gemini_key_input = gr.Textbox(
 
362
  )
363
  k_ask = gr.Slider(1, 32, value=6, step=1, label="Top-k retrieval")
364
  ask_btn = gr.Button("Trả lời", elem_classes="gen-btn")
365
+ ask_md = _result_markdown()
366
  with gr.Accordion("JSON debug", open=False):
367
  ask_raw = gr.Code(label="", language="json")
368
 
 
370
  s_query = gr.Textbox(label="Chủ đề hướng dẫn (tuỳ chọn)", lines=1)
371
  s_k = gr.Slider(1, 64, value=10, step=1, label="Số đoạn truy xuất (k)")
372
  s_btn = gr.Button("Tạo tóm tắt", elem_classes="gen-btn")
373
+ s_md = _result_markdown()
374
  s_download = gr.File(label="Tải Markdown", interactive=False)
375
  with gr.Accordion("JSON debug", open=False):
376
  s_raw = gr.Code(label="", language="json")
 
380
  z_count = gr.Slider(1, 30, value=3, step=1, label="Số câu hỏi")
381
  z_k = gr.Slider(1, 64, value=10, step=1, label="Số đoạn truy xuất (k)")
382
  z_btn = gr.Button("Tạo quiz", elem_classes="gen-btn")
383
+ z_md = _result_markdown()
384
  z_download = gr.File(label="Tải Markdown", interactive=False)
385
  with gr.Accordion("JSON debug", open=False):
386
  z_raw = gr.Code(label="", language="json")
 
390
  f_count = gr.Slider(1, 40, value=15, step=1, label="Số thẻ")
391
  f_k = gr.Slider(1, 64, value=16, step=1, label="Số đoạn truy xuất (k)")
392
  f_btn = gr.Button("Tạo flashcards", elem_classes="gen-btn")
393
+ f_md = _result_markdown()
394
  f_download = gr.File(label="Tải Markdown", interactive=False)
395
  with gr.Accordion("JSON debug", open=False):
396
  f_raw = gr.Code(label="", language="json")
src/export.py CHANGED
@@ -2,12 +2,13 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  from pathlib import Path
6
  from typing import Literal
7
 
8
  from pydantic import BaseModel
9
 
10
- from src.schemas import Citation, FlashcardSet, QuizSet, Summary
11
 
12
  ExportFormat = Literal["text", "md", "json"]
13
 
@@ -21,14 +22,59 @@ def _citation_line(c: Citation) -> str:
21
  return " | ".join(parts)
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  def _citations_block(citations: list[Citation]) -> str:
25
  if not citations:
26
  return ""
27
  lines = ["## Sources", ""]
28
- lines.extend(f"- {_citation_line(c)}" for c in citations)
 
29
  return "\n".join(lines) + "\n"
30
 
31
 
 
 
 
 
 
 
 
 
32
  def _to_markdown(model: BaseModel) -> str:
33
  if isinstance(model, Summary):
34
  title = "# Summary" + (f": {model.target}" if model.target else "")
@@ -37,10 +83,10 @@ def _to_markdown(model: BaseModel) -> str:
37
  lines.extend([model.summary.strip(), ""])
38
  if model.key_points:
39
  lines.extend(["## Key Points", "", *[f"- {kp}" for kp in model.key_points], ""])
40
- c = _citations_block(model.citations)
41
- if c:
42
- lines.append(c)
43
- return "\n".join(lines).rstrip() + "\n"
44
 
45
  if isinstance(model, QuizSet):
46
  title = "# Quiz" + (f": {model.target}" if model.target else "")
@@ -61,7 +107,7 @@ def _to_markdown(model: BaseModel) -> str:
61
  if item.explanation:
62
  lines.append(f"**Explanation:** {item.explanation.strip()}")
63
  if item.source_markers:
64
- lines.append(f"**Sources:** {', '.join(item.source_markers)}")
65
  lines.append("")
66
 
67
  c = _citations_block(model.citations)
@@ -80,7 +126,7 @@ def _to_markdown(model: BaseModel) -> str:
80
  if card.hint:
81
  lines.append(f"**Hint:** {card.hint.strip()}")
82
  if card.source_markers:
83
- lines.append(f"**Sources:** {', '.join(card.source_markers)}")
84
  lines.append("")
85
 
86
  c = _citations_block(model.citations)
 
2
 
3
  from __future__ import annotations
4
 
5
+ import html
6
  from pathlib import Path
7
  from typing import Literal
8
 
9
  from pydantic import BaseModel
10
 
11
+ from src.schemas import Citation, FlashcardSet, QuizSet, RagAnswer, Summary
12
 
13
  ExportFormat = Literal["text", "md", "json"]
14
 
 
22
  return " | ".join(parts)
23
 
24
 
25
+ def _details_block(summary: str, content: str) -> str:
26
+ safe_summary = html.escape(summary, quote=False)
27
+ safe_content = html.escape(content, quote=False)
28
+ return (
29
+ "<details>"
30
+ f"<summary>{safe_summary}</summary>"
31
+ f"<div style=\"margin:8px 0 0 0; white-space:pre-wrap;\">{safe_content}</div>"
32
+ "</details>"
33
+ )
34
+
35
+
36
+ def _citation_source_text_block(c: Citation) -> str:
37
+ if not c.source_text:
38
+ return ""
39
+ return "\n " + _details_block("Xem đoạn nguồn", c.source_text)
40
+
41
+
42
+ def _marker_details(citations: list[Citation], markers: list[str]) -> list[str]:
43
+ by_marker = {c.source_marker: c for c in citations}
44
+ lines: list[str] = []
45
+ for m in markers:
46
+ c = by_marker.get(m)
47
+ if c is None:
48
+ continue
49
+ summary = f"[{c.source_marker}] {c.filename} p.{c.page}"
50
+ if c.section:
51
+ summary += f" | section: {c.section}"
52
+ if c.chunk_id:
53
+ summary += f" | chunk: {c.chunk_id}"
54
+ if c.source_text:
55
+ lines.append(f"- {_details_block(summary, c.source_text)}")
56
+ else:
57
+ lines.append(f"- {summary}")
58
+ return lines
59
+
60
+
61
  def _citations_block(citations: list[Citation]) -> str:
62
  if not citations:
63
  return ""
64
  lines = ["## Sources", ""]
65
+ for c in citations:
66
+ lines.append(f"- {_citation_line(c)}{_citation_source_text_block(c)}")
67
  return "\n".join(lines) + "\n"
68
 
69
 
70
+ def _render_with_sources(body_lines: list[str], citations: list[Citation]) -> str:
71
+ lines = [*body_lines, ""]
72
+ c = _citations_block(citations)
73
+ if c:
74
+ lines.append(c)
75
+ return "\n".join(lines).rstrip() + "\n"
76
+
77
+
78
  def _to_markdown(model: BaseModel) -> str:
79
  if isinstance(model, Summary):
80
  title = "# Summary" + (f": {model.target}" if model.target else "")
 
83
  lines.extend([model.summary.strip(), ""])
84
  if model.key_points:
85
  lines.extend(["## Key Points", "", *[f"- {kp}" for kp in model.key_points], ""])
86
+ return _render_with_sources(lines, model.citations)
87
+
88
+ if isinstance(model, RagAnswer):
89
+ return _render_with_sources([model.answer.strip()], model.citations)
90
 
91
  if isinstance(model, QuizSet):
92
  title = "# Quiz" + (f": {model.target}" if model.target else "")
 
107
  if item.explanation:
108
  lines.append(f"**Explanation:** {item.explanation.strip()}")
109
  if item.source_markers:
110
+ lines.extend(["**Sources:**", *_marker_details(model.citations, item.source_markers)])
111
  lines.append("")
112
 
113
  c = _citations_block(model.citations)
 
126
  if card.hint:
127
  lines.append(f"**Hint:** {card.hint.strip()}")
128
  if card.source_markers:
129
+ lines.extend(["**Sources:**", *_marker_details(model.citations, card.source_markers)])
130
  lines.append("")
131
 
132
  c = _citations_block(model.citations)
src/rag.py CHANGED
@@ -87,6 +87,7 @@ def format_citations(chunks: list[RetrievedChunk]) -> list[Citation]:
87
  source_marker=f"S{i}",
88
  filename=c.metadata.filename,
89
  page=c.metadata.page,
 
90
  section=c.metadata.section,
91
  chunk_id=c.metadata.chunk_id,
92
  )
 
87
  source_marker=f"S{i}",
88
  filename=c.metadata.filename,
89
  page=c.metadata.page,
90
+ source_text=c.text.strip(),
91
  section=c.metadata.section,
92
  chunk_id=c.metadata.chunk_id,
93
  )
src/schemas.py CHANGED
@@ -33,6 +33,7 @@ class Citation(BaseModel):
33
  source_marker: str
34
  filename: str
35
  page: int
 
36
  section: str | None = None
37
  chunk_id: str | None = None
38
 
 
33
  source_marker: str
34
  filename: str
35
  page: int
36
+ source_text: str | None = None
37
  section: str | None = None
38
  chunk_id: str | None = None
39
 
static/style.css CHANGED
@@ -1,42 +1,43 @@
1
  /* ==========================================================
2
  RAG Learning System — Design Tokens
3
  ========================================================== */
4
- :root, #gradio-app {
5
- --c-bg: #eef1fb;
6
- --c-bg-2: #e4e9f7;
7
- --c-surface: #ffffff;
8
- --c-surface-2: #f2f5ff;
 
9
  --c-surface-tint: rgba(255, 255, 255, 0.72);
10
 
11
- --c-primary: #3d5af1;
12
- --c-primary-dk: #2945d4;
13
  --c-primary-pale: #dde4fd;
14
 
15
- --c-accent: #6c3de0;
16
 
17
- --c-text: #181c2e;
18
- --c-text-2: #3d4460;
19
- --c-text-muted: #6370a0;
20
 
21
  /* Border needs to be darker than background for legibility */
22
- --c-border: #b6c0e6;
23
- --c-border-strong:#9faee0;
24
 
25
- --c-note-bg: #fffbeb;
26
- --c-note-bd: #f5c842;
27
- --c-note-txt: #7a5200;
28
 
29
- --c-status-bg: #f0f4ff;
30
 
31
  --r-lg: 16px;
32
  --r-md: 12px;
33
- --r-sm: 8px;
34
- --r-xs: 6px;
35
 
36
- --shadow-card: 0 3px 16px rgba(40,55,130,.12), 0 1px 5px rgba(40,55,130,.07);
37
- --shadow-panel: 0 6px 24px rgba(40,55,130,.10), 0 2px 8px rgba(40,55,130,.06);
38
- --shadow-btn: 0 4px 14px rgba(61,90,241,.30);
39
- --shadow-foc: 0 0 0 3px rgba(61,90,241,.20);
40
 
41
  --app-max-width: 1440px;
42
  }
@@ -45,19 +46,21 @@
45
  Gradio variable bridge — all blocks transparent by default
46
  ========================================================== */
47
  #gradio-app {
48
- --background-fill-primary: var(--c-bg);
49
  --background-fill-secondary: var(--c-bg-2);
50
- --block-background-fill: transparent;
51
- --block-border-color: transparent;
52
- --block-border-width: 0px;
53
- --input-background-fill: var(--c-surface);
54
  }
55
 
56
  /* ==========================================================
57
  Page shell
58
  ========================================================== */
59
- html, body {
60
- margin: 0; padding: 0;
 
 
61
  background: linear-gradient(155deg, var(--c-bg) 0%, var(--c-bg-2) 100%) !important;
62
  color: var(--c-text);
63
  min-height: 100vh;
@@ -131,9 +134,15 @@ html, body {
131
 
132
  .info-card-title,
133
  .info-card-list,
134
- .info-card-list li { color: var(--c-note-txt) !important; }
 
 
135
 
136
- .info-card-title { margin-bottom: 6px; font-size: 0.95rem; font-weight: 700; }
 
 
 
 
137
 
138
  .info-card-list {
139
  margin: 0;
@@ -142,7 +151,9 @@ html, body {
142
  line-height: 1.65;
143
  }
144
 
145
- .info-card-list li + li { margin-top: 3px; }
 
 
146
 
147
  /* ==========================================================
148
  2-Column main layout — EXPLICIT flex, do not rely solely on Gradio
@@ -196,7 +207,9 @@ html, body {
196
  }
197
 
198
  /* accent top stripe */
199
- .control-card { border-top: 3px solid var(--c-primary) !important; }
 
 
200
 
201
  /* strip nested .block inside cards — theme transparent already handles most,
202
  but belt-and-suspenders for sub-blocks that may carry inline styles */
@@ -210,7 +223,9 @@ html, body {
210
  /* ==========================================================
211
  Typography
212
  ========================================================== */
213
- label, .gr-label, .block-title {
 
 
214
  color: var(--c-text-2) !important;
215
  font-size: 0.93rem !important;
216
  font-weight: 600 !important;
@@ -229,14 +244,17 @@ label, .gr-label, .block-title {
229
  /* standalone markdown outside cards (doc_list_md etc.) */
230
  .prose p,
231
  .prose li,
232
- .prose h1, .prose h2, .prose h3 {
 
 
233
  color: var(--c-text) !important;
234
  }
235
 
236
  /* ==========================================================
237
  Inputs & textareas
238
  ========================================================== */
239
- textarea, input:not([type="range"]):not([type="checkbox"]) {
 
240
  background: var(--c-surface) !important;
241
  color: var(--c-text) !important;
242
  font-weight: 500 !important;
@@ -261,13 +279,17 @@ select:focus {
261
  outline: none !important;
262
  }
263
 
264
- textarea:focus, input:focus {
 
265
  border-color: var(--c-primary) !important;
266
  box-shadow: var(--shadow-foc) !important;
267
  outline: none !important;
268
  }
269
 
270
- ::placeholder { color: var(--c-text-muted) !important; opacity: 1 !important; }
 
 
 
271
 
272
  /* ==========================================================
273
  Gradio form wrappers — remove default gray panels
@@ -296,6 +318,73 @@ textarea:focus, input:focus {
296
  padding: 0 8px !important;
297
  }
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  /* Make option rows readable and consistent */
300
  .gradio-container input[type="checkbox"] {
301
  accent-color: var(--c-primary);
@@ -315,9 +404,9 @@ textarea:focus, input:focus {
315
  /* Tab content area sometimes renders as a gray block */
316
  .tabs,
317
  .tabs .tabitem,
318
- .tabs .tabitem > .block,
319
- .tabs .tabitem > .wrap,
320
- .tabs .tabitem > .panel {
321
  background: transparent !important;
322
  }
323
 
@@ -349,7 +438,7 @@ input[type="range"] {
349
 
350
  .gen-btn:hover {
351
  opacity: 0.88 !important;
352
- box-shadow: 0 6px 22px rgba(61,90,241,.38) !important;
353
  transform: translateY(-1px) !important;
354
  }
355
 
@@ -359,7 +448,7 @@ input[type="range"] {
359
  }
360
 
361
  /* Secondary buttons */
362
- button:not(.gen-btn) {
363
  border-radius: var(--r-sm) !important;
364
  border: 1.5px solid var(--c-border) !important;
365
  background: var(--c-surface-2) !important;
@@ -420,19 +509,33 @@ button:not(.gen-btn):hover {
420
  /* ==========================================================
421
  Accordion
422
  ========================================================== */
423
- .gradio-accordion,
424
- .gradio-accordion > div,
425
- .gradio-accordion details {
 
 
 
 
 
 
426
  background: var(--c-surface-2) !important;
427
  border: 1.5px solid var(--c-border) !important;
428
  border-radius: var(--r-md) !important;
429
- box-shadow: none !important;
430
  overflow: hidden !important;
431
  }
432
 
433
- .gradio-accordion summary,
434
- .gradio-accordion button {
435
- background: var(--c-surface-2) !important;
 
 
 
 
 
 
 
 
 
436
  color: var(--c-text-2) !important;
437
  font-weight: 700 !important;
438
  font-size: 0.93rem !important;
@@ -442,12 +545,47 @@ button:not(.gen-btn):hover {
442
  transition: background 0.14s !important;
443
  }
444
 
445
- .gradio-accordion summary:hover,
446
- .gradio-accordion button:not(.gen-btn):hover {
 
447
  background: var(--c-primary-pale) !important;
448
  border-color: transparent !important;
449
  }
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  /* ==========================================================
452
  Preview header
453
  ========================================================== */
@@ -479,7 +617,7 @@ button:not(.gen-btn):hover {
479
  ========================================================== */
480
  .result-markdown {
481
  background: var(--c-surface) !important;
482
- border: 1.5px solid var(--c-border) !important;
483
  border-radius: var(--r-md) !important;
484
  padding: 14px 16px !important;
485
  min-height: 60px;
@@ -488,13 +626,19 @@ button:not(.gen-btn):hover {
488
 
489
  .result-markdown h1,
490
  .result-markdown h2,
491
- .result-markdown h3 { color: var(--c-primary-dk) !important; }
 
 
492
 
493
  .result-markdown p,
494
  .result-markdown li,
495
- .result-markdown strong { color: var(--c-text) !important; }
 
 
496
 
497
- .result-markdown a { color: var(--c-primary) !important; }
 
 
498
 
499
  .result-markdown code {
500
  background: var(--c-surface-2) !important;
@@ -509,6 +653,28 @@ button:not(.gen-btn):hover {
509
  border-radius: var(--r-md) !important;
510
  }
511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  /* ==========================================================
513
  Tabs nav bar
514
  ========================================================== */
@@ -520,7 +686,7 @@ button:not(.gen-btn):hover {
520
  padding: 10px 12px 12px !important;
521
  }
522
 
523
- .tabs > .tab-nav {
524
  border-bottom: 2px solid var(--c-border) !important;
525
  gap: 2px !important;
526
  padding: 0 2px !important;
@@ -545,23 +711,51 @@ button:not(.gen-btn):hover {
545
  font-weight: 600;
546
  }
547
 
548
- .footer-text a:hover { text-decoration: underline; }
 
 
549
 
550
  /* ==========================================================
551
  Responsive
552
  ========================================================== */
553
  @media (max-width: 860px) {
554
- .main-layout { flex-direction: column !important; }
555
- .control-stack, .preview-col {
 
 
 
 
556
  flex: 1 1 100% !important;
557
  min-width: 0 !important;
558
  padding: 12px 12px !important;
559
  }
560
- .gradio-container { padding: 6px 8px !important; }
561
- .header-row { gap: 8px !important; padding: 2px 0 6px !important; }
562
- .header-row img { height: 56px; }
563
- .header-title { font-size: 1.35rem; }
564
- .header-sub { font-size: 0.87rem; }
565
- .control-card { padding: 10px 12px !important; }
566
- .tabs { padding: 8px 8px 10px !important; }
567
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /* ==========================================================
2
  RAG Learning System — Design Tokens
3
  ========================================================== */
4
+ :root,
5
+ #gradio-app {
6
+ --c-bg: #eef1fb;
7
+ --c-bg-2: #e4e9f7;
8
+ --c-surface: #ffffff;
9
+ --c-surface-2: #f2f5ff;
10
  --c-surface-tint: rgba(255, 255, 255, 0.72);
11
 
12
+ --c-primary: #3d5af1;
13
+ --c-primary-dk: #2945d4;
14
  --c-primary-pale: #dde4fd;
15
 
16
+ --c-accent: #6c3de0;
17
 
18
+ --c-text: #181c2e;
19
+ --c-text-2: #3d4460;
20
+ --c-text-muted: #6370a0;
21
 
22
  /* Border needs to be darker than background for legibility */
23
+ --c-border: #b6c0e6;
24
+ --c-border-strong: #9faee0;
25
 
26
+ --c-note-bg: #fffbeb;
27
+ --c-note-bd: #f5c842;
28
+ --c-note-txt: #7a5200;
29
 
30
+ --c-status-bg: #f0f4ff;
31
 
32
  --r-lg: 16px;
33
  --r-md: 12px;
34
+ --r-sm: 8px;
35
+ --r-xs: 6px;
36
 
37
+ --shadow-card: 0 3px 16px rgba(40, 55, 130, .12), 0 1px 5px rgba(40, 55, 130, .07);
38
+ --shadow-panel: 0 6px 24px rgba(40, 55, 130, .10), 0 2px 8px rgba(40, 55, 130, .06);
39
+ --shadow-btn: 0 4px 14px rgba(61, 90, 241, .30);
40
+ --shadow-foc: 0 0 0 3px rgba(61, 90, 241, .20);
41
 
42
  --app-max-width: 1440px;
43
  }
 
46
  Gradio variable bridge — all blocks transparent by default
47
  ========================================================== */
48
  #gradio-app {
49
+ --background-fill-primary: var(--c-bg);
50
  --background-fill-secondary: var(--c-bg-2);
51
+ --block-background-fill: transparent;
52
+ --block-border-color: transparent;
53
+ --block-border-width: 0px;
54
+ --input-background-fill: var(--c-surface);
55
  }
56
 
57
  /* ==========================================================
58
  Page shell
59
  ========================================================== */
60
+ html,
61
+ body {
62
+ margin: 0;
63
+ padding: 0;
64
  background: linear-gradient(155deg, var(--c-bg) 0%, var(--c-bg-2) 100%) !important;
65
  color: var(--c-text);
66
  min-height: 100vh;
 
134
 
135
  .info-card-title,
136
  .info-card-list,
137
+ .info-card-list li {
138
+ color: var(--c-note-txt) !important;
139
+ }
140
 
141
+ .info-card-title {
142
+ margin-bottom: 6px;
143
+ font-size: 0.95rem;
144
+ font-weight: 700;
145
+ }
146
 
147
  .info-card-list {
148
  margin: 0;
 
151
  line-height: 1.65;
152
  }
153
 
154
+ .info-card-list li+li {
155
+ margin-top: 3px;
156
+ }
157
 
158
  /* ==========================================================
159
  2-Column main layout — EXPLICIT flex, do not rely solely on Gradio
 
207
  }
208
 
209
  /* accent top stripe */
210
+ .control-card {
211
+ border-top: 3px solid var(--c-primary) !important;
212
+ }
213
 
214
  /* strip nested .block inside cards — theme transparent already handles most,
215
  but belt-and-suspenders for sub-blocks that may carry inline styles */
 
223
  /* ==========================================================
224
  Typography
225
  ========================================================== */
226
+ label,
227
+ .gr-label,
228
+ .block-title {
229
  color: var(--c-text-2) !important;
230
  font-size: 0.93rem !important;
231
  font-weight: 600 !important;
 
244
  /* standalone markdown outside cards (doc_list_md etc.) */
245
  .prose p,
246
  .prose li,
247
+ .prose h1,
248
+ .prose h2,
249
+ .prose h3 {
250
  color: var(--c-text) !important;
251
  }
252
 
253
  /* ==========================================================
254
  Inputs & textareas
255
  ========================================================== */
256
+ textarea,
257
+ input:not([type="range"]):not([type="checkbox"]) {
258
  background: var(--c-surface) !important;
259
  color: var(--c-text) !important;
260
  font-weight: 500 !important;
 
279
  outline: none !important;
280
  }
281
 
282
+ textarea:focus,
283
+ input:focus {
284
  border-color: var(--c-primary) !important;
285
  box-shadow: var(--shadow-foc) !important;
286
  outline: none !important;
287
  }
288
 
289
+ ::placeholder {
290
+ color: var(--c-text-muted) !important;
291
+ opacity: 1 !important;
292
+ }
293
 
294
  /* ==========================================================
295
  Gradio form wrappers — remove default gray panels
 
318
  padding: 0 8px !important;
319
  }
320
 
321
+ /* ==========================================================
322
+ Left upload block & right selection block
323
+ - remove nested borders (fieldset, inner inputs)
324
+ - let outer wrappers provide the frame
325
+ ========================================================== */
326
+ .control-stack .gr-file,
327
+ .control-stack .gr-file *,
328
+ .preview-col .gr-file,
329
+ .preview-col .gr-file * {
330
+ box-shadow: none !important;
331
+ }
332
+
333
+ .control-stack fieldset,
334
+ .control-stack .gr-checkboxgroup fieldset,
335
+ .control-stack .gr-radiogroup fieldset,
336
+ .preview-col fieldset,
337
+ .preview-col .gr-checkboxgroup fieldset,
338
+ .preview-col .gr-radiogroup fieldset {
339
+ border: none !important;
340
+ background: transparent !important;
341
+ }
342
+
343
+ .control-stack fieldset legend,
344
+ .preview-col fieldset legend {
345
+ padding: 0 8px !important;
346
+ }
347
+
348
+ .control-stack .gr-checkboxgroup,
349
+ .control-stack .gr-dropdown,
350
+ .control-stack .gr-file,
351
+ .preview-col .gr-checkboxgroup,
352
+ .preview-col .gr-dropdown,
353
+ .preview-col .gr-file {
354
+ background: transparent !important;
355
+ border: none !important;
356
+ }
357
+
358
+ .control-stack .gr-file button:not(.gen-btn),
359
+ .preview-col .gr-file button:not(.gen-btn) {
360
+ border: none !important;
361
+ background: var(--c-surface-2) !important;
362
+ }
363
+
364
+ /* Dropdown inner input: remove inner border/outline; outer wrap provides frame */
365
+ .preview-col .gr-dropdown input.border-none,
366
+ .preview-col .gr-dropdown input[role="listbox"],
367
+ .preview-col .wrap-inner input.border-none,
368
+ .preview-col .wrap-inner input[role="listbox"] {
369
+ border: none !important;
370
+ outline: none !important;
371
+ box-shadow: none !important;
372
+ background: transparent !important;
373
+ }
374
+
375
+ .preview-col .gr-dropdown input.border-none:focus,
376
+ .preview-col .gr-dropdown input[role="listbox"]:focus,
377
+ .preview-col .wrap-inner input.border-none:focus,
378
+ .preview-col .wrap-inner input[role="listbox"]:focus,
379
+ .preview-col .gr-dropdown input.border-none:focus-visible,
380
+ .preview-col .gr-dropdown input[role="listbox"]:focus-visible,
381
+ .preview-col .wrap-inner input.border-none:focus-visible,
382
+ .preview-col .wrap-inner input[role="listbox"]:focus-visible {
383
+ border: none !important;
384
+ outline: none !important;
385
+ box-shadow: none !important;
386
+ }
387
+
388
  /* Make option rows readable and consistent */
389
  .gradio-container input[type="checkbox"] {
390
  accent-color: var(--c-primary);
 
404
  /* Tab content area sometimes renders as a gray block */
405
  .tabs,
406
  .tabs .tabitem,
407
+ .tabs .tabitem>.block,
408
+ .tabs .tabitem>.wrap,
409
+ .tabs .tabitem>.panel {
410
  background: transparent !important;
411
  }
412
 
 
438
 
439
  .gen-btn:hover {
440
  opacity: 0.88 !important;
441
+ box-shadow: 0 6px 22px rgba(61, 90, 241, .38) !important;
442
  transform: translateY(-1px) !important;
443
  }
444
 
 
448
  }
449
 
450
  /* Secondary buttons */
451
+ button:not(.gen-btn):not(.label-wrap) {
452
  border-radius: var(--r-sm) !important;
453
  border: 1.5px solid var(--c-border) !important;
454
  background: var(--c-surface-2) !important;
 
509
  /* ==========================================================
510
  Accordion
511
  ========================================================== */
512
+ /* Move the "card" border to the outer block wrapper for accordion-only blocks
513
+ in the left panel, and keep inner accordion elements borderless. */
514
+ .control-stack .block.padded.auto-margin {
515
+ background: transparent !important;
516
+ border: none !important;
517
+ box-shadow: none !important;
518
+ }
519
+
520
+ .control-stack .block.padded.auto-margin:has(.gradio-accordion) {
521
  background: var(--c-surface-2) !important;
522
  border: 1.5px solid var(--c-border) !important;
523
  border-radius: var(--r-md) !important;
 
524
  overflow: hidden !important;
525
  }
526
 
527
+ .control-stack .gradio-accordion,
528
+ .control-stack .gradio-accordion>div,
529
+ .control-stack .gradio-accordion details {
530
+ background: transparent !important;
531
+ border: none !important;
532
+ box-shadow: none !important;
533
+ }
534
+
535
+ .control-stack .gradio-accordion summary,
536
+ .control-stack .gradio-accordion button,
537
+ .control-stack .gradio-accordion .label-wrap {
538
+ background: transparent !important;
539
  color: var(--c-text-2) !important;
540
  font-weight: 700 !important;
541
  font-size: 0.93rem !important;
 
545
  transition: background 0.14s !important;
546
  }
547
 
548
+ .control-stack .gradio-accordion summary:hover,
549
+ .control-stack .gradio-accordion button:not(.gen-btn):hover,
550
+ .control-stack .gradio-accordion .label-wrap:hover {
551
  background: var(--c-primary-pale) !important;
552
  border-color: transparent !important;
553
  }
554
 
555
+ /* Ensure accordion header button never gets an inner border */
556
+ .control-stack button.label-wrap,
557
+ .control-stack .block.padded.auto-margin:has(.gradio-accordion) button.label-wrap {
558
+ border: none !important;
559
+ outline: none !important;
560
+ box-shadow: none !important;
561
+ background: transparent !important;
562
+ }
563
+
564
+ /* Status bar inside left panel blocks: borderless, let outer wrapper frame it */
565
+ .control-stack .block.padded.auto-margin:has(.gradio-accordion) .status-bar {
566
+ border: none !important;
567
+ border-left: none !important;
568
+ background: transparent !important;
569
+ padding: 8px 12px !important;
570
+ }
571
+
572
+ /* Document selection checkbox options container (right panel) */
573
+ .preview-col .wrap-inner,
574
+ .preview-col .gr-checkboxgroup .wrap-inner {
575
+ border: 1.5px solid var(--c-border-strong) !important;
576
+ border-radius: var(--r-md) !important;
577
+ background: var(--c-surface) !important;
578
+ }
579
+
580
+ /* Tabs: ensure tab buttons are borderless */
581
+ .tab-container button[role="tab"],
582
+ .tab-container button[role="tab"].selected,
583
+ .tab-wrapper button[role="tab"] {
584
+ border: none !important;
585
+ box-shadow: none !important;
586
+ outline: none !important;
587
+ }
588
+
589
  /* ==========================================================
590
  Preview header
591
  ========================================================== */
 
617
  ========================================================== */
618
  .result-markdown {
619
  background: var(--c-surface) !important;
620
+ border: none !important;
621
  border-radius: var(--r-md) !important;
622
  padding: 14px 16px !important;
623
  min-height: 60px;
 
626
 
627
  .result-markdown h1,
628
  .result-markdown h2,
629
+ .result-markdown h3 {
630
+ color: var(--c-primary-dk) !important;
631
+ }
632
 
633
  .result-markdown p,
634
  .result-markdown li,
635
+ .result-markdown strong {
636
+ color: var(--c-text) !important;
637
+ }
638
 
639
+ .result-markdown a {
640
+ color: var(--c-primary) !important;
641
+ }
642
 
643
  .result-markdown code {
644
  background: var(--c-surface-2) !important;
 
653
  border-radius: var(--r-md) !important;
654
  }
655
 
656
+ /* ==========================================================
657
+ Inline citations (HTML <details>) inside result markdown
658
+ ========================================================== */
659
+ .result-markdown details {
660
+ background: var(--c-surface-2);
661
+ border: 1px solid var(--c-border);
662
+ border-radius: var(--r-sm);
663
+ padding: 8px 10px;
664
+ margin-top: 8px;
665
+ }
666
+
667
+ .result-markdown summary {
668
+ cursor: pointer;
669
+ font-weight: 700;
670
+ color: var(--c-text-2);
671
+ outline: none;
672
+ }
673
+
674
+ .result-markdown details[open] summary {
675
+ color: var(--c-primary-dk);
676
+ }
677
+
678
  /* ==========================================================
679
  Tabs nav bar
680
  ========================================================== */
 
686
  padding: 10px 12px 12px !important;
687
  }
688
 
689
+ .tabs>.tab-nav {
690
  border-bottom: 2px solid var(--c-border) !important;
691
  gap: 2px !important;
692
  padding: 0 2px !important;
 
711
  font-weight: 600;
712
  }
713
 
714
+ .footer-text a:hover {
715
+ text-decoration: underline;
716
+ }
717
 
718
  /* ==========================================================
719
  Responsive
720
  ========================================================== */
721
  @media (max-width: 860px) {
722
+ .main-layout {
723
+ flex-direction: column !important;
724
+ }
725
+
726
+ .control-stack,
727
+ .preview-col {
728
  flex: 1 1 100% !important;
729
  min-width: 0 !important;
730
  padding: 12px 12px !important;
731
  }
732
+
733
+ .gradio-container {
734
+ padding: 6px 8px !important;
735
+ }
736
+
737
+ .header-row {
738
+ gap: 8px !important;
739
+ padding: 2px 0 6px !important;
740
+ }
741
+
742
+ .header-row img {
743
+ height: 56px;
744
+ }
745
+
746
+ .header-title {
747
+ font-size: 1.35rem;
748
+ }
749
+
750
+ .header-sub {
751
+ font-size: 0.87rem;
752
+ }
753
+
754
+ .control-card {
755
+ padding: 10px 12px !important;
756
+ }
757
+
758
+ .tabs {
759
+ padding: 8px 8px 10px !important;
760
+ }
761
+ }