fixed markdown rendering by shifting it to javascript

This commit is contained in:
2026-03-06 13:48:47 -07:00
parent c747f1d4ce
commit 6d5ca55900
7 changed files with 128 additions and 46 deletions

View File

@@ -92,7 +92,7 @@
} }
.markdown table { .markdown table {
@apply w-full border-collapse my-4 text-sm; @apply block w-full border-collapse my-4 text-sm overflow-x-auto;
} }
.markdown thead { .markdown thead {
@apply bg-cyan-950; @apply bg-cyan-950;

View File

@@ -24,6 +24,52 @@ import topbar from "../vendor/topbar"
let Hooks = {} let Hooks = {}
// Renders a complete markdown string client-side on mount.
// The raw markdown is passed as the data-md attribute.
Hooks.MarkdownRender = {
mounted() {
const smd = window.smd
const content = this.el.dataset.md
if (!content) return
const parser = smd.parser(smd.default_renderer(this.el))
smd.parser_write(parser, content)
smd.parser_end(parser)
}
}
// Streams markdown chunks into the element using the streaming-markdown parser.
// The server sends push_event(socket, eventName, %{chunk: "..."}) for each chunk.
// data-event on the element controls which event this hook listens for.
// The server sends push_event(socket, eventName, %{chunk: "..."}) for each chunk.
// data-event on the element controls which event this hook listens for.
Hooks.MarkdownStream = {
mounted() {
const smd = window.smd
const DOMPurify = window.DOMPurify
this._chunks = ""
this._parser = smd.parser(smd.default_renderer(this.el))
const eventName = this.el.dataset.event
this.handleEvent(eventName, ({chunk}) => {
this._chunks += chunk
// Sanitize all accumulated chunks to detect injection attacks.
DOMPurify.sanitize(this._chunks)
if (DOMPurify.removed.length > 0) {
// Insecure content detected — stop rendering immediately.
smd.parser_end(this._parser)
this._parser = null
return
}
if (this._parser) smd.parser_write(this._parser, chunk)
})
},
destroyed() {
if (this._parser) {
window.smd.parser_end(this._parser)
this._parser = null
}
}
}
Hooks.ScrollBottom = { Hooks.ScrollBottom = {
mounted() { mounted() {
this.scrollToBottom() this.scrollToBottom()

View File

@@ -1,6 +1,5 @@
defmodule ElixirAiWeb.ChatMessage do defmodule ElixirAiWeb.ChatMessage do
use Phoenix.Component use Phoenix.Component
alias ElixirAiWeb.Markdown
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
attr :content, :string, required: true attr :content, :string, required: true
@@ -60,20 +59,62 @@ defmodule ElixirAiWeb.ChatMessage do
attr :reasoning_content, :string, default: nil attr :reasoning_content, :string, default: nil
attr :tool_calls, :list, default: [] attr :tool_calls, :list, default: []
# Renders the in-progress streaming message. Content and reasoning are rendered
# entirely client-side via the MarkdownStream hook — the server sends push_event
# chunks instead of re-rendering the full markdown on every token.
def streaming_assistant_message(assigns) do def streaming_assistant_message(assigns) do
assigns =
assigns
|> assign(:_reasoning_id, "reasoning-stream")
|> assign(:_expanded, true)
~H""" ~H"""
<.message_bubble <div class="mb-2 text-sm text-left">
reasoning_id={@_reasoning_id} <!-- Reasoning section — only shown once reasoning_content is non-empty.
content={@content} The div is always in the DOM so the hook mounts before chunks arrive. -->
reasoning_content={@reasoning_content} <div id="stream-reasoning-wrap">
tool_calls={@tool_calls} <%= if @reasoning_content && @reasoning_content != "" do %>
expanded={@_expanded} <button
type="button"
class="flex items-center text-cyan-500/60 hover:text-cyan-300 transition-colors duration-150 cursor-pointer"
phx-click={
JS.toggle_class("collapsed", to: "#reasoning-stream")
|> JS.toggle_class("rotate-180", to: "#reasoning-stream-chevron")
}
aria-label="Toggle reasoning"
>
<div class="flex items-center gap-1 text-cyan-100/40 ps-2 mb-1">
<span class="text-xs">reasoning</span>
<svg
id="reasoning-stream-chevron"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 transition-transform duration-300"
>
<path
fill-rule="evenodd"
d="M9.47 6.47a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 1 1-1.06 1.06L10 8.06l-3.72 3.72a.75.75 0 0 1-1.06-1.06l4.25-4.25Z"
clip-rule="evenodd"
/> />
</svg>
</div>
</button>
<% end %>
<div
id="reasoning-stream"
phx-hook="MarkdownStream"
phx-update="ignore"
data-event="reasoning_chunk"
class="reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs max-w-prose mb-1 markdown"
></div>
</div>
<%= for tool_call <- @tool_calls do %>
<.tool_call_item tool_call={tool_call} />
<% end %>
<div
id="stream-content"
phx-hook="MarkdownStream"
phx-update="ignore"
data-event="md_chunk"
class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50"
></div>
</div>
""" """
end end
@@ -115,21 +156,26 @@ defmodule ElixirAiWeb.ChatMessage do
</button> </button>
<div <div
id={@reasoning_id} id={@reasoning_id}
phx-hook="MarkdownRender"
phx-update="ignore"
data-md={@reasoning_content}
class={[ class={[
"reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs max-w-prose mb-1 markdown", "reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs max-w-prose mb-1 markdown",
!@expanded && "collapsed" !@expanded && "collapsed"
]} ]}
> ></div>
{Markdown.render(@reasoning_content)}
</div>
<% end %> <% end %>
<%= for tool_call <- @tool_calls do %> <%= for tool_call <- @tool_calls do %>
<.tool_call_item tool_call={tool_call} /> <.tool_call_item tool_call={tool_call} />
<% end %> <% end %>
<%= if @content && @content != "" do %> <%= if @content && @content != "" do %>
<div class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50"> <div
{Markdown.render(@content)} id={"#{@reasoning_id}-content"}
</div> phx-hook="MarkdownRender"
phx-update="ignore"
data-md={@content}
class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50"
></div>
<% end %> <% end %>
</div> </div>
""" """

View File

@@ -10,6 +10,12 @@
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script> </script>
<script type="module">
import * as smd from "https://cdn.jsdelivr.net/npm/streaming-markdown/smd.min.js"
import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify/+esm"
window.smd = smd
window.DOMPurify = DOMPurify
</script>
</head> </head>
<body class="bg-cyan-900 text-cyan-50"> <body class="bg-cyan-900 text-cyan-50">
{@inner_content} {@inner_content}

View File

@@ -1,23 +0,0 @@
defmodule ElixirAiWeb.Markdown do
@doc """
Converts a markdown string to sanitized HTML, safe for raw rendering.
Falls back to escaped plain text if the markdown is incomplete or invalid.
"""
def render(nil), do: Phoenix.HTML.raw("")
def render(""), do: Phoenix.HTML.raw("")
def render(markdown) do
case Earmark.as_html(markdown, breaks: true, compact_output: true) do
{:ok, html, _warnings} ->
html
|> HtmlSanitizeEx.markdown_html()
|> Phoenix.HTML.raw()
{:error, html, _errors} ->
# Partial/invalid markdown — use whatever HTML was produced, still sanitize
html
|> HtmlSanitizeEx.markdown_html()
|> Phoenix.HTML.raw()
end
end
end

View File

@@ -131,7 +131,11 @@ defmodule ElixirAiWeb.ChatLive do
socket.assigns.streaming_response.reasoning_content <> reasoning_content socket.assigns.streaming_response.reasoning_content <> reasoning_content
} }
{:noreply, assign(socket, streaming_response: updated_response)} # Update assign (controls toggle button visibility) and stream chunk to hook.
{:noreply,
socket
|> assign(streaming_response: updated_response)
|> push_event("reasoning_chunk", %{chunk: reasoning_content})}
end end
def handle_info( def handle_info(
@@ -148,7 +152,11 @@ defmodule ElixirAiWeb.ChatLive do
| content: socket.assigns.streaming_response.content <> text_content | content: socket.assigns.streaming_response.content <> text_content
} }
{:noreply, assign(socket, streaming_response: updated_response)} # Update assign (accumulated for final message) and stream chunk to hook.
{:noreply,
socket
|> assign(streaming_response: updated_response)
|> push_event("md_chunk", %{chunk: text_content})}
end end
def handle_info(:tool_calls_finished, socket) do def handle_info(:tool_calls_finished, socket) do

View File

@@ -49,7 +49,6 @@ defmodule ElixirAi.MixProject do
depth: 1}, depth: 1},
{:req, "~> 0.5"}, {:req, "~> 0.5"},
{:html_sanitize_ex, "~> 1.4"}, {:html_sanitize_ex, "~> 1.4"},
{:earmark, "~> 1.4"},
{:dotenvy, "~> 1.1.1"}, {:dotenvy, "~> 1.1.1"},
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},