Skip to content

Commit 06ddac2

Browse files
Merge pull request #32 from Promptly-Technologies-LLC/22-render-markdown
Re-parse accumulated markdown after every streamed text delta
2 parents 0cc2db3 + a521c69 commit 06ddac2

File tree

4 files changed

+213
-2
lines changed

4 files changed

+213
-2
lines changed

static/stream-md.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// A global map to hold the growing markdown string per target container node
2+
window._streamingMarkdown = new WeakMap();
3+
4+
function handleSSETextDelta(evt) {
5+
// Check if this event is for a 'textDelta' message fired by sse.js
6+
const originalSSEEvent = evt.detail;
7+
if (!originalSSEEvent || originalSSEEvent.type !== 'textDelta') {
8+
return;
9+
}
10+
11+
// Prevent the default HTMX swap for this specific message
12+
// sse.js triggers this *before* calling api.swap()
13+
evt.preventDefault();
14+
15+
// The data contains the OOB swap HTML: <span hx-swap-oob="beforeend:#step-...">CHUNK</span>
16+
const oobHTML = originalSSEEvent.data;
17+
18+
// Use DOMParser to safely extract the target selector and the markdown chunk
19+
const parser = new DOMParser();
20+
const doc = parser.parseFromString(oobHTML, 'text/html');
21+
const oobElement = doc.body.firstChild;
22+
23+
if (!oobElement || !oobElement.getAttribute || oobElement.nodeType !== Node.ELEMENT_NODE) {
24+
console.error("Could not parse OOB element from SSE data:", oobHTML);
25+
return;
26+
}
27+
28+
const swapOobAttr = oobElement.getAttribute('hx-swap-oob');
29+
const markdownChunk = oobElement.textContent || '';
30+
31+
if (!swapOobAttr) {
32+
// Might be a non-OOB textDelta, handle differently or ignore?
33+
// For now, let's assume textDelta is always OOB for steps.
34+
console.warn("textDelta message did not contain hx-swap-oob:", oobHTML);
35+
return;
36+
}
37+
if (!markdownChunk) {
38+
// Empty chunk, nothing to render
39+
return;
40+
}
41+
42+
// Extract the target selector (e.g., "beforeend:#step-...") -> "#step-..."
43+
let targetSelector = swapOobAttr;
44+
const colonIndex = swapOobAttr.indexOf(':');
45+
if (colonIndex !== -1) {
46+
targetSelector = swapOobAttr.substring(colonIndex + 1);
47+
}
48+
49+
// Find the actual target element where content should be rendered
50+
const targetElement = document.querySelector(targetSelector);
51+
if (!targetElement) {
52+
console.warn("Target element for OOB swap not found:", targetSelector);
53+
return;
54+
}
55+
56+
// Use a WeakMap keyed by the *actual target* element
57+
if (!window._streamingMarkdown) {
58+
window._streamingMarkdown = new WeakMap();
59+
}
60+
61+
// 1) Accumulate markdown for the specific target
62+
const prev = window._streamingMarkdown.get(targetElement) || '';
63+
const updatedMarkdown = prev + markdownChunk;
64+
window._streamingMarkdown.set(targetElement, updatedMarkdown);
65+
66+
// 2) Re-render -> sanitize -> swap into the target
67+
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
68+
console.error("marked.js or DOMPurify not loaded.");
69+
// Fallback to raw text
70+
targetElement.textContent += markdownChunk;
71+
return;
72+
}
73+
74+
try {
75+
// Use marked.parse() for incremental updates.
76+
const rawHtml = marked.parse(updatedMarkdown);
77+
// Configure DOMPurify
78+
const sanitizedHtml = DOMPurify.sanitize(rawHtml, {
79+
// Allows standard HTML elements
80+
USE_PROFILES: { html: true }
81+
});
82+
targetElement.innerHTML = sanitizedHtml;
83+
84+
// 3) Auto-scroll the main messages container
85+
const messagesContainer = document.getElementById('messages');
86+
if (messagesContainer) {
87+
// Scroll only if the user isn't intentionally scrolled up
88+
const isScrolledToBottom = messagesContainer.scrollHeight - messagesContainer.clientHeight <= messagesContainer.scrollTop + 1; // +1 for tolerance
89+
if(isScrolledToBottom) {
90+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
91+
}
92+
}
93+
} catch (e) {
94+
console.error("Error processing markdown:", e);
95+
// Fallback on error: append raw chunk to existing text content
96+
targetElement.textContent = (targetElement.textContent || '') + markdownChunk;
97+
}
98+
}

