Skip to content

Commit f6d7db8

Browse files
committed
Improve frontend markdown rendering
1 parent 684626f commit f6d7db8

5 files changed

Lines changed: 83 additions & 2 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v0.0.43
1+
v0.0.44

frontend/static/app.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,16 @@ button {
568568
border-radius: 14px;
569569
}
570570

571+
.mermaid-chart {
572+
margin: 0.85rem 0;
573+
overflow-x: auto;
574+
}
575+
576+
.mermaid-chart svg {
577+
max-width: 100%;
578+
height: auto;
579+
}
580+
571581
.markdown-body table {
572582
width: 100%;
573583
border-collapse: collapse;

frontend/static/app.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
var currentWorkspacePath = "";
161161
var fileToken = "";
162162
var defaultPromptPlaceholder = promptInput.getAttribute("placeholder") || "Message ResearchHarness";
163+
var mermaidCounter = 0;
163164

164165
function escapeHtml(value) {
165166
return String(value || "")
@@ -204,6 +205,12 @@
204205
return template.innerHTML;
205206
}
206207

208+
function unwrapFullMarkdownFence(text) {
209+
var source = String(text || "").trim();
210+
var match = /^(```|~~~)[ \t]*(markdown|md|gfm)[^\n]*\n([\s\S]*?)\n\1[ \t]*$/i.exec(source);
211+
return match ? match[3] : text;
212+
}
213+
207214
function renderMathInMarkdown(container) {
208215
if (!window.renderMathInElement) return;
209216
container.querySelectorAll(".markdown-body").forEach(function (body) {
@@ -223,13 +230,37 @@
223230
});
224231
}
225232

233+
function renderMermaidInMarkdown(container) {
234+
if (!window.mermaid) return;
235+
try {
236+
window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict" });
237+
} catch (e) {
238+
console.warn("Mermaid initialization failed.", e);
239+
return;
240+
}
241+
container.querySelectorAll(".markdown-body pre code.language-mermaid").forEach(function (code) {
242+
var pre = code.closest("pre");
243+
if (!pre) return;
244+
var source = code.textContent || "";
245+
var target = document.createElement("div");
246+
var id = "rh-mermaid-" + (++mermaidCounter);
247+
target.className = "mermaid-chart";
248+
window.mermaid.render(id, source).then(function (result) {
249+
target.innerHTML = result.svg || "";
250+
pre.replaceWith(target);
251+
}).catch(function (e) {
252+
console.warn("Mermaid rendering failed.", e);
253+
});
254+
});
255+
}
256+
226257
function renderMarkdown(text) {
227258
if (!window.marked || !window.DOMPurify) {
228259
console.warn("Markdown renderer unavailable; falling back to plain text.");
229260
return "<pre>" + escapeHtml(text) + "</pre>";
230261
}
231262
try {
232-
var protectedMath = protectMathSegments(text);
263+
var protectedMath = protectMathSegments(unwrapFullMarkdownFence(text));
233264
var rawHtml = window.marked.parse(protectedMath.text, { gfm: true, breaks: false, async: false });
234265
rawHtml = rewriteWorkspaceImageSources(rawHtml);
235266
var safeHtml = window.DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } });
@@ -464,6 +495,7 @@
464495
});
465496
timeline.appendChild(node);
466497
renderMathInMarkdown(node);
498+
renderMermaidInMarkdown(node);
467499
setEventExpanded(node, true, false);
468500
scrollTimeline(shouldFollow);
469501
}

frontend/static/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ <h2 id="workspaceModalTitle">Open workspace</h2>
9494
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
9595
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
9696
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
97+
<script src="https://cdn.jsdelivr.net/npm/mermaid@11.12.0/dist/mermaid.min.js"></script>
9798
<script src="/static/app.js"></script>
9899
</body>
99100
</html>

tests/test_frontend_checks.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@
55
from dataclasses import asdict, dataclass
66
from pathlib import Path
77

8+
from fastapi.testclient import TestClient
9+
810
ROOT = Path(__file__).resolve().parents[1]
911
if str(ROOT) not in sys.path:
1012
sys.path.insert(0, str(ROOT))
1113

1214
from frontend.local_server import (
15+
app,
1316
configure_frontend,
1417
decode_image_data_url,
1518
save_uploaded_images,
19+
_register_file_workspace,
1620
_resolve_existing_workspace,
1721
_resolve_workspace_file_path,
22+
_unregister_file_workspace,
1823
_workspace_directory_payload,
1924
)
2025

@@ -123,6 +128,28 @@ def test_frontend_workspace_file_paths_are_scoped_to_workspace(tmp_path: Path) -
123128
raise AssertionError("expected non-image file to be rejected")
124129

125130

131+
def test_frontend_workspace_file_endpoint_serves_only_scoped_images(tmp_path: Path) -> None:
132+
image_path = tmp_path / "outputs" / "demo image.png"
133+
image_path.parent.mkdir()
134+
image_path.write_bytes(b"png-bytes")
135+
outside = tmp_path.parent / f"outside-{tmp_path.name}.png"
136+
outside.write_bytes(b"outside")
137+
token = _register_file_workspace(tmp_path)
138+
client = TestClient(app)
139+
try:
140+
for path in ("outputs/demo image.png", "outputs/demo%20image.png", str(image_path)):
141+
response = client.get("/api/workspace-file", params={"token": token, "path": path})
142+
assert response.status_code == 200
143+
assert response.content == b"png-bytes"
144+
outside_response = client.get("/api/workspace-file", params={"token": token, "path": str(outside)})
145+
assert outside_response.status_code == 403
146+
missing_token_response = client.get("/api/workspace-file", params={"token": "missing", "path": "outputs/demo image.png"})
147+
assert missing_token_response.status_code == 404
148+
finally:
149+
_unregister_file_workspace(token)
150+
outside.unlink(missing_ok=True)
151+
152+
126153
def test_frontend_configures_trace_dir(tmp_path: Path) -> None:
127154
trace_dir = tmp_path / "frontend-traces"
128155
configure_frontend(role_prompt="Extra role guidance.", trace_dir=str(trace_dir))
@@ -199,6 +226,12 @@ def test_frontend_static_interaction_contract() -> None:
199226
assert 'addMessage("user", answer, [])' in js
200227
assert "function renderMarkdown(text)" in js
201228
assert "rewriteWorkspaceImageSources" in js
229+
assert "unwrapFullMarkdownFence" in js
230+
assert "markdown|md|gfm" in js
231+
assert "renderMermaidInMarkdown" in js
232+
assert "language-mermaid" in js
233+
assert "securityLevel: \"strict\"" in js
234+
assert "renderMermaidInMarkdown(node)" in js
202235
assert "/api/workspace-file" in js
203236
assert '"file_token"' in server
204237
assert "window.marked.parse" in js
@@ -214,6 +247,7 @@ def test_frontend_static_interaction_contract() -> None:
214247
assert 'class="space-links"' in html
215248
assert "marked@15.0.12/marked.min.js" in html
216249
assert "dompurify@3.2.6/dist/purify.min.js" in html
250+
assert "mermaid@11.12.0/dist/mermaid.min.js" in html
217251
assert 'eventNode.classList.add("collapsed")' not in js
218252
assert 'node.classList.contains("latest")' in js
219253
assert "event.isComposing" in js
@@ -240,6 +274,7 @@ def test_frontend_static_interaction_contract() -> None:
240274
assert ".event.collapsed .event-body-inner::after" not in css
241275
assert ".markdown-body" in css
242276
assert ".markdown-body table" in css
277+
assert ".mermaid-chart" in css
243278
assert ".event.can-collapse" in css
244279
assert ".event:not(.can-collapse) .event-toggle" in css
245280
assert ".space-links" in css
@@ -269,6 +304,9 @@ def main() -> int:
269304
with tempfile.TemporaryDirectory() as tmp:
270305
test_frontend_workspace_file_paths_are_scoped_to_workspace(Path(tmp))
271306
outputs.append("workspace image path scoping: ok")
307+
with tempfile.TemporaryDirectory() as tmp:
308+
test_frontend_workspace_file_endpoint_serves_only_scoped_images(Path(tmp))
309+
outputs.append("workspace image endpoint: ok")
272310
with tempfile.TemporaryDirectory() as tmp:
273311
test_frontend_configures_trace_dir(Path(tmp))
274312
outputs.append("frontend trace-dir config: ok")

0 commit comments

Comments
 (0)