Use MDEx streaming to render chat content

This commit is contained in:
Claudio Ortolina
2026-03-20 23:42:10 +00:00
parent de8a0a044a
commit 56d9d6bfd7
4 changed files with 84 additions and 13 deletions
+1 -1
View File
@@ -296,7 +296,7 @@ All authenticated routes live inside a single `live_session` with three `on_moun
| Module | Purpose |
|--------|---------|
| `ErrorMessages` | Maps internal error terms (atoms, structs) to user-friendly gettext strings via `friendly_message/1` |
| `Markdown` | Markdown-to-HTML conversion (MDEx with ammonia sanitization) with `[[double bracket]]` link syntax |
| `Markdown` | Markdown-to-HTML conversion (MDEx with ammonia sanitization) with `[[double bracket]]` link syntax and streaming document support for chat |
| `Duration` | Milliseconds to human-readable duration formatting |
| `Auth` | Authentication plugs: login password check, API token validation, session enforcement |
| `LiveHelpers.Params` | Pagination param parsing from URL query params |
+16 -5
View File
@@ -19,12 +19,18 @@ defmodule MusicLibraryWeb.Components.Chat do
|> assign(:view, :active)
|> assign(:chat, nil)
|> assign(:chats, [])
|> assign(:streaming_doc, nil)
|> assign(:has_history, false)}
end
@impl true
def update(%{chunk: chunk}, socket) do
{:ok, update(socket, :current_response, &(&1 <> chunk))}
doc = socket.assigns.streaming_doc || Markdown.new_streaming_doc()
{:ok,
socket
|> update(:current_response, &(&1 <> chunk))
|> assign(:streaming_doc, MDEx.Document.put_markdown(doc, chunk))}
end
def update(%{done: true}, socket) do
@@ -36,6 +42,7 @@ defmodule MusicLibraryWeb.Components.Chat do
socket
|> update(:messages, &(&1 ++ [completed_message]))
|> assign(:current_response, "")
|> assign(:streaming_doc, nil)
|> assign(:loading, false)}
end
@@ -207,7 +214,7 @@ defmodule MusicLibraryWeb.Components.Chat do
phx-hook=".ScrollBottom"
>
<div
:if={@messages == [] and @current_response == ""}
:if={@messages == [] and @streaming_doc == nil}
class="flex h-full flex-col items-center justify-center text-center text-zinc-500 dark:text-zinc-400"
>
<.icon
@@ -231,16 +238,16 @@ defmodule MusicLibraryWeb.Components.Chat do
</div>
<div
:if={@current_response != ""}
:if={@streaming_doc != nil}
class="max-w-[85%] rounded-lg bg-zinc-100 px-4 py-2 text-sm text-zinc-900 dark:bg-zinc-700 dark:text-zinc-100"
>
<div class="dark:prose-invert prose prose-sm">
{raw(Markdown.to_html(@current_response))}
{raw(Markdown.streaming_to_html(@streaming_doc))}
</div>
</div>
<div
:if={@loading and @current_response == ""}
:if={@loading and @streaming_doc == nil}
class="flex items-center gap-2 text-zinc-500 dark:text-zinc-400"
>
<.loading class="size-4" />
@@ -306,6 +313,7 @@ defmodule MusicLibraryWeb.Components.Chat do
socket
|> assign(:messages, [])
|> assign(:current_response, "")
|> assign(:streaming_doc, nil)
|> assign(:error, nil)
|> assign(:chat, nil)
|> assign(:view, :active)}
@@ -329,6 +337,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|> assign(:chat, chat)
|> assign(:messages, messages)
|> assign(:current_response, "")
|> assign(:streaming_doc, nil)
|> assign(:error, nil)
|> assign(:view, :active)}
end
@@ -346,6 +355,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|> assign(:chat, nil)
|> assign(:messages, [])
|> assign(:current_response, "")
|> assign(:streaming_doc, nil)
|> assign(:error, nil)
else
socket
@@ -407,6 +417,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|> assign(:has_history, true)
|> assign(:loading, true)
|> assign(:current_response, "")
|> assign(:streaming_doc, nil)
|> assign(:error, nil)}
end
+33 -7
View File
@@ -1,12 +1,19 @@
defmodule MusicLibraryWeb.Markdown do
@moduledoc """
Custom markdown processor that handles double square bracket links.
Custom markdown processor that handles double square bracket links
and streaming markdown rendering for chat.
Text wrapped in double square brackets like `[[Foo]]` will be rendered as
Text wrapped in double square brackets like `[[Foo]]` will be rendered as
search links to the collection page.
"""
alias MusicLibrary.Records
@mdex_options [
extension: [autolink: true, strikethrough: true, table: true],
render: [unsafe: true],
sanitize: MDEx.Document.default_sanitize_options()
]
@doc """
Converts markdown with custom `[[link]]` syntax to HTML.
@@ -21,11 +28,7 @@ defmodule MusicLibraryWeb.Markdown do
result =
markdown_text
|> process_double_bracket_links()
|> MDEx.to_html!(
extension: [autolink: true, strikethrough: true, table: true],
render: [unsafe: true],
sanitize: MDEx.Document.default_sanitize_options()
)
|> MDEx.to_html!(@mdex_options)
{result, %{}}
end
@@ -34,6 +37,29 @@ defmodule MusicLibraryWeb.Markdown do
def to_html(nil), do: ""
@doc """
Creates a new streaming MDEx document for incremental markdown rendering.
"""
@spec new_streaming_doc() :: MDEx.Document.t()
def new_streaming_doc do
MDEx.new([streaming: true] ++ @mdex_options)
end
@doc """
Renders a streaming MDEx document to HTML.
"""
@spec streaming_to_html(MDEx.Document.t()) :: String.t()
def streaming_to_html(%MDEx.Document{} = doc) do
:telemetry.span(
[:markdown, :streaming_to_html],
%{},
fn ->
result = MDEx.to_html!(doc)
{result, %{}}
end
)
end
@doc """
Processes text to convert [[text]] patterns into markdown links.
+34
View File
@@ -5,6 +5,40 @@ defmodule MusicLibraryWeb.MarkdownTest do
doctest MusicLibraryWeb.Markdown
describe "streaming" do
test "new document renders empty string" do
doc = Markdown.new_streaming_doc()
assert Markdown.streaming_to_html(doc) == ""
end
test "incremental chunks produce valid HTML" do
doc = Markdown.new_streaming_doc()
doc = MDEx.Document.put_markdown(doc, "**bold")
html = Markdown.streaming_to_html(doc)
assert html =~ "<strong>bold</strong>"
doc = MDEx.Document.put_markdown(doc, " text**")
html = Markdown.streaming_to_html(doc)
assert html =~ "<strong>bold text</strong>"
end
test "handles incomplete code fences" do
doc = Markdown.new_streaming_doc()
doc = MDEx.Document.put_markdown(doc, "```elixir\nIO.puts")
html = Markdown.streaming_to_html(doc)
assert html =~ "IO"
assert html =~ "puts"
assert html =~ "<code"
end
test "sanitizes script tags" do
doc = Markdown.new_streaming_doc()
doc = MDEx.Document.put_markdown(doc, "<script>alert(1)</script>")
html = Markdown.streaming_to_html(doc)
refute html =~ "<script>"
end
end
describe "to_html/1" do
test "strips script tags" do
result = Markdown.to_html("<script>alert('xss')</script>")