diff --git a/docs/architecture.md b/docs/architecture.md
index bdf175c4..8964f1d4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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 |
diff --git a/lib/music_library_web/components/chat.ex b/lib/music_library_web/components/chat.ex
index 1923f2ba..544d6df9 100644
--- a/lib/music_library_web/components/chat.ex
+++ b/lib/music_library_web/components/chat.ex
@@ -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"
>
<.icon
@@ -231,16 +238,16 @@ defmodule MusicLibraryWeb.Components.Chat do
- {raw(Markdown.to_html(@current_response))}
+ {raw(Markdown.streaming_to_html(@streaming_doc))}
<.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
diff --git a/lib/music_library_web/markdown.ex b/lib/music_library_web/markdown.ex
index 59261e0a..36dc0196 100644
--- a/lib/music_library_web/markdown.ex
+++ b/lib/music_library_web/markdown.ex
@@ -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.
diff --git a/test/music_library_web/markdown_test.exs b/test/music_library_web/markdown_test.exs
index dca56aee..a858426b 100644
--- a/test/music_library_web/markdown_test.exs
+++ b/test/music_library_web/markdown_test.exs
@@ -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 =~ "bold"
+
+ doc = MDEx.Document.put_markdown(doc, " text**")
+ html = Markdown.streaming_to_html(doc)
+ assert html =~ "bold text"
+ 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 =~ "alert(1)")
+ html = Markdown.streaming_to_html(doc)
+ refute html =~ "")