mirror of
https://github.com/furyhawk/Multi-Agent-Research-Assistant.git
synced 2026-07-21 00:25:35 +00:00
refactor(app): modularize UI and add PDF export support
Split app.py into separate modules: state.py for Reflex state management, report_formatting.py for Markdown processing and CSS, and pdf_export.py for ReportLab-based PDF generation. Update README with app structure details and PDF download feature. Refine research_assistant.py to remove redundant date context and enhance time-sensitive query handling. Adjust .gitignore for Python bytecode and temporary files. Add reportlab dependency to requirements.txt.
This commit is contained in:
+6
-2
@@ -1,3 +1,7 @@
|
||||
tmp
|
||||
.states
|
||||
.web
|
||||
*.py[cod]
|
||||
# Reflex generated files
|
||||
.web/
|
||||
.states/
|
||||
@@ -206,9 +210,9 @@ cython_debug/
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
# Temporary file for partial code execution
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
---
|
||||
title: Multi-Agent Research Assistant
|
||||
emoji: 🌍
|
||||
colorFrom: blue
|
||||
colorTo: green
|
||||
sdk: docker
|
||||
app_port: 7860
|
||||
pinned: true
|
||||
license: apache-2.0
|
||||
thumbnail: >-
|
||||
https://cdn-uploads.huggingface.co/production/uploads/603945d6db430f160dced222/_vUmbjxaAA8DYH5iNL0QZ.png
|
||||
short_description: Manager → Answer API → Judge → Search/Scrape → Analyst.
|
||||
---
|
||||
|
||||
# Multi-Agent Research Assistant
|
||||
|
||||
A multi-agent research assistant built with the OpenAI Agents SDK, Olostep, and Reflex.
|
||||
|
||||
https://github.com/user-attachments/assets/9aee7d1e-7d3d-4c11-b286-a6b11fef2d8d
|
||||
|
||||
Enter a research question and the manager agent coordinates judges, retrieval tools, and an analyst agent to produce a polished, source-backed Markdown research report. The original notebook is included, and the same logic is also available as a Reflex web app.
|
||||
Enter a research question and the manager agent coordinates judges, retrieval tools, and an analyst agent to produce a polished, source-backed Markdown research report. The Reflex app renders the report in the browser and exports a formatted PDF.
|
||||
|
||||
## Flow
|
||||
|
||||
@@ -111,15 +97,22 @@ Then open the local URL printed by Reflex, usually:
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
## App Structure
|
||||
|
||||
The app files live in `app/`:
|
||||
|
||||
- `app/app.py` — Reflex UI with styled Markdown report rendering and download.
|
||||
- `app/research_assistant.py` — OpenAI Agents SDK multi-agent workflow with Olostep tools.
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/app.py` | Reflex UI components and page registration only. |
|
||||
| `app/state.py` | Reflex state, event handlers, progress logging, stop/reset behavior, and downloads. |
|
||||
| `app/research_assistant.py` | OpenAI Agents SDK workflow with Manager, Judge, Analyst, and Olostep tools. |
|
||||
| `app/report_formatting.py` | Markdown cleanup, browser HTML rendering, link behavior, and report CSS. |
|
||||
| `app/pdf_export.py` | ReportLab-based PDF generation with headings, bullets, links, and Markdown table support. |
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-agent workflow** — Manager, Judge, and Analyst agents collaborate while the manager directly controls Olostep retrieval tools.
|
||||
- **Live progress logs** — Watch each agent step in real time.
|
||||
- **Styled Markdown report** — Headings, bullets, tables, code blocks, and more render properly in the browser.
|
||||
- **Download report** — Export the full Markdown report with one click.
|
||||
- **Deep retrieval path** — If early evidence is weak, the manager runs targeted searches and scrapes at least the top 3 relevant pages.
|
||||
- **Multi-agent workflow**: Manager, Judge, and Analyst agents collaborate while the manager directly controls Olostep retrieval tools.
|
||||
- **Live progress logs**: Watch each agent step in real time.
|
||||
- **Styled Markdown report**: Headings, bullets, tables, code blocks, and more render properly in the browser.
|
||||
- **Download report**: Export the full report as a formatted PDF using ReportLab.
|
||||
- **Deep retrieval path**: If early evidence is weak, the manager runs targeted searches and scrapes at least the top 3 relevant pages.
|
||||
|
||||
+193
-339
@@ -1,173 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
|
||||
import markdown
|
||||
from markdown.extensions import Extension
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
import reflex as rx
|
||||
|
||||
from .research_assistant import environment_status, run_research_assistant
|
||||
from .report_formatting import MARKDOWN_CSS
|
||||
from .state import State
|
||||
|
||||
|
||||
PAGE_BG = "linear-gradient(135deg, #fbfdff 0%, #fffdf7 48%, #f8fff9 100%)"
|
||||
PAGE_PADDING = {"initial": "1rem", "sm": "1.25rem", "lg": "2rem"}
|
||||
PANEL_PADDING = {"initial": "1rem", "sm": "1.25rem"}
|
||||
_RUNNING_TASKS: dict[str, asyncio.Task] = {}
|
||||
_REFERENCE_RETURN_MARKERS = re.compile(r"(?:\s*(?:↩️|↩|🔙|↪️|↪)){1,}")
|
||||
|
||||
|
||||
def _plain_markdown(value) -> str:
|
||||
if isinstance(value, str):
|
||||
return _clean_markdown(value)
|
||||
if isinstance(value, dict):
|
||||
nested = value.get("markdown_report")
|
||||
text = nested if isinstance(nested, str) else str(nested or value)
|
||||
return _clean_markdown(text)
|
||||
nested = getattr(value, "markdown_report", None)
|
||||
text = nested if isinstance(nested, str) else str(value)
|
||||
return _clean_markdown(text)
|
||||
|
||||
|
||||
def _clean_markdown(value: str) -> str:
|
||||
return _REFERENCE_RETURN_MARKERS.sub("", value)
|
||||
|
||||
|
||||
class NewTabLinksTreeprocessor(Treeprocessor):
|
||||
def run(self, root):
|
||||
for element in root.iter("a"):
|
||||
element.set("target", "_blank")
|
||||
element.set("rel", "noopener noreferrer")
|
||||
return root
|
||||
|
||||
|
||||
class NewTabLinksExtension(Extension):
|
||||
def extendMarkdown(self, md):
|
||||
md.treeprocessors.register(NewTabLinksTreeprocessor(md), "new_tab_links", 15)
|
||||
|
||||
|
||||
class State(rx.State):
|
||||
query: str = ""
|
||||
logs: list[str] = []
|
||||
report_markdown: str = ""
|
||||
report_html: str = ""
|
||||
trace_url: str = ""
|
||||
error: str = ""
|
||||
is_running: bool = False
|
||||
status: str = "Ready"
|
||||
active_run_id: int = 0
|
||||
step_started_at: float = 0.0
|
||||
|
||||
def set_query(self, value: str) -> None:
|
||||
self.query = value
|
||||
|
||||
def _client_token(self) -> str:
|
||||
return self.router.session.client_token or "default"
|
||||
|
||||
def _cancel_active_task(self) -> bool:
|
||||
task = _RUNNING_TASKS.pop(self._client_token(), None)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear_all(self) -> None:
|
||||
self._cancel_active_task()
|
||||
self.active_run_id += 1
|
||||
self.query = ""
|
||||
self.logs = []
|
||||
self.report_markdown = ""
|
||||
self.report_html = ""
|
||||
self.trace_url = ""
|
||||
self.error = ""
|
||||
self.is_running = False
|
||||
self.status = "Ready"
|
||||
self.step_started_at = 0.0
|
||||
|
||||
def stop_report(self) -> None:
|
||||
if not self.is_running:
|
||||
return
|
||||
now = time.monotonic()
|
||||
elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
|
||||
self._cancel_active_task()
|
||||
self.is_running = False
|
||||
self.error = ""
|
||||
self.status = "Stopped"
|
||||
self.step_started_at = now
|
||||
self.logs.append(f"{elapsed:.1f}s Research stopped by user.")
|
||||
|
||||
async def _log(self, message: str) -> None:
|
||||
async with self:
|
||||
now = time.monotonic()
|
||||
elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
|
||||
self.step_started_at = now
|
||||
self.logs.append(f"{elapsed:.1f}s {message}")
|
||||
|
||||
def handle_key_down(self, key: str):
|
||||
if key == "Enter":
|
||||
return State.run_report
|
||||
|
||||
@rx.event(background=True)
|
||||
async def run_report(self):
|
||||
task = asyncio.current_task()
|
||||
run_id = 0
|
||||
async with self:
|
||||
query = self.query.strip()
|
||||
if not query:
|
||||
self.error = ""
|
||||
return
|
||||
self.active_run_id += 1
|
||||
run_id = self.active_run_id
|
||||
if task is not None:
|
||||
_RUNNING_TASKS[self._client_token()] = task
|
||||
self.logs = []
|
||||
self.report_markdown = ""
|
||||
self.report_html = ""
|
||||
self.trace_url = ""
|
||||
self.error = ""
|
||||
self.is_running = True
|
||||
self.status = "Researching"
|
||||
self.step_started_at = time.monotonic()
|
||||
|
||||
try:
|
||||
report, trace_url = await run_research_assistant(query, progress=self._log)
|
||||
async with self:
|
||||
self.report_markdown = _plain_markdown(report.markdown_report)
|
||||
self.report_html = markdown.markdown(
|
||||
self.report_markdown,
|
||||
extensions=["extra", "sane_lists", "tables", NewTabLinksExtension()],
|
||||
output_format="html5",
|
||||
)
|
||||
self.trace_url = trace_url
|
||||
self.status = "Complete"
|
||||
except asyncio.CancelledError:
|
||||
async with self:
|
||||
if self.active_run_id == run_id:
|
||||
self.error = ""
|
||||
self.status = "Stopped"
|
||||
except Exception as exc:
|
||||
async with self:
|
||||
if self.active_run_id == run_id:
|
||||
self.error = str(exc)
|
||||
self.status = "Failed"
|
||||
finally:
|
||||
async with self:
|
||||
if self.active_run_id == run_id:
|
||||
self.is_running = False
|
||||
_RUNNING_TASKS.pop(self._client_token(), None)
|
||||
|
||||
def download_markdown(self):
|
||||
if not self.report_markdown:
|
||||
return rx.window_alert("Generate a report before downloading.")
|
||||
return rx.download(data=self.report_markdown, filename="research-report.md")
|
||||
|
||||
|
||||
def status_badge() -> rx.Component:
|
||||
return rx.badge(
|
||||
State.status,
|
||||
color_scheme=rx.cond(State.status == "Complete", "green", rx.cond(State.status == "Failed", "red", "blue")),
|
||||
color_scheme=rx.cond(
|
||||
State.status == "Complete",
|
||||
"green",
|
||||
rx.cond(State.status == "Failed", "red", "blue"),
|
||||
),
|
||||
variant="soft",
|
||||
size="2",
|
||||
)
|
||||
@@ -190,7 +41,12 @@ def log_panel() -> rx.Component:
|
||||
rx.vstack(
|
||||
rx.foreach(
|
||||
State.logs,
|
||||
lambda item: rx.text(item, font_family="monospace", font_size="0.8rem", color="#263238"),
|
||||
lambda item: rx.text(
|
||||
item,
|
||||
font_family="monospace",
|
||||
font_size="0.8rem",
|
||||
color="#263238",
|
||||
),
|
||||
),
|
||||
align="stretch",
|
||||
spacing="2",
|
||||
@@ -207,41 +63,14 @@ def log_panel() -> rx.Component:
|
||||
)
|
||||
|
||||
|
||||
_MARKDOWN_CSS = """
|
||||
<style>
|
||||
.md-report h1 { font-size: 1.65rem; font-weight: 700; margin: 1.4rem 0 0.6rem; color: #101828; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.35rem; }
|
||||
.md-report h2 { font-size: 1.35rem; font-weight: 600; margin: 1.2rem 0 0.5rem; color: #1e293b; border-bottom: 1px solid #f1f5f9; padding-bottom: 0.25rem; }
|
||||
.md-report h3 { font-size: 1.12rem; font-weight: 600; margin: 1rem 0 0.4rem; color: #334155; }
|
||||
.md-report h4 { font-size: 1rem; font-weight: 600; margin: 0.8rem 0 0.3rem; color: #475569; }
|
||||
.md-report p { margin: 0.5rem 0; line-height: 1.7; color: #374151; }
|
||||
.md-report ul, .md-report ol { margin: 0.5rem 0 0.5rem 1.5rem; padding: 0; }
|
||||
.md-report ul { list-style-type: disc; }
|
||||
.md-report ol { list-style-type: decimal; }
|
||||
.md-report li { margin: 0.25rem 0; line-height: 1.65; color: #374151; }
|
||||
.md-report li > ul, .md-report li > ol { margin: 0.2rem 0 0.2rem 1.2rem; }
|
||||
.md-report strong { font-weight: 700; color: #111827; }
|
||||
.md-report em { font-style: italic; }
|
||||
.md-report blockquote { border-left: 4px solid #94a3b8; margin: 0.75rem 0; padding: 0.5rem 1rem; background: #f8fafc; color: #475569; border-radius: 0 4px 4px 0; }
|
||||
.md-report code { background: #f1f5f9; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.88em; color: #7c3aed; }
|
||||
.md-report pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.75rem 0; }
|
||||
.md-report pre code { background: none; color: inherit; padding: 0; font-size: 0.88em; }
|
||||
.md-report table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
|
||||
.md-report th, .md-report td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; }
|
||||
.md-report th { background: #f8fafc; font-weight: 600; color: #1e293b; }
|
||||
.md-report hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.2rem 0; }
|
||||
.md-report a { color: #2563eb; text-decoration: underline; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def report_panel() -> rx.Component:
|
||||
return rx.box(
|
||||
rx.hstack(
|
||||
rx.spacer(),
|
||||
rx.button(
|
||||
rx.icon("download", size=17),
|
||||
"Download",
|
||||
on_click=State.download_markdown,
|
||||
"Download PDF",
|
||||
on_click=State.download_pdf,
|
||||
background="#2f9e44",
|
||||
color="white",
|
||||
border_radius="8px",
|
||||
@@ -250,7 +79,11 @@ def report_panel() -> rx.Component:
|
||||
cursor="pointer",
|
||||
margin_top="1rem",
|
||||
margin_right="1rem",
|
||||
_hover={"background": "#238b36", "transform": "translateY(-1px)", "box_shadow": "0 4px 12px rgba(47, 158, 68, 0.35)"},
|
||||
_hover={
|
||||
"background": "#238b36",
|
||||
"transform": "translateY(-1px)",
|
||||
"box_shadow": "0 4px 12px rgba(47, 158, 68, 0.35)",
|
||||
},
|
||||
transition="all 0.2s ease",
|
||||
),
|
||||
justify="end",
|
||||
@@ -258,7 +91,10 @@ def report_panel() -> rx.Component:
|
||||
width="100%",
|
||||
),
|
||||
rx.html(
|
||||
_MARKDOWN_CSS + '<div class="md-report" style="padding: 0.5rem 1.25rem 1rem;">' + State.report_html + "</div>",
|
||||
MARKDOWN_CSS
|
||||
+ '<div class="md-report" style="padding: 0.5rem 1.25rem 1rem;">'
|
||||
+ State.report_html
|
||||
+ "</div>",
|
||||
width="100%",
|
||||
overflow_x="auto",
|
||||
),
|
||||
@@ -272,162 +108,180 @@ def report_panel() -> rx.Component:
|
||||
)
|
||||
|
||||
|
||||
def search_bar() -> rx.Component:
|
||||
return rx.hstack(
|
||||
rx.input(
|
||||
value=State.query,
|
||||
on_change=State.set_query,
|
||||
on_key_down=State.handle_key_down,
|
||||
placeholder="Ask anything",
|
||||
height="2.8rem",
|
||||
width="100%",
|
||||
flex="1 1 auto",
|
||||
min_width="0",
|
||||
background="transparent",
|
||||
border="0",
|
||||
box_shadow="none",
|
||||
font_size="1.05rem",
|
||||
margin_left="1.5rem",
|
||||
padding_left="0",
|
||||
text_indent="0.85rem",
|
||||
padding_right={"initial": "0.75rem", "sm": "1rem"},
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("rotate_ccw", size=19),
|
||||
on_click=State.clear_all,
|
||||
aria_label="Reset",
|
||||
height="3.15rem",
|
||||
width="3.15rem",
|
||||
min_width="3.15rem",
|
||||
padding="0",
|
||||
flex="0 0 auto",
|
||||
border_radius="8px",
|
||||
background="transparent",
|
||||
color="#111111",
|
||||
box_shadow="none",
|
||||
cursor="pointer",
|
||||
_hover={"background": "#f3f4f6"},
|
||||
),
|
||||
rx.cond(
|
||||
State.is_running,
|
||||
rx.button(
|
||||
rx.icon("square", size=16),
|
||||
on_click=State.stop_report,
|
||||
aria_label="Stop",
|
||||
height="3.15rem",
|
||||
width="3.15rem",
|
||||
min_width="3.15rem",
|
||||
padding="0",
|
||||
flex="0 0 auto",
|
||||
border_radius="999px",
|
||||
background="#111111",
|
||||
color="white",
|
||||
cursor="pointer",
|
||||
position="relative",
|
||||
z_index="1",
|
||||
margin_right="1rem",
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("search", size=17),
|
||||
on_click=State.run_report,
|
||||
aria_label="Search",
|
||||
height="3.15rem",
|
||||
width="3.15rem",
|
||||
min_width="3.15rem",
|
||||
padding="0",
|
||||
flex="0 0 auto",
|
||||
border_radius="999px",
|
||||
background="#111111",
|
||||
color="white",
|
||||
cursor="pointer",
|
||||
position="relative",
|
||||
z_index="1",
|
||||
margin_right="1rem",
|
||||
),
|
||||
),
|
||||
width="100%",
|
||||
min_height="5rem",
|
||||
align="center",
|
||||
justify="between",
|
||||
spacing={"initial": "3", "sm": "4"},
|
||||
align_self="center",
|
||||
padding={
|
||||
"initial": "0.75rem 1.85rem 0.75rem 1.2rem",
|
||||
"sm": "0.8rem 2.35rem 0.8rem 1.6rem",
|
||||
},
|
||||
border="1px solid #d8d8d8",
|
||||
border_radius="999px",
|
||||
background="rgba(255, 255, 255, 0.96)",
|
||||
box_shadow="0 18px 55px rgba(16, 24, 40, 0.12)",
|
||||
)
|
||||
|
||||
|
||||
def prompt_suggestions() -> rx.Component:
|
||||
return rx.hstack(
|
||||
rx.button(
|
||||
"Is remote work dying in 2026?",
|
||||
on_click=State.set_query("Is remote work dying in 2026?"),
|
||||
variant="soft",
|
||||
color_scheme="gray",
|
||||
border_radius="999px",
|
||||
),
|
||||
rx.button(
|
||||
"What's behind the global coffee shortage?",
|
||||
on_click=State.set_query("What's behind the global coffee shortage?"),
|
||||
variant="soft",
|
||||
color_scheme="gray",
|
||||
border_radius="999px",
|
||||
),
|
||||
rx.button(
|
||||
"Are electric cars actually cheaper to own?",
|
||||
on_click=State.set_query("Are electric cars actually cheaper to own?"),
|
||||
variant="soft",
|
||||
color_scheme="gray",
|
||||
border_radius="999px",
|
||||
),
|
||||
justify="center",
|
||||
align="center",
|
||||
wrap="wrap",
|
||||
spacing="3",
|
||||
width="100%",
|
||||
)
|
||||
|
||||
|
||||
def page_header() -> rx.Component:
|
||||
return rx.vstack(
|
||||
rx.heading(
|
||||
"What do you want to research today?",
|
||||
size={"initial": "6", "md": "7"},
|
||||
weight="regular",
|
||||
color="#101828",
|
||||
text_align="center",
|
||||
line_height="1.15",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.badge("Manager", color_scheme="blue", variant="soft", size="2", border_radius="999px"),
|
||||
rx.badge("Judge", color_scheme="orange", variant="soft", size="2", border_radius="999px"),
|
||||
rx.badge("Researcher", color_scheme="purple", variant="soft", size="2", border_radius="999px"),
|
||||
rx.badge("Analyst", color_scheme="green", variant="soft", size="2", border_radius="999px"),
|
||||
justify="center",
|
||||
align="center",
|
||||
wrap="wrap",
|
||||
spacing="2",
|
||||
),
|
||||
width="100%",
|
||||
align="center",
|
||||
spacing="3",
|
||||
)
|
||||
|
||||
|
||||
def result_area() -> rx.Component:
|
||||
return rx.cond(
|
||||
State.is_running,
|
||||
log_panel(),
|
||||
rx.cond(
|
||||
State.report_markdown != "",
|
||||
report_panel(),
|
||||
rx.box(
|
||||
rx.text("Enter a question and press Search.", color="#52616b", align="center"),
|
||||
padding="0.25rem",
|
||||
width="100%",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def index() -> rx.Component:
|
||||
_ok, _missing, _olostep_ver, _openai_ver = environment_status()
|
||||
return rx.box(
|
||||
rx.vstack(
|
||||
rx.vstack(
|
||||
rx.heading(
|
||||
"What do you want to research today?",
|
||||
size={"initial": "6", "md": "7"},
|
||||
weight="regular",
|
||||
color="#101828",
|
||||
text_align="center",
|
||||
line_height="1.15",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.badge("Manager", color_scheme="blue", variant="soft", size="2", border_radius="999px"),
|
||||
rx.badge("Judge", color_scheme="orange", variant="soft", size="2", border_radius="999px"),
|
||||
rx.badge("Researcher", color_scheme="purple", variant="soft", size="2", border_radius="999px"),
|
||||
rx.badge("Analyst", color_scheme="green", variant="soft", size="2", border_radius="999px"),
|
||||
justify="center",
|
||||
align="center",
|
||||
wrap="wrap",
|
||||
spacing="2",
|
||||
),
|
||||
width="100%",
|
||||
align="center",
|
||||
spacing="3",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.input(
|
||||
value=State.query,
|
||||
on_change=State.set_query,
|
||||
on_key_down=State.handle_key_down,
|
||||
placeholder="Ask anything",
|
||||
height="2.8rem",
|
||||
width="100%",
|
||||
flex="1 1 auto",
|
||||
min_width="0",
|
||||
background="transparent",
|
||||
border="0",
|
||||
box_shadow="none",
|
||||
font_size="1.05rem",
|
||||
margin_left="1.5rem",
|
||||
padding_left="0",
|
||||
text_indent="0.85rem",
|
||||
padding_right={"initial": "0.75rem", "sm": "1rem"},
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("rotate_ccw", size=19),
|
||||
on_click=State.clear_all,
|
||||
aria_label="Reset",
|
||||
height="3.15rem",
|
||||
width="3.15rem",
|
||||
min_width="3.15rem",
|
||||
padding="0",
|
||||
flex="0 0 auto",
|
||||
border_radius="8px",
|
||||
background="transparent",
|
||||
color="#111111",
|
||||
box_shadow="none",
|
||||
cursor="pointer",
|
||||
_hover={"background": "#f3f4f6"},
|
||||
),
|
||||
rx.cond(
|
||||
State.is_running,
|
||||
rx.button(
|
||||
rx.icon("square", size=16),
|
||||
on_click=State.stop_report,
|
||||
aria_label="Stop",
|
||||
height="3.15rem",
|
||||
width="3.15rem",
|
||||
min_width="3.15rem",
|
||||
padding="0",
|
||||
flex="0 0 auto",
|
||||
border_radius="999px",
|
||||
background="#111111",
|
||||
color="white",
|
||||
cursor="pointer",
|
||||
position="relative",
|
||||
z_index="1",
|
||||
margin_right="1rem",
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("search", size=17),
|
||||
on_click=State.run_report,
|
||||
aria_label="Search",
|
||||
height="3.15rem",
|
||||
width="3.15rem",
|
||||
min_width="3.15rem",
|
||||
padding="0",
|
||||
flex="0 0 auto",
|
||||
border_radius="999px",
|
||||
background="#111111",
|
||||
color="white",
|
||||
cursor="pointer",
|
||||
position="relative",
|
||||
z_index="1",
|
||||
margin_right="1rem",
|
||||
),
|
||||
),
|
||||
width="100%",
|
||||
min_height="5rem",
|
||||
align="center",
|
||||
justify="between",
|
||||
spacing={"initial": "3", "sm": "4"},
|
||||
align_self="center",
|
||||
padding={
|
||||
"initial": "0.75rem 1.85rem 0.75rem 1.2rem",
|
||||
"sm": "0.8rem 2.35rem 0.8rem 1.6rem",
|
||||
},
|
||||
border="1px solid #d8d8d8",
|
||||
border_radius="999px",
|
||||
background="rgba(255, 255, 255, 0.96)",
|
||||
box_shadow="0 18px 55px rgba(16, 24, 40, 0.12)",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.button(
|
||||
"Is remote work dying in 2026?",
|
||||
on_click=State.set_query("Is remote work dying in 2026?"),
|
||||
variant="soft",
|
||||
color_scheme="gray",
|
||||
border_radius="999px",
|
||||
),
|
||||
rx.button(
|
||||
"What's behind the global coffee shortage?",
|
||||
on_click=State.set_query("What's behind the global coffee shortage?"),
|
||||
variant="soft",
|
||||
color_scheme="gray",
|
||||
border_radius="999px",
|
||||
),
|
||||
rx.button(
|
||||
"Are electric cars actually cheaper to own?",
|
||||
on_click=State.set_query("Are electric cars actually cheaper to own?"),
|
||||
variant="soft",
|
||||
color_scheme="gray",
|
||||
border_radius="999px",
|
||||
),
|
||||
justify="center",
|
||||
align="center",
|
||||
wrap="wrap",
|
||||
spacing="3",
|
||||
width="100%",
|
||||
),
|
||||
rx.cond(State.error != "", rx.callout(State.error, icon="triangle_alert", color_scheme="red", width="100%")),
|
||||
page_header(),
|
||||
search_bar(),
|
||||
prompt_suggestions(),
|
||||
rx.cond(
|
||||
State.is_running,
|
||||
log_panel(),
|
||||
rx.cond(
|
||||
State.report_markdown != "",
|
||||
report_panel(),
|
||||
rx.box(
|
||||
rx.text("Enter a question and press Search.", color="#52616b", align="center"),
|
||||
padding="0.25rem",
|
||||
width="100%",
|
||||
),
|
||||
),
|
||||
State.error != "",
|
||||
rx.callout(State.error, icon="triangle_alert", color_scheme="red", width="100%"),
|
||||
),
|
||||
result_area(),
|
||||
spacing="5",
|
||||
align="stretch",
|
||||
padding=PANEL_PADDING,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from html import escape
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def markdown_to_pdf_bytes(report_markdown: str) -> bytes:
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import (
|
||||
ListFlowable,
|
||||
ListItem,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
buffer = BytesIO()
|
||||
styles = getSampleStyleSheet()
|
||||
body_style = ParagraphStyle(
|
||||
"ReportBody",
|
||||
parent=styles["BodyText"],
|
||||
fontName="Helvetica",
|
||||
fontSize=10,
|
||||
leading=14,
|
||||
alignment=TA_LEFT,
|
||||
spaceAfter=5,
|
||||
)
|
||||
title_style = ParagraphStyle(
|
||||
"ReportTitle",
|
||||
parent=styles["Title"],
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=18,
|
||||
leading=22,
|
||||
alignment=TA_LEFT,
|
||||
spaceAfter=10,
|
||||
)
|
||||
heading1_style = ParagraphStyle(
|
||||
"ReportHeading1",
|
||||
parent=styles["Heading1"],
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=15,
|
||||
leading=18,
|
||||
alignment=TA_LEFT,
|
||||
spaceBefore=10,
|
||||
spaceAfter=6,
|
||||
)
|
||||
heading2_style = ParagraphStyle(
|
||||
"ReportHeading2",
|
||||
parent=styles["Heading2"],
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=12,
|
||||
leading=15,
|
||||
alignment=TA_LEFT,
|
||||
spaceBefore=7,
|
||||
spaceAfter=4,
|
||||
)
|
||||
table_header_style = ParagraphStyle(
|
||||
"ReportTableHeader",
|
||||
parent=body_style,
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=8.5,
|
||||
leading=11,
|
||||
)
|
||||
table_cell_style = ParagraphStyle(
|
||||
"ReportTableCell",
|
||||
parent=body_style,
|
||||
fontSize=8.5,
|
||||
leading=11,
|
||||
)
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
rightMargin=0.65 * inch,
|
||||
leftMargin=0.65 * inch,
|
||||
topMargin=0.65 * inch,
|
||||
bottomMargin=0.65 * inch,
|
||||
)
|
||||
story = []
|
||||
list_items = []
|
||||
list_type = "bullet"
|
||||
table_rows = []
|
||||
|
||||
def flush_list() -> None:
|
||||
nonlocal list_items, list_type
|
||||
if list_items:
|
||||
story.append(
|
||||
ListFlowable(
|
||||
[ListItem(Paragraph(item, body_style)) for item in list_items],
|
||||
bulletType=list_type,
|
||||
bulletFontName="Helvetica",
|
||||
bulletFontSize=7 if list_type == "bullet" else 9,
|
||||
leftIndent=16,
|
||||
bulletIndent=4,
|
||||
itemSpace=3,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 6))
|
||||
list_items = []
|
||||
list_type = "bullet"
|
||||
|
||||
def flush_table() -> None:
|
||||
nonlocal table_rows
|
||||
if not table_rows:
|
||||
return
|
||||
if len(table_rows) >= 2 and _is_markdown_table_separator(table_rows[1]):
|
||||
rows = [table_rows[0], *table_rows[2:]]
|
||||
data = []
|
||||
for row_index, row in enumerate(rows):
|
||||
style = table_header_style if row_index == 0 else table_cell_style
|
||||
data.append(
|
||||
[
|
||||
Paragraph(_markdown_to_reportlab_text(cell), style)
|
||||
for cell in _split_table_row(row)
|
||||
]
|
||||
)
|
||||
if data:
|
||||
col_count = max(len(row) for row in data)
|
||||
for row in data:
|
||||
row.extend(
|
||||
Paragraph("", table_cell_style)
|
||||
for _ in range(col_count - len(row))
|
||||
)
|
||||
table = Table(data, repeatRows=1)
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f1f5f9")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
|
||||
("GRID", (0, 0), (-1, -1), 0.35, colors.HexColor("#cbd5e1")),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(table)
|
||||
story.append(Spacer(1, 8))
|
||||
else:
|
||||
for row in table_rows:
|
||||
story.append(Paragraph(_markdown_to_reportlab_text(row), body_style))
|
||||
table_rows = []
|
||||
|
||||
def append_list_item(item: str, item_type: str) -> None:
|
||||
nonlocal list_items, list_type
|
||||
if list_items and list_type != item_type:
|
||||
flush_list()
|
||||
list_type = item_type
|
||||
list_items.append(item)
|
||||
|
||||
for raw_line in report_markdown.splitlines():
|
||||
stripped_line = raw_line.strip()
|
||||
if _is_markdown_table_line(stripped_line):
|
||||
flush_list()
|
||||
table_rows.append(stripped_line)
|
||||
continue
|
||||
|
||||
flush_table()
|
||||
line = _markdown_to_reportlab_text(stripped_line)
|
||||
if not line:
|
||||
flush_list()
|
||||
story.append(Spacer(1, 5))
|
||||
continue
|
||||
if line.startswith("- ") or line.startswith("* "):
|
||||
append_list_item(line[2:].strip(), "bullet")
|
||||
continue
|
||||
flush_list()
|
||||
if line.startswith("# "):
|
||||
story.append(Paragraph(line[2:].strip(), title_style))
|
||||
elif line.startswith("## "):
|
||||
story.append(Paragraph(line[3:].strip(), heading1_style))
|
||||
elif line.startswith("### "):
|
||||
story.append(Paragraph(line[4:].strip(), heading2_style))
|
||||
elif set(line) <= {"-", "_", "*"}:
|
||||
story.append(Spacer(1, 8))
|
||||
else:
|
||||
story.append(Paragraph(line, body_style))
|
||||
|
||||
flush_table()
|
||||
flush_list()
|
||||
if not story:
|
||||
raise RuntimeError("No report content available for PDF generation.")
|
||||
doc.build(story)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _is_markdown_table_line(value: str) -> bool:
|
||||
return value.startswith("|") and value.endswith("|") and value.count("|") >= 2
|
||||
|
||||
|
||||
def _is_markdown_table_separator(value: str) -> bool:
|
||||
cells = _split_table_row(value)
|
||||
return bool(cells) and all(
|
||||
re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in cells
|
||||
)
|
||||
|
||||
|
||||
def _split_table_row(value: str) -> list[str]:
|
||||
return [cell.strip() for cell in value.strip().strip("|").split("|")]
|
||||
|
||||
|
||||
def _markdown_to_reportlab_text(value: str) -> str:
|
||||
value = escape(value)
|
||||
value = re.sub(r"^(\d+)\.\s+", r"\1. ", value)
|
||||
value = re.sub(r"`([^`]+)`", r"\1", value)
|
||||
value = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", value)
|
||||
value = re.sub(r"\*([^*]+)\*", r"<i>\1</i>", value)
|
||||
return re.sub(
|
||||
r"\[([^\]]+)\]\(([^)]+)\)",
|
||||
r'<link href="\2" color="blue">\1</link>',
|
||||
value,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import markdown
|
||||
from markdown.extensions import Extension
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
|
||||
|
||||
MARKDOWN_CSS = """
|
||||
<style>
|
||||
.md-report h1 { font-size: 1.65rem; font-weight: 700; margin: 1.4rem 0 0.6rem; color: #101828; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.35rem; }
|
||||
.md-report h2 { font-size: 1.35rem; font-weight: 600; margin: 1.2rem 0 0.5rem; color: #1e293b; border-bottom: 1px solid #f1f5f9; padding-bottom: 0.25rem; }
|
||||
.md-report h3 { font-size: 1.12rem; font-weight: 600; margin: 1rem 0 0.4rem; color: #334155; }
|
||||
.md-report h4 { font-size: 1rem; font-weight: 600; margin: 0.8rem 0 0.3rem; color: #475569; }
|
||||
.md-report p { margin: 0.5rem 0; line-height: 1.7; color: #374151; }
|
||||
.md-report ul, .md-report ol { margin: 0.5rem 0 0.5rem 1.5rem; padding: 0; }
|
||||
.md-report ul { list-style-type: disc; }
|
||||
.md-report ol { list-style-type: decimal; }
|
||||
.md-report li { margin: 0.25rem 0; line-height: 1.65; color: #374151; }
|
||||
.md-report li > ul, .md-report li > ol { margin: 0.2rem 0 0.2rem 1.2rem; }
|
||||
.md-report strong { font-weight: 700; color: #111827; }
|
||||
.md-report em { font-style: italic; }
|
||||
.md-report blockquote { border-left: 4px solid #94a3b8; margin: 0.75rem 0; padding: 0.5rem 1rem; background: #f8fafc; color: #475569; border-radius: 0 4px 4px 0; }
|
||||
.md-report code { background: #f1f5f9; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.88em; color: #7c3aed; }
|
||||
.md-report pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.75rem 0; }
|
||||
.md-report pre code { background: none; color: inherit; padding: 0; font-size: 0.88em; }
|
||||
.md-report table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
|
||||
.md-report th, .md-report td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; }
|
||||
.md-report th { background: #f8fafc; font-weight: 600; color: #1e293b; }
|
||||
.md-report hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.2rem 0; }
|
||||
.md-report a { color: #2563eb; text-decoration: underline; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
_REFERENCE_RETURN_MARKERS = re.compile(r"(?:\s*(?:↩️|↩|🔙|↪️|↪)){1,}")
|
||||
|
||||
|
||||
def plain_markdown(value) -> str:
|
||||
if isinstance(value, str):
|
||||
return clean_markdown(value)
|
||||
if isinstance(value, dict):
|
||||
nested = value.get("markdown_report")
|
||||
text = nested if isinstance(nested, str) else str(nested or value)
|
||||
return clean_markdown(text)
|
||||
nested = getattr(value, "markdown_report", None)
|
||||
text = nested if isinstance(nested, str) else str(value)
|
||||
return clean_markdown(text)
|
||||
|
||||
|
||||
def clean_markdown(value: str) -> str:
|
||||
return _REFERENCE_RETURN_MARKERS.sub("", value)
|
||||
|
||||
|
||||
def markdown_to_html(value: str) -> str:
|
||||
return markdown.markdown(
|
||||
value,
|
||||
extensions=["extra", "sane_lists", "tables", NewTabLinksExtension()],
|
||||
output_format="html5",
|
||||
)
|
||||
|
||||
|
||||
class NewTabLinksTreeprocessor(Treeprocessor):
|
||||
def run(self, root):
|
||||
for element in root.iter("a"):
|
||||
element.set("target", "_blank")
|
||||
element.set("rel", "noopener noreferrer")
|
||||
return root
|
||||
|
||||
|
||||
class NewTabLinksExtension(Extension):
|
||||
def extendMarkdown(self, md):
|
||||
md.treeprocessors.register(NewTabLinksTreeprocessor(md), "new_tab_links", 15)
|
||||
@@ -128,10 +128,6 @@ def compact_json(data: Any, max_chars: int = 8000) -> str:
|
||||
return text[:max_chars] + "\n... [truncated]"
|
||||
|
||||
|
||||
def current_date_context() -> str:
|
||||
return datetime.now().strftime("%B %d, %Y")
|
||||
|
||||
|
||||
def current_year_context() -> str:
|
||||
return str(datetime.now().year)
|
||||
|
||||
@@ -341,10 +337,12 @@ manager_agent = Agent(
|
||||
name="Manager research agent",
|
||||
model=MODEL,
|
||||
instructions=(
|
||||
f"Current date: {current_date_context()}\n"
|
||||
f"Current year: {current_year_context()}\n\n"
|
||||
"You are the orchestrator for a multi-agent research assistant. You must manage the workflow, "
|
||||
"not answer from your own memory. Follow this policy exactly:\n"
|
||||
"not answer from your own memory. For current, recent, latest, ongoing, or time-sensitive "
|
||||
"topics, if the user query does not mention a year, add the current year "
|
||||
f"({current_year_context()}) to the answer_query, search_with_scrape, and search_web query text "
|
||||
"so the tools look for recent results. If the user already mentions a year, preserve that year. "
|
||||
"Do not treat older sources as sufficient when newer coverage is needed. Follow this policy exactly:\n"
|
||||
"1. Always call answer_query first to get a simple initial answer for the user's question.\n"
|
||||
"2. Immediately call judge_answer_quality on the original question plus the answer_query result. "
|
||||
"If the judge returns is_good_enough=true and score >= 0.85, stop researching and call "
|
||||
@@ -388,15 +386,7 @@ async def run_research_assistant(
|
||||
|
||||
try:
|
||||
await emit_progress("Starting manager research agent.")
|
||||
current_date = current_date_context()
|
||||
current_year = current_year_context()
|
||||
prompt = f"""
|
||||
Current date:
|
||||
{current_date}
|
||||
|
||||
Current year:
|
||||
{current_year}
|
||||
|
||||
Research question:
|
||||
{query}
|
||||
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import reflex as rx
|
||||
|
||||
from .pdf_export import markdown_to_pdf_bytes
|
||||
from .report_formatting import markdown_to_html, plain_markdown
|
||||
from .research_assistant import run_research_assistant
|
||||
|
||||
|
||||
_RUNNING_TASKS: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
class State(rx.State):
|
||||
query: str = ""
|
||||
logs: list[str] = []
|
||||
report_markdown: str = ""
|
||||
report_html: str = ""
|
||||
trace_url: str = ""
|
||||
error: str = ""
|
||||
is_running: bool = False
|
||||
status: str = "Ready"
|
||||
active_run_id: int = 0
|
||||
step_started_at: float = 0.0
|
||||
|
||||
def set_query(self, value: str) -> None:
|
||||
self.query = value
|
||||
|
||||
def _client_token(self) -> str:
|
||||
return self.router.session.client_token or "default"
|
||||
|
||||
def _cancel_active_task(self) -> bool:
|
||||
task = _RUNNING_TASKS.pop(self._client_token(), None)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear_all(self) -> None:
|
||||
self._cancel_active_task()
|
||||
self.active_run_id += 1
|
||||
self.query = ""
|
||||
self.logs = []
|
||||
self.report_markdown = ""
|
||||
self.report_html = ""
|
||||
self.trace_url = ""
|
||||
self.error = ""
|
||||
self.is_running = False
|
||||
self.status = "Ready"
|
||||
self.step_started_at = 0.0
|
||||
|
||||
def stop_report(self) -> None:
|
||||
if not self.is_running:
|
||||
return
|
||||
now = time.monotonic()
|
||||
elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
|
||||
self._cancel_active_task()
|
||||
self.is_running = False
|
||||
self.error = ""
|
||||
self.status = "Stopped"
|
||||
self.step_started_at = now
|
||||
self.logs.append(f"{elapsed:.1f}s Research stopped by user.")
|
||||
|
||||
async def _log(self, message: str) -> None:
|
||||
async with self:
|
||||
now = time.monotonic()
|
||||
elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
|
||||
self.step_started_at = now
|
||||
self.logs.append(f"{elapsed:.1f}s {message}")
|
||||
|
||||
def handle_key_down(self, key: str):
|
||||
if key == "Enter":
|
||||
return State.run_report
|
||||
|
||||
@rx.event(background=True)
|
||||
async def run_report(self):
|
||||
task = asyncio.current_task()
|
||||
run_id = 0
|
||||
async with self:
|
||||
query = self.query.strip()
|
||||
if not query:
|
||||
self.error = ""
|
||||
return
|
||||
self.active_run_id += 1
|
||||
run_id = self.active_run_id
|
||||
if task is not None:
|
||||
_RUNNING_TASKS[self._client_token()] = task
|
||||
self.logs = []
|
||||
self.report_markdown = ""
|
||||
self.report_html = ""
|
||||
self.trace_url = ""
|
||||
self.error = ""
|
||||
self.is_running = True
|
||||
self.status = "Researching"
|
||||
self.step_started_at = time.monotonic()
|
||||
|
||||
try:
|
||||
report, trace_url = await run_research_assistant(query, progress=self._log)
|
||||
async with self:
|
||||
self.report_markdown = plain_markdown(report.markdown_report)
|
||||
self.report_html = markdown_to_html(self.report_markdown)
|
||||
self.trace_url = trace_url
|
||||
self.status = "Complete"
|
||||
except asyncio.CancelledError:
|
||||
async with self:
|
||||
if self.active_run_id == run_id:
|
||||
self.error = ""
|
||||
self.status = "Stopped"
|
||||
except Exception as exc:
|
||||
async with self:
|
||||
if self.active_run_id == run_id:
|
||||
self.error = str(exc)
|
||||
self.status = "Failed"
|
||||
finally:
|
||||
async with self:
|
||||
if self.active_run_id == run_id:
|
||||
self.is_running = False
|
||||
_RUNNING_TASKS.pop(self._client_token(), None)
|
||||
|
||||
def download_markdown(self):
|
||||
if not self.report_markdown:
|
||||
return rx.window_alert("Generate a report before downloading.")
|
||||
return rx.download(data=self.report_markdown, filename="research-report.md")
|
||||
|
||||
def download_pdf(self):
|
||||
if not self.report_markdown:
|
||||
return rx.window_alert("Generate a report before downloading.")
|
||||
try:
|
||||
pdf_bytes = markdown_to_pdf_bytes(self.report_markdown)
|
||||
except ImportError:
|
||||
return rx.window_alert(
|
||||
"PDF support is not installed. Run: pip install -r requirements.txt"
|
||||
)
|
||||
except Exception as exc:
|
||||
return rx.window_alert(f"Could not generate PDF: {exc}")
|
||||
return rx.download(data=pdf_bytes, filename="research-report.pdf")
|
||||
@@ -4,3 +4,4 @@ python-dotenv>=1.2.2
|
||||
pydantic>=2.13.4
|
||||
reflex>=0.9.2
|
||||
markdown>=3.10.2
|
||||
reportlab>=4.0.4
|
||||
|
||||
Reference in New Issue
Block a user