Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 102 additions & 38 deletions apps/api/src/synthora/api/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@
body {{ font-family: Georgia, serif; max-width: 46rem; margin: 3rem auto; line-height: 1.6; color: #1c2a30; padding: 0 1.5rem; }}
h1, h2, h3 {{ font-family: Georgia, serif; letter-spacing: -0.01em; }}
code {{ font-family: ui-monospace, monospace; background: #f3f1ea; padding: 0.1em 0.3em; border-radius: 3px; }}
pre {{ background: #f3f1ea; padding: 0.75rem 1rem; overflow-x: auto; border-radius: 4px; }}
pre code {{ background: transparent; padding: 0; }}
a {{ color: #0e6f6a; }}
blockquote {{ border-left: 3px solid #0e6f6a; margin-left: 0; padding-left: 1rem; color: #3d5059; }}
table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
th, td {{ border: 1px solid #d8d2c4; padding: 0.35rem 0.6rem; text-align: left; }}
th {{ background: #f3f1ea; }}
hr {{ border: none; border-top: 1px solid #d8d2c4; margin: 1.5rem 0; }}
@media print {{ body {{ margin: 0.5in; max-width: none; }} }}
</style>
</head>
Expand All @@ -27,38 +33,114 @@


def markdown_to_html(markdown: str) -> str:
"""Minimal dependency-free Markdown -> HTML for report export.
"""Dependency-free Markdown -> HTML for report export.

Covers the subset our writers emit: headings, paragraphs, lists,
bold/italic, inline code, and links.
Covers headings, paragraphs, bullet/ordered lists, blockquotes, fenced
code blocks, tables, bold/italic, inline code, and links.
"""
lines = markdown.splitlines()
out: list[str] = []
in_list = False
for line in lines:
i = 0
list_kind: str | None = None # "ul" | "ol"

def close_list() -> None:
nonlocal list_kind
if list_kind:
out.append(f"</{list_kind}>")
list_kind = None

while i < len(lines):
line = lines[i]
stripped = line.strip()

if stripped.startswith("```"):
close_list()
fence = stripped[3:].strip()
i += 1
code_lines: list[str] = []
while i < len(lines) and not lines[i].strip().startswith("```"):
code_lines.append(lines[i])
i += 1
if i < len(lines):
i += 1
code = html_lib.escape("\n".join(code_lines))
lang = html_lib.escape(fence) if fence else ""
cls = f' class="language-{lang}"' if lang else ""
out.append(f"<pre><code{cls}>{code}</code></pre>")
continue

if stripped.startswith("|") and "|" in stripped[1:]:
close_list()
table_rows: list[str] = []
while i < len(lines) and lines[i].strip().startswith("|"):
table_rows.append(lines[i].strip())
i += 1
if len(table_rows) >= 2 and re.match(r"^\|[-: |]+\|$", table_rows[1]):
table_rows.pop(1)
if table_rows:
out.append("<table>")
for row_idx, row in enumerate(table_rows):
cells = [c.strip() for c in row.strip("|").split("|")]
tag = "th" if row_idx == 0 else "td"
out.append("<tr>")
for cell in cells:
out.append(f"<{tag}>{_inline(cell)}</{tag}>")
out.append("</tr>")
out.append("</table>")
continue

heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if heading:
if in_list:
out.append("</ul>")
in_list = False
close_list()
level = len(heading.group(1))
out.append(f"<h{level}>{_inline(heading.group(2))}</h{level}>")
i += 1
continue

if re.match(r"^[-*]{3,}$", stripped):
close_list()
out.append("<hr />")
i += 1
continue

if stripped.startswith(">"):
close_list()
quote_lines: list[str] = []
while i < len(lines) and lines[i].strip().startswith(">"):
quote_lines.append(lines[i].strip().lstrip(">").lstrip())
i += 1
out.append(
f"<blockquote><p>{_inline(' '.join(quote_lines))}</p></blockquote>"
)
continue

ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped)
if ordered:
if list_kind != "ol":
close_list()
out.append("<ol>")
list_kind = "ol"
out.append(f"<li>{_inline(ordered.group(2))}</li>")
i += 1
continue

if stripped.startswith(("- ", "* ")):
if not in_list:
if list_kind != "ul":
close_list()
out.append("<ul>")
in_list = True
list_kind = "ul"
out.append(f"<li>{_inline(stripped[2:])}</li>")
i += 1
continue
if in_list:
out.append("</ul>")
in_list = False

close_list()
if not stripped:
i += 1
continue
out.append(f"<p>{_inline(stripped)}</p>")
if in_list:
out.append("</ul>")
i += 1

close_list()
return "\n".join(out)


Expand All @@ -79,26 +161,17 @@ def render_html_document(markdown: str, *, title: str) -> str:
)


def _plain_from_markdown(markdown: str) -> str:
"""Strip common markdown markers for PDF core fonts."""
text = re.sub(r"^#{1,6}\s+", "", markdown, flags=re.MULTILINE)
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\[([^\]]+)\]\((https?://[^)]+)\)", r"\1 (\2)", text)
return text


def _pdf_safe(text: str) -> str:
return text.encode("latin-1", "replace").decode("latin-1")


def markdown_to_pdf_bytes(markdown: str, *, title: str = "Report") -> bytes:
"""Render markdown to PDF bytes using fpdf2 (no network)."""
"""Render markdown to PDF bytes using fpdf2 HTML layout (no network)."""
from fpdf import FPDF
from fpdf.enums import XPos, YPos

pdf = FPDF()
pdf.set_compression(False)
pdf.set_auto_page_break(auto=True, margin=15)
pdf.set_margins(15, 15, 15)
pdf.add_page()
Expand All @@ -112,17 +185,8 @@ def markdown_to_pdf_bytes(markdown: str, *, title: str = "Report") -> bytes:
)
pdf.ln(4)
pdf.set_font("Helvetica", size=11)
body = _plain_from_markdown(markdown or "")
for line in body.splitlines() or [""]:
if not line.strip():
pdf.ln(6)
continue
pdf.multi_cell(
0,
6,
_pdf_safe(line),
new_x=XPos.LMARGIN,
new_y=YPos.NEXT,
)
body_html = markdown_to_html(markdown or "")
if body_html.strip():
pdf.write_html(_pdf_safe(body_html))
out = pdf.output()
return bytes(out)
13 changes: 13 additions & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,19 @@ export const api = {
body: JSON.stringify({ query, max_results: maxResults }),
},
),

health: () => request<{ status: string }>("/health"),
ready: () => request<{ status: string }>("/ready"),
mcpToolsList: () =>
request<{ tools: Array<Record<string, unknown>> }>(
"/api/v1/mcp/tools/list",
{ method: "POST", body: "{}" },
),
mcpToolsCall: (name: string, args: Record<string, unknown> = {}) =>
request<{ content: string }>("/api/v1/mcp/tools/call", {
method: "POST",
body: JSON.stringify({ name, arguments: args }),
}),
};

export function eventsSocketUrl(runId: string): string {
Expand Down
64 changes: 59 additions & 5 deletions apps/web/src/components/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@ import { api, RunSummary, SessionSummary } from "../api";
export function History({ onOpen }: { onOpen: (runId: string) => void }) {
const [runs, setRuns] = useState<RunSummary[]>([]);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);

const load = useCallback(() => {
const loadRuns = useCallback((sessionFilter?: string | null) => {
const filter =
sessionFilter !== undefined ? sessionFilter : selectedSessionId;
api
.listRuns()
.listRuns(filter || undefined)
.then(setRuns)
.catch((e) => setError(String(e)));
}, [selectedSessionId]);

const loadSessions = useCallback(() => {
api
.listSessions()
.then(setSessions)
Expand All @@ -21,8 +29,9 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) {
}, []);

useEffect(() => {
load();
}, [load]);
loadRuns();
loadSessions();
}, [loadRuns, loadSessions]);

async function handleDelete(runId: string, e: MouseEvent) {
e.stopPropagation();
Expand All @@ -46,6 +55,10 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) {
try {
await api.deleteSession(sessionId);
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
if (selectedSessionId === sessionId) {
setSelectedSessionId(null);
loadRuns(null);
}
} catch (err) {
setError(String(err));
}
Expand All @@ -65,6 +78,22 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) {
}
}

async function handleSelectSession(sessionId: string) {
setError(null);
if (selectedSessionId === sessionId) {
setSelectedSessionId(null);
loadRuns(null);
return;
}
setSelectedSessionId(sessionId);
try {
const detail = await api.getSession(sessionId);
setRuns(detail.runs);
} catch (err) {
setError(String(err));
}
}

function sessionTitle(id: string | null): string {
if (!id) return "—";
const match = sessions.find((s) => s.id === id);
Expand All @@ -75,6 +104,18 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) {
<section className="panel">
<div className="action-row">
<h2 style={{ margin: 0, flex: 1 }}>Research history</h2>
{selectedSessionId && (
<button
type="button"
className="ghost"
onClick={() => {
setSelectedSessionId(null);
loadRuns(null);
}}
>
Clear session filter
</button>
)}
{runs.length > 0 && (
<button
type="button"
Expand All @@ -86,6 +127,12 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) {
</button>
)}
</div>
{selectedSessionId && (
<p className="muted">
Showing runs for session:{" "}
<strong>{sessionTitle(selectedSessionId)}</strong>
</p>
)}
{error && <p className="error-text">{error}</p>}
{runs.length === 0 && !error && <p>No research yet.</p>}
{runs.length > 0 && (
Expand Down Expand Up @@ -143,7 +190,14 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) {
{sessions.map((s) => (
<li key={s.id} className="discourse-turn">
<div className="discourse-meta">
<strong>{s.title}</strong>
<button
type="button"
className="ghost"
aria-pressed={selectedSessionId === s.id}
onClick={() => handleSelectSession(s.id)}
>
<strong>{s.title}</strong>
</button>
<span className="muted">
{" "}
· <code>{s.id.slice(0, 8)}…</code>
Expand Down
7 changes: 6 additions & 1 deletion docs/feature-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ See also [parity-audit.md](parity-audit.md).
| Search strategy abstraction (5 strategies + aliases) | ✅ | `strategy_registry` |
| Search engine abstraction (full catalog) | ✅ | 29 registered engines |
| LLM provider abstraction + think-tag handling | ✅ | 11 providers |
| Research history + export md/html/pdf | ✅ | API + web buttons |
| Research history + export md/html/pdf | ✅ | API + web buttons; structured HTML/PDF export |
| Docker Compose self-host | ✅ | `docker-compose.yml` |
| Python SDK | ✅ | `packages/sdk` — full REST mirror incl. upload, export download, news GET |
| Document library + RAG (`collection` engine) | ✅ | documents API + `document_index` |
Expand Down Expand Up @@ -125,6 +125,11 @@ bytes), ``get_news_subscription``, ``search_documents(max_results=...)``,
``health``/``ready``; web client ``getNewsSubscription`` and search
``max_results``; streamable MCP workspace isolation test.

Closed on ``feat/export-and-web-parity``: full markdown export subset (fenced
code, blockquotes, ordered lists, tables, rules); PDF via fpdf2 ``write_html``
(preserves structure); web ``health``/``ready``/MCP REST wrappers; History
session drill-down via ``getSession``.

No known functional gaps remain beyond explicit non-goals below.

Chat remains session-scoped ``fast_research`` with prior-report memory —
Expand Down
Loading
Loading