|
8 | 8 | <link rel="icon" href="{{ url_for('static', path='openai.svg') }}">
|
9 | 9 | <link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}">
|
10 | 10 | <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> |
13 | 11 | </head>
|
14 | 12 | <body>
|
15 | 13 | <nav class="nav">
|
|
30 | 28 | {% endblock %}
|
31 | 29 | </div>
|
32 | 30 | </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> |
33 | 136 | </body>
|
34 | 137 | </html>
|
0 commit comments