Feature/pdf to markdown agent (#6271)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
EthanHealy01
2026-05-14 16:20:45 +00:00
committed by GitHub
co-authored by James Brunton
parent 5b9ef852ab
commit ece1bb6865
36 changed files with 4081 additions and 267 deletions
@@ -0,0 +1,108 @@
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() =>
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
}
style={{
position: "absolute",
top: 8,
right: 8,
background: copied
? "var(--mantine-color-green-0)"
: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 3,
cursor: "pointer",
fontSize: "0.7em",
padding: "2px 8px",
color: copied
? "var(--mantine-color-green-7)"
: "var(--mantine-color-gray-7)",
}}
>
{copied ? "✓ Copied" : "Copy"}
</button>
);
}
const components: Components = {
pre: ({ children }) => {
const codeText = React.isValidElement(children)
? String((children.props as { children?: unknown }).children ?? "")
: String(children ?? "");
return (
<div style={{ position: "relative", margin: "8px 0" }}>
<pre
style={{
background: "var(--mantine-color-gray-1)",
padding: "10px 52px 10px 14px",
borderRadius: 4,
overflowX: "auto",
fontSize: "0.85em",
margin: 0,
}}
>
{children}
</pre>
<CopyButton text={codeText} />
</div>
);
},
table: ({ children }) => (
<div style={{ overflowX: "auto", margin: "10px 0" }}>
<table
style={{
borderCollapse: "collapse",
width: "100%",
fontSize: "0.85em",
}}
>
{children}
</table>
</div>
),
th: ({ children, style }) => (
<th
style={{
border: "1px solid var(--mantine-color-gray-3)",
padding: "6px 10px",
background: "var(--mantine-color-gray-1)",
textAlign: "left",
fontWeight: 600,
whiteSpace: "nowrap",
...style,
}}
>
{children}
</th>
),
td: ({ children, style }) => (
<td
style={{
border: "1px solid var(--mantine-color-gray-3)",
padding: "5px 10px",
...style,
}}
>
{children}
</td>
),
};
export function renderMarkdown(content: string): React.ReactNode[] {
return [
<ReactMarkdown key="md" remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>,
];
}
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Center,
@@ -12,153 +12,7 @@ import {
import { useTranslation } from "react-i18next";
import { formatFileSize } from "@app/utils/fileUtils";
// ─── Markdown renderer ────────────────────────────────────────────────────────
function renderInline(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
// Patterns: **bold**, *italic*, `code`, [link](url)
const re = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
let last = 0;
let match;
let key = 0;
while ((match = re.exec(text)) !== null) {
if (match.index > last) parts.push(text.slice(last, match.index));
if (match[2]) parts.push(<strong key={key++}>{match[2]}</strong>);
else if (match[3]) parts.push(<em key={key++}>{match[3]}</em>);
else if (match[4])
parts.push(
<code
key={key++}
style={{
background: "var(--mantine-color-gray-1)",
padding: "0 3px",
borderRadius: 3,
fontFamily: "monospace",
fontSize: "0.85em",
}}
>
{match[4]}
</code>,
);
else if (match[5])
parts.push(
<a
key={key++}
href={match[6]}
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--mantine-color-blue-6)" }}
>
{match[5]}
</a>,
);
last = match.index + match[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
function renderMarkdown(text: string): React.ReactNode[] {
const lines = text.split("\n");
const elements: React.ReactNode[] = [];
let inCodeBlock = false;
let codeLines: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith("```")) {
if (inCodeBlock) {
elements.push(
<pre
key={i}
style={{
background: "var(--mantine-color-gray-1)",
padding: "8px 12px",
borderRadius: 4,
overflowX: "auto",
fontSize: "0.85em",
margin: "4px 0",
}}
>
<code>{codeLines.join("\n")}</code>
</pre>,
);
codeLines = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
}
i++;
continue;
}
if (inCodeBlock) {
codeLines.push(line);
i++;
continue;
}
if (line.startsWith("### ")) {
elements.push(
<Text key={i} fw={600} size="md" mt="xs" mb={2}>
{renderInline(line.slice(4))}
</Text>,
);
} else if (line.startsWith("## ")) {
elements.push(
<Text key={i} fw={700} size="lg" mt="sm" mb={4}>
{renderInline(line.slice(3))}
</Text>,
);
} else if (line.startsWith("# ")) {
elements.push(
<Text key={i} fw={800} size="xl" mt="md" mb={6}>
{renderInline(line.slice(2))}
</Text>,
);
} else if (line.startsWith("- ") || line.startsWith("* ")) {
elements.push(
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>
{"\u2022"}
</Text>
<Text size="sm" style={{ lineHeight: 1.6 }}>
{renderInline(line.slice(2))}
</Text>
</Group>,
);
} else if (/^\d+\.\s/.test(line)) {
const num = line.match(/^(\d+)\.\s/)?.[1];
const rest = line.replace(/^\d+\.\s/, "");
elements.push(
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>
{num}.
</Text>
<Text size="sm" style={{ lineHeight: 1.6 }}>
{renderInline(rest)}
</Text>
</Group>,
);
} else if (line.trim() === "" || line === "---" || line === "***") {
elements.push(<Box key={i} style={{ height: 8 }} />);
} else {
elements.push(
<Text key={i} size="sm" style={{ lineHeight: 1.7 }}>
{renderInline(line)}
</Text>,
);
}
i++;
}
return elements;
}
// ─── Text / Markdown viewer ───────────────────────────────────────────────────
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
interface TextViewerProps {
file: File;
@@ -176,6 +30,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
}, [file]);
const lines = content?.split("\n") ?? [];
const renderedMarkdown = useMemo(
() =>
content !== null && isMarkdown && renderMd
? renderMarkdown(content)
: null,
[content, isMarkdown, renderMd],
);
return (
<Stack gap={0} style={{ height: "100%", flex: 1 }}>
@@ -222,9 +83,17 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
{t("viewer.nonPdf.loading")}
</Text>
</Center>
) : isMarkdown && renderMd ? (
<Box style={{ maxWidth: 800, margin: "0 auto", padding: "8px 0" }}>
{renderMarkdown(content)}
) : renderedMarkdown !== null ? (
<Box
style={{
maxWidth: 800,
margin: "0 auto",
padding: "20px 28px",
background: "#ffffff",
borderRadius: 6,
}}
>
{renderedMarkdown}
</Box>
) : (
<Box
@@ -240,8 +109,8 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
width: "100%",
}}
>
{lines.map((line, i) => (
<Box key={i} component="div" style={{ display: "table-row" }}>
{lines.map((line, idx) => (
<Box key={idx} component="div" style={{ display: "table-row" }}>
{showLineNumbers && (
<Box
component="span"
@@ -256,7 +125,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
minWidth: `${String(lines.length).length + 1}ch`,
}}
>
{i + 1}
{idx + 1}
</Box>
)}
<Box
@@ -266,7 +135,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
paddingLeft: showLineNumbers ? 12 : 0,
}}
>
{line || "\u00A0"}
{line || " "}
</Box>
</Box>
))}