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 =~ "")