From 6d5ca55900768dbd3a3f5f3bf32e3c4793314abe Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Fri, 6 Mar 2026 13:48:47 -0700 Subject: [PATCH] fixed markdown rendering by shifting it to javascript --- assets/css/markdown.css | 2 +- assets/js/app.js | 46 ++++++++++ lib/elixir_ai_web/components/chat_message.ex | 84 ++++++++++++++----- .../components/layouts/root.html.heex | 6 ++ lib/elixir_ai_web/components/markdown.ex | 23 ----- lib/elixir_ai_web/live/chat_live.ex | 12 ++- mix.exs | 1 - 7 files changed, 128 insertions(+), 46 deletions(-) delete mode 100644 lib/elixir_ai_web/components/markdown.ex diff --git a/assets/css/markdown.css b/assets/css/markdown.css index fe50705..024f539 100644 --- a/assets/css/markdown.css +++ b/assets/css/markdown.css @@ -92,7 +92,7 @@ } .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 { @apply bg-cyan-950; diff --git a/assets/js/app.js b/assets/js/app.js index 7b114fd..e93ef47 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,6 +24,52 @@ import topbar from "../vendor/topbar" 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 = { mounted() { this.scrollToBottom() diff --git a/lib/elixir_ai_web/components/chat_message.ex b/lib/elixir_ai_web/components/chat_message.ex index 5591788..b4f9eb0 100644 --- a/lib/elixir_ai_web/components/chat_message.ex +++ b/lib/elixir_ai_web/components/chat_message.ex @@ -1,6 +1,5 @@ defmodule ElixirAiWeb.ChatMessage do use Phoenix.Component - alias ElixirAiWeb.Markdown alias Phoenix.LiveView.JS attr :content, :string, required: true @@ -60,20 +59,62 @@ defmodule ElixirAiWeb.ChatMessage do attr :reasoning_content, :string, default: nil 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 - assigns = - assigns - |> assign(:_reasoning_id, "reasoning-stream") - |> assign(:_expanded, true) - ~H""" - <.message_bubble - reasoning_id={@_reasoning_id} - content={@content} - reasoning_content={@reasoning_content} - tool_calls={@tool_calls} - expanded={@_expanded} - /> +
+ +
+ <%= if @reasoning_content && @reasoning_content != "" do %> + + <% end %> +
+
+ <%= for tool_call <- @tool_calls do %> + <.tool_call_item tool_call={tool_call} /> + <% end %> +
+
""" end @@ -115,21 +156,26 @@ defmodule ElixirAiWeb.ChatMessage do
- {Markdown.render(@reasoning_content)} -
+ > <% end %> <%= for tool_call <- @tool_calls do %> <.tool_call_item tool_call={tool_call} /> <% end %> <%= if @content && @content != "" do %> -
- {Markdown.render(@content)} -
+
<% end %> """ diff --git a/lib/elixir_ai_web/components/layouts/root.html.heex b/lib/elixir_ai_web/components/layouts/root.html.heex index 87ec6ff..b74e100 100644 --- a/lib/elixir_ai_web/components/layouts/root.html.heex +++ b/lib/elixir_ai_web/components/layouts/root.html.heex @@ -10,6 +10,12 @@ + {@inner_content} diff --git a/lib/elixir_ai_web/components/markdown.ex b/lib/elixir_ai_web/components/markdown.ex deleted file mode 100644 index bbe2095..0000000 --- a/lib/elixir_ai_web/components/markdown.ex +++ /dev/null @@ -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 diff --git a/lib/elixir_ai_web/live/chat_live.ex b/lib/elixir_ai_web/live/chat_live.ex index 2aebb61..d7e13f4 100644 --- a/lib/elixir_ai_web/live/chat_live.ex +++ b/lib/elixir_ai_web/live/chat_live.ex @@ -131,7 +131,11 @@ defmodule ElixirAiWeb.ChatLive do 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 def handle_info( @@ -148,7 +152,11 @@ defmodule ElixirAiWeb.ChatLive do | 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 def handle_info(:tool_calls_finished, socket) do diff --git a/mix.exs b/mix.exs index 4003c81..b901835 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,6 @@ defmodule ElixirAi.MixProject do depth: 1}, {:req, "~> 0.5"}, {:html_sanitize_ex, "~> 1.4"}, - {:earmark, "~> 1.4"}, {:dotenvy, "~> 1.1.1"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},