Use MDEx streaming to render chat content
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>")
|
||||
|
||||
Reference in New Issue
Block a user