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 |
|
| Module | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `ErrorMessages` | Maps internal error terms (atoms, structs) to user-friendly gettext strings via `friendly_message/1` |
|
| `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 |
|
| `Duration` | Milliseconds to human-readable duration formatting |
|
||||||
| `Auth` | Authentication plugs: login password check, API token validation, session enforcement |
|
| `Auth` | Authentication plugs: login password check, API token validation, session enforcement |
|
||||||
| `LiveHelpers.Params` | Pagination param parsing from URL query params |
|
| `LiveHelpers.Params` | Pagination param parsing from URL query params |
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
|> assign(:view, :active)
|
|> assign(:view, :active)
|
||||||
|> assign(:chat, nil)
|
|> assign(:chat, nil)
|
||||||
|> assign(:chats, [])
|
|> assign(:chats, [])
|
||||||
|
|> assign(:streaming_doc, nil)
|
||||||
|> assign(:has_history, false)}
|
|> assign(:has_history, false)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def update(%{chunk: chunk}, socket) do
|
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
|
end
|
||||||
|
|
||||||
def update(%{done: true}, socket) do
|
def update(%{done: true}, socket) do
|
||||||
@@ -36,6 +42,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
socket
|
socket
|
||||||
|> update(:messages, &(&1 ++ [completed_message]))
|
|> update(:messages, &(&1 ++ [completed_message]))
|
||||||
|> assign(:current_response, "")
|
|> assign(:current_response, "")
|
||||||
|
|> assign(:streaming_doc, nil)
|
||||||
|> assign(:loading, false)}
|
|> assign(:loading, false)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -207,7 +214,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
phx-hook=".ScrollBottom"
|
phx-hook=".ScrollBottom"
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
class="flex h-full flex-col items-center justify-center text-center text-zinc-500 dark:text-zinc-400"
|
||||||
>
|
>
|
||||||
<.icon
|
<.icon
|
||||||
@@ -231,16 +238,16 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
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">
|
<div class="dark:prose-invert prose prose-sm">
|
||||||
{raw(Markdown.to_html(@current_response))}
|
{raw(Markdown.streaming_to_html(@streaming_doc))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
class="flex items-center gap-2 text-zinc-500 dark:text-zinc-400"
|
||||||
>
|
>
|
||||||
<.loading class="size-4" />
|
<.loading class="size-4" />
|
||||||
@@ -306,6 +313,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
socket
|
socket
|
||||||
|> assign(:messages, [])
|
|> assign(:messages, [])
|
||||||
|> assign(:current_response, "")
|
|> assign(:current_response, "")
|
||||||
|
|> assign(:streaming_doc, nil)
|
||||||
|> assign(:error, nil)
|
|> assign(:error, nil)
|
||||||
|> assign(:chat, nil)
|
|> assign(:chat, nil)
|
||||||
|> assign(:view, :active)}
|
|> assign(:view, :active)}
|
||||||
@@ -329,6 +337,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
|> assign(:chat, chat)
|
|> assign(:chat, chat)
|
||||||
|> assign(:messages, messages)
|
|> assign(:messages, messages)
|
||||||
|> assign(:current_response, "")
|
|> assign(:current_response, "")
|
||||||
|
|> assign(:streaming_doc, nil)
|
||||||
|> assign(:error, nil)
|
|> assign(:error, nil)
|
||||||
|> assign(:view, :active)}
|
|> assign(:view, :active)}
|
||||||
end
|
end
|
||||||
@@ -346,6 +355,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
|> assign(:chat, nil)
|
|> assign(:chat, nil)
|
||||||
|> assign(:messages, [])
|
|> assign(:messages, [])
|
||||||
|> assign(:current_response, "")
|
|> assign(:current_response, "")
|
||||||
|
|> assign(:streaming_doc, nil)
|
||||||
|> assign(:error, nil)
|
|> assign(:error, nil)
|
||||||
else
|
else
|
||||||
socket
|
socket
|
||||||
@@ -407,6 +417,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
|> assign(:has_history, true)
|
|> assign(:has_history, true)
|
||||||
|> assign(:loading, true)
|
|> assign(:loading, true)
|
||||||
|> assign(:current_response, "")
|
|> assign(:current_response, "")
|
||||||
|
|> assign(:streaming_doc, nil)
|
||||||
|> assign(:error, nil)}
|
|> assign(:error, nil)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
defmodule MusicLibraryWeb.Markdown do
|
defmodule MusicLibraryWeb.Markdown do
|
||||||
@moduledoc """
|
@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.
|
search links to the collection page.
|
||||||
"""
|
"""
|
||||||
alias MusicLibrary.Records
|
alias MusicLibrary.Records
|
||||||
|
|
||||||
|
@mdex_options [
|
||||||
|
extension: [autolink: true, strikethrough: true, table: true],
|
||||||
|
render: [unsafe: true],
|
||||||
|
sanitize: MDEx.Document.default_sanitize_options()
|
||||||
|
]
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Converts markdown with custom `[[link]]` syntax to HTML.
|
Converts markdown with custom `[[link]]` syntax to HTML.
|
||||||
|
|
||||||
@@ -21,11 +28,7 @@ defmodule MusicLibraryWeb.Markdown do
|
|||||||
result =
|
result =
|
||||||
markdown_text
|
markdown_text
|
||||||
|> process_double_bracket_links()
|
|> process_double_bracket_links()
|
||||||
|> MDEx.to_html!(
|
|> MDEx.to_html!(@mdex_options)
|
||||||
extension: [autolink: true, strikethrough: true, table: true],
|
|
||||||
render: [unsafe: true],
|
|
||||||
sanitize: MDEx.Document.default_sanitize_options()
|
|
||||||
)
|
|
||||||
|
|
||||||
{result, %{}}
|
{result, %{}}
|
||||||
end
|
end
|
||||||
@@ -34,6 +37,29 @@ defmodule MusicLibraryWeb.Markdown do
|
|||||||
|
|
||||||
def to_html(nil), 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 """
|
@doc """
|
||||||
Processes text to convert [[text]] patterns into markdown links.
|
Processes text to convert [[text]] patterns into markdown links.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,40 @@ defmodule MusicLibraryWeb.MarkdownTest do
|
|||||||
|
|
||||||
doctest MusicLibraryWeb.Markdown
|
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
|
describe "to_html/1" do
|
||||||
test "strips script tags" do
|
test "strips script tags" do
|
||||||
result = Markdown.to_html("<script>alert('xss')</script>")
|
result = Markdown.to_html("<script>alert('xss')</script>")
|
||||||
|
|||||||
Reference in New Issue
Block a user