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} - /> +