fixed markdown rendering by shifting it to javascript
This commit is contained in:
@@ -92,7 +92,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.markdown table {
|
.markdown table {
|
||||||
@apply w-full border-collapse my-4 text-sm;
|
@apply block w-full border-collapse my-4 text-sm overflow-x-auto;
|
||||||
}
|
}
|
||||||
.markdown thead {
|
.markdown thead {
|
||||||
@apply bg-cyan-950;
|
@apply bg-cyan-950;
|
||||||
|
|||||||
@@ -24,6 +24,52 @@ import topbar from "../vendor/topbar"
|
|||||||
|
|
||||||
let Hooks = {}
|
let Hooks = {}
|
||||||
|
|
||||||
|
// Renders a complete markdown string client-side on mount.
|
||||||
|
// The raw markdown is passed as the data-md attribute.
|
||||||
|
Hooks.MarkdownRender = {
|
||||||
|
mounted() {
|
||||||
|
const smd = window.smd
|
||||||
|
const content = this.el.dataset.md
|
||||||
|
if (!content) return
|
||||||
|
const parser = smd.parser(smd.default_renderer(this.el))
|
||||||
|
smd.parser_write(parser, content)
|
||||||
|
smd.parser_end(parser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams markdown chunks into the element using the streaming-markdown parser.
|
||||||
|
// The server sends push_event(socket, eventName, %{chunk: "..."}) for each chunk.
|
||||||
|
// data-event on the element controls which event this hook listens for.
|
||||||
|
// The server sends push_event(socket, eventName, %{chunk: "..."}) for each chunk.
|
||||||
|
// data-event on the element controls which event this hook listens for.
|
||||||
|
Hooks.MarkdownStream = {
|
||||||
|
mounted() {
|
||||||
|
const smd = window.smd
|
||||||
|
const DOMPurify = window.DOMPurify
|
||||||
|
this._chunks = ""
|
||||||
|
this._parser = smd.parser(smd.default_renderer(this.el))
|
||||||
|
const eventName = this.el.dataset.event
|
||||||
|
this.handleEvent(eventName, ({chunk}) => {
|
||||||
|
this._chunks += chunk
|
||||||
|
// Sanitize all accumulated chunks to detect injection attacks.
|
||||||
|
DOMPurify.sanitize(this._chunks)
|
||||||
|
if (DOMPurify.removed.length > 0) {
|
||||||
|
// Insecure content detected — stop rendering immediately.
|
||||||
|
smd.parser_end(this._parser)
|
||||||
|
this._parser = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this._parser) smd.parser_write(this._parser, chunk)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
if (this._parser) {
|
||||||
|
window.smd.parser_end(this._parser)
|
||||||
|
this._parser = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Hooks.ScrollBottom = {
|
Hooks.ScrollBottom = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
defmodule ElixirAiWeb.ChatMessage do
|
defmodule ElixirAiWeb.ChatMessage do
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
alias ElixirAiWeb.Markdown
|
|
||||||
alias Phoenix.LiveView.JS
|
alias Phoenix.LiveView.JS
|
||||||
|
|
||||||
attr :content, :string, required: true
|
attr :content, :string, required: true
|
||||||
@@ -60,20 +59,62 @@ defmodule ElixirAiWeb.ChatMessage do
|
|||||||
attr :reasoning_content, :string, default: nil
|
attr :reasoning_content, :string, default: nil
|
||||||
attr :tool_calls, :list, default: []
|
attr :tool_calls, :list, default: []
|
||||||
|
|
||||||
|
# Renders the in-progress streaming message. Content and reasoning are rendered
|
||||||
|
# entirely client-side via the MarkdownStream hook — the server sends push_event
|
||||||
|
# chunks instead of re-rendering the full markdown on every token.
|
||||||
def streaming_assistant_message(assigns) do
|
def streaming_assistant_message(assigns) do
|
||||||
assigns =
|
|
||||||
assigns
|
|
||||||
|> assign(:_reasoning_id, "reasoning-stream")
|
|
||||||
|> assign(:_expanded, true)
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<.message_bubble
|
<div class="mb-2 text-sm text-left">
|
||||||
reasoning_id={@_reasoning_id}
|
<!-- Reasoning section — only shown once reasoning_content is non-empty.
|
||||||
content={@content}
|
The div is always in the DOM so the hook mounts before chunks arrive. -->
|
||||||
reasoning_content={@reasoning_content}
|
<div id="stream-reasoning-wrap">
|
||||||
tool_calls={@tool_calls}
|
<%= if @reasoning_content && @reasoning_content != "" do %>
|
||||||
expanded={@_expanded}
|
<button
|
||||||
/>
|
type="button"
|
||||||
|
class="flex items-center text-cyan-500/60 hover:text-cyan-300 transition-colors duration-150 cursor-pointer"
|
||||||
|
phx-click={
|
||||||
|
JS.toggle_class("collapsed", to: "#reasoning-stream")
|
||||||
|
|> JS.toggle_class("rotate-180", to: "#reasoning-stream-chevron")
|
||||||
|
}
|
||||||
|
aria-label="Toggle reasoning"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1 text-cyan-100/40 ps-2 mb-1">
|
||||||
|
<span class="text-xs">reasoning</span>
|
||||||
|
<svg
|
||||||
|
id="reasoning-stream-chevron"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="w-3 h-3 transition-transform duration-300"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M9.47 6.47a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 1 1-1.06 1.06L10 8.06l-3.72 3.72a.75.75 0 0 1-1.06-1.06l4.25-4.25Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
<div
|
||||||
|
id="reasoning-stream"
|
||||||
|
phx-hook="MarkdownStream"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-event="reasoning_chunk"
|
||||||
|
class="reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs max-w-prose mb-1 markdown"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<%= for tool_call <- @tool_calls do %>
|
||||||
|
<.tool_call_item tool_call={tool_call} />
|
||||||
|
<% end %>
|
||||||
|
<div
|
||||||
|
id="stream-content"
|
||||||
|
phx-hook="MarkdownStream"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-event="md_chunk"
|
||||||
|
class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -115,21 +156,26 @@ defmodule ElixirAiWeb.ChatMessage do
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
id={@reasoning_id}
|
id={@reasoning_id}
|
||||||
|
phx-hook="MarkdownRender"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-md={@reasoning_content}
|
||||||
class={[
|
class={[
|
||||||
"reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs max-w-prose mb-1 markdown",
|
"reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs max-w-prose mb-1 markdown",
|
||||||
!@expanded && "collapsed"
|
!@expanded && "collapsed"
|
||||||
]}
|
]}
|
||||||
>
|
></div>
|
||||||
{Markdown.render(@reasoning_content)}
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= for tool_call <- @tool_calls do %>
|
<%= for tool_call <- @tool_calls do %>
|
||||||
<.tool_call_item tool_call={tool_call} />
|
<.tool_call_item tool_call={tool_call} />
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if @content && @content != "" do %>
|
<%= if @content && @content != "" do %>
|
||||||
<div class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50">
|
<div
|
||||||
{Markdown.render(@content)}
|
id={"#{@reasoning_id}-content"}
|
||||||
</div>
|
phx-hook="MarkdownRender"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-md={@content}
|
||||||
|
class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50"
|
||||||
|
></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||||
</script>
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
import * as smd from "https://cdn.jsdelivr.net/npm/streaming-markdown/smd.min.js"
|
||||||
|
import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify/+esm"
|
||||||
|
window.smd = smd
|
||||||
|
window.DOMPurify = DOMPurify
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-cyan-900 text-cyan-50">
|
<body class="bg-cyan-900 text-cyan-50">
|
||||||
{@inner_content}
|
{@inner_content}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
defmodule ElixirAiWeb.Markdown do
|
|
||||||
@doc """
|
|
||||||
Converts a markdown string to sanitized HTML, safe for raw rendering.
|
|
||||||
Falls back to escaped plain text if the markdown is incomplete or invalid.
|
|
||||||
"""
|
|
||||||
def render(nil), do: Phoenix.HTML.raw("")
|
|
||||||
def render(""), do: Phoenix.HTML.raw("")
|
|
||||||
|
|
||||||
def render(markdown) do
|
|
||||||
case Earmark.as_html(markdown, breaks: true, compact_output: true) do
|
|
||||||
{:ok, html, _warnings} ->
|
|
||||||
html
|
|
||||||
|> HtmlSanitizeEx.markdown_html()
|
|
||||||
|> Phoenix.HTML.raw()
|
|
||||||
|
|
||||||
{:error, html, _errors} ->
|
|
||||||
# Partial/invalid markdown — use whatever HTML was produced, still sanitize
|
|
||||||
html
|
|
||||||
|> HtmlSanitizeEx.markdown_html()
|
|
||||||
|> Phoenix.HTML.raw()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -131,7 +131,11 @@ defmodule ElixirAiWeb.ChatLive do
|
|||||||
socket.assigns.streaming_response.reasoning_content <> reasoning_content
|
socket.assigns.streaming_response.reasoning_content <> reasoning_content
|
||||||
}
|
}
|
||||||
|
|
||||||
{:noreply, assign(socket, streaming_response: updated_response)}
|
# Update assign (controls toggle button visibility) and stream chunk to hook.
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(streaming_response: updated_response)
|
||||||
|
|> push_event("reasoning_chunk", %{chunk: reasoning_content})}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(
|
def handle_info(
|
||||||
@@ -148,7 +152,11 @@ defmodule ElixirAiWeb.ChatLive do
|
|||||||
| content: socket.assigns.streaming_response.content <> text_content
|
| content: socket.assigns.streaming_response.content <> text_content
|
||||||
}
|
}
|
||||||
|
|
||||||
{:noreply, assign(socket, streaming_response: updated_response)}
|
# Update assign (accumulated for final message) and stream chunk to hook.
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(streaming_response: updated_response)
|
||||||
|
|> push_event("md_chunk", %{chunk: text_content})}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(:tool_calls_finished, socket) do
|
def handle_info(:tool_calls_finished, socket) do
|
||||||
|
|||||||
1
mix.exs
1
mix.exs
@@ -49,7 +49,6 @@ defmodule ElixirAi.MixProject do
|
|||||||
depth: 1},
|
depth: 1},
|
||||||
{:req, "~> 0.5"},
|
{:req, "~> 0.5"},
|
||||||
{:html_sanitize_ex, "~> 1.4"},
|
{:html_sanitize_ex, "~> 1.4"},
|
||||||
{:earmark, "~> 1.4"},
|
|
||||||
{:dotenvy, "~> 1.1.1"},
|
{:dotenvy, "~> 1.1.1"},
|
||||||
{:telemetry_metrics, "~> 1.0"},
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
{:telemetry_poller, "~> 1.0"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
|
|||||||
Reference in New Issue
Block a user