static/styles.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ a {
3232
ul,
3333
ol {
3434
padding-left: 20px;
35+
white-space: normal;
36+
}
37+
38+
li {
39+
white-space: normal;
40+
}
41+
42+
li > ul {
43+
margin-bottom: 0;
3544
}
3645

3746
pre {

templates/components/assistant-run.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
hx-ext="sse"
44
sse-connect="/assistants/{{ assistant_id }}/messages/{{ thread_id }}/receive"
55
sse-swap="messageCreated,toolCallCreated,toolOutput,imageOutput,fileOutput,textDelta"
6+
hx-on:htmx:sse-before-message="handleSSETextDelta(event)"
67
sse-close="endStream"
78
data-assistant-id="{{ assistant_id }}"
89
data-thread-id="{{ thread_id }}">

templates/layout.html

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
<link rel="icon" href="{{ url_for('static', path='openai.svg') }}">
99
<link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}">
1010
<link rel="favicon" href="{{ url_for('static', path='favicon.png') }}">
11-
<script src="{{ url_for('static', path='htmx.min.js') }}"></script>
12-
<script src="{{ url_for('static', path='sse.js') }}"></script>
1311
</head>
1412
<body>
1513
<nav class="nav">
@@ -30,5 +28,110 @@
3028
{% endblock %}
3129
</div>
3230
</main>
31+
<script src="{{ url_for('static', path='htmx.min.js') }}"></script>
32+
<script src="{{ url_for('static', path='sse.js') }}"></script>
33+
<!-- Markdown Rendering -->
34+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
35+
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
36+
<script>
37+
// A global map to hold the growing markdown string per target container node
38+
window._streamingMarkdown = new WeakMap();
39+
40+
function handleSSETextDelta(evt) {
41+
// Check if this event is for a 'textDelta' message fired by sse.js
42+
const originalSSEEvent = evt.detail;
43+
if (!originalSSEEvent || originalSSEEvent.type !== 'textDelta') {
44+
return;
45+
}
46+
47+
// Prevent the default HTMX swap for this specific message
48+
// sse.js triggers this *before* calling api.swap()
49+
evt.preventDefault();
50+
51+
// The data contains the OOB swap HTML: <span hx-swap-oob="beforeend:#step-...">CHUNK</span>
52+
const oobHTML = originalSSEEvent.data;
53+
54+
// Use DOMParser to safely extract the target selector and the markdown chunk
55+
const parser = new DOMParser();
56+
const doc = parser.parseFromString(oobHTML, 'text/html');
57+
const oobElement = doc.body.firstChild;
58+
59+
if (!oobElement || !oobElement.getAttribute || oobElement.nodeType !== Node.ELEMENT_NODE) {
60+
console.error("Could not parse OOB element from SSE data:", oobHTML);
61+
return;
62+
}
63+
64+
const swapOobAttr = oobElement.getAttribute('hx-swap-oob');
65+
const markdownChunk = oobElement.textContent || '';
66+
67+
if (!swapOobAttr) {
68+
// Might be a non-OOB textDelta, handle differently or ignore?
69+
// For now, let's assume textDelta is always OOB for steps.
70+
console.warn("textDelta message did not contain hx-swap-oob:", oobHTML);
71+
return;
72+
}
73+
if (!markdownChunk) {
74+
// Empty chunk, nothing to render
75+
return;
76+
}
77+
78+
// Extract the target selector (e.g., "beforeend:#step-...") -> "#step-..."
79+
let targetSelector = swapOobAttr;
80+
const colonIndex = swapOobAttr.indexOf(':');
81+
if (colonIndex !== -1) {
82+
targetSelector = swapOobAttr.substring(colonIndex + 1);
83+
}
84+
85+
// Find the actual target element where content should be rendered
86+
const targetElement = document.querySelector(targetSelector);
87+
if (!targetElement) {
88+
console.warn("Target element for OOB swap not found:", targetSelector);
89+
return;
90+
}
91+
92+
// Use a WeakMap keyed by the *actual target* element
93+
if (!window._streamingMarkdown) {
94+
window._streamingMarkdown = new WeakMap();
95+
}
96+
97+
// 1) Accumulate markdown for the specific target
98+
const prev = window._streamingMarkdown.get(targetElement) || '';
99+
const updatedMarkdown = prev + markdownChunk;
100+
window._streamingMarkdown.set(targetElement, updatedMarkdown);
101+
102+
// 2) Re-render -> sanitize -> swap into the target
103+
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
104+
console.error("marked.js or DOMPurify not loaded.");
105+
// Fallback to raw text
106+
targetElement.textContent += markdownChunk;
107+
return;
108+
}
109+
110+
try {
111+
// Use marked.parse() for incremental updates.
112+
const rawHtml = marked.parse(updatedMarkdown);
113+
// Configure DOMPurify
114+
const sanitizedHtml = DOMPurify.sanitize(rawHtml, {
115+
// Allows standard HTML elements
116+
USE_PROFILES: { html: true }
117+
});
118+
targetElement.innerHTML = sanitizedHtml;
119+
120+
// 3) Auto-scroll the main messages container
121+
const messagesContainer = document.getElementById('messages');
122+
if (messagesContainer) {
123+
// Scroll only if the user isn't intentionally scrolled up
124+
const isScrolledToBottom = messagesContainer.scrollHeight - messagesContainer.clientHeight <= messagesContainer.scrollTop + 1; // +1 for tolerance
125+
if(isScrolledToBottom) {
126+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
127+
}
128+
}
129+
} catch (e) {
130+
console.error("Error processing markdown:", e);
131+
// Fallback on error: append raw chunk to existing text content
132+
targetElement.textContent = (targetElement.textContent || '') + markdownChunk;
133+
}
134+
}
135+
</script>
33136
</body>
34137
</html>

0 commit comments

Comments
 (0)