This commit is contained in:
@@ -260,8 +260,17 @@ defmodule ElixirAiWeb.ChatLive do
|
||||
def handle_info({:ai_request_error, reason}, socket) do
|
||||
error_message =
|
||||
case reason do
|
||||
%{__struct__: mod, reason: r} -> "#{inspect(mod)}: #{inspect(r)}"
|
||||
_ -> inspect(reason)
|
||||
"proxy error" <> _ ->
|
||||
"Could not connect to AI provider. Please check your proxy and provider settings."
|
||||
|
||||
%{__struct__: mod, reason: r} ->
|
||||
"#{inspect(mod)}: #{inspect(r)}"
|
||||
|
||||
msg when is_binary(msg) ->
|
||||
msg
|
||||
|
||||
_ ->
|
||||
inspect(reason)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, ai_error: error_message, streaming_response: nil)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
defmodule ElixirAiWeb.ChatMessage do
|
||||
use Phoenix.Component
|
||||
alias Phoenix.LiveView.JS
|
||||
import ElixirAiWeb.JsonDisplay
|
||||
|
||||
defp max_width_class, do: "max-w-300"
|
||||
defp max_width_class, do: "max-w-full xl:max-w-300"
|
||||
|
||||
attr :content, :string, required: true
|
||||
attr :tool_call_id, :string, required: true
|
||||
@@ -38,7 +39,7 @@ defmodule ElixirAiWeb.ChatMessage do
|
||||
def user_message(assigns) do
|
||||
~H"""
|
||||
<div class="mb-2 text-sm text-right">
|
||||
<div class={"inline-block px-3 py-2 rounded-lg bg-seafoam-950 text-seafoam-50 #{max_width_class()} text-left"}>
|
||||
<div class={"w-fit px-3 py-2 rounded-lg bg-seafoam-950 text-seafoam-50 #{max_width_class()} text-left"}>
|
||||
{@content}
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +79,7 @@ defmodule ElixirAiWeb.ChatMessage do
|
||||
# chunks instead of re-rendering the full markdown on every token.
|
||||
def streaming_assistant_message(assigns) do
|
||||
~H"""
|
||||
<div class="mb-2 text-sm text-left">
|
||||
<div class="mb-2 text-sm text-left min-w-0">
|
||||
<!-- Reasoning section — only shown once reasoning_content is non-empty.
|
||||
The div is always in the DOM so the hook mounts before chunks arrive. -->
|
||||
<div id="stream-reasoning-wrap">
|
||||
@@ -127,7 +128,7 @@ defmodule ElixirAiWeb.ChatMessage do
|
||||
phx-hook="MarkdownStream"
|
||||
phx-update="ignore"
|
||||
data-event="md_chunk"
|
||||
class={"inline-block px-3 py-2 rounded-lg #{max_width_class()} markdown bg-seafoam-950/50"}
|
||||
class={"w-fit px-3 py-2 rounded-lg #{max_width_class()} markdown bg-seafoam-950/50 overflow-x-auto"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +143,7 @@ defmodule ElixirAiWeb.ChatMessage do
|
||||
|
||||
defp message_bubble(assigns) do
|
||||
~H"""
|
||||
<div class="mb-2 text-sm text-left">
|
||||
<div class="mb-2 text-sm text-left min-w-0">
|
||||
<%= if @reasoning_content && @reasoning_content != "" do %>
|
||||
<button
|
||||
type="button"
|
||||
@@ -191,7 +192,7 @@ defmodule ElixirAiWeb.ChatMessage do
|
||||
phx-hook="MarkdownRender"
|
||||
phx-update="ignore"
|
||||
data-md={@content}
|
||||
class={"inline-block px-3 py-2 rounded-lg #{max_width_class()} markdown bg-seafoam-950/50"}
|
||||
class={"w-fit px-3 py-2 rounded-lg #{max_width_class()} markdown bg-seafoam-950/50 overflow-x-auto"}
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -253,47 +254,49 @@ defmodule ElixirAiWeb.ChatMessage do
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div class={[
|
||||
"mb-1 #{max_width_class()} rounded-lg border text-xs font-mono overflow-hidden bg-seafoam-950/40",
|
||||
@state == :error && "border-red-900/50",
|
||||
@state == :called && "border-seafoam-900/60",
|
||||
@state in [:pending, :success] && "border-seafoam-900"
|
||||
]}>
|
||||
<div class={[
|
||||
"flex items-center gap-2 px-3 py-1.5 border-b text-seafoam-400",
|
||||
@state == :error && "border-red-900/50 bg-red-900/20",
|
||||
@state == :called && "border-seafoam-900/60 bg-seafoam-900/20",
|
||||
@state in [:pending, :success] && "border-seafoam-900 bg-seafoam-900/30"
|
||||
]}>
|
||||
<.tool_call_icon />
|
||||
<span class="text-seafoam-300 font-semibold shrink-0">{@name}</span>
|
||||
<span :if={@_truncated} class="text-seafoam-600/50 truncate flex-1 min-w-0 ml-1">
|
||||
{@_truncated}
|
||||
</span>
|
||||
<span :if={!@_truncated} class="flex-1" />
|
||||
<button
|
||||
:if={@_truncated}
|
||||
type="button"
|
||||
phx-click={
|
||||
<div
|
||||
id={@_id}
|
||||
class={[
|
||||
"mb-1 #{max_width_class()} rounded-lg border text-xs font-mono overflow-hidden bg-seafoam-950/40",
|
||||
@state == :error && "border-red-900/50",
|
||||
@state == :called && "border-seafoam-900/60",
|
||||
@state in [:pending, :success] && "border-seafoam-900"
|
||||
]}
|
||||
>
|
||||
<div
|
||||
class={[
|
||||
"flex items-center gap-2 px-3 py-1.5 border-b text-seafoam-400",
|
||||
@_truncated && "cursor-pointer select-none",
|
||||
@state == :error && "border-red-900/50 bg-red-900/20",
|
||||
@state == :called && "border-seafoam-900/60 bg-seafoam-900/20",
|
||||
@state in [:pending, :success] && "border-seafoam-900 bg-seafoam-900/30"
|
||||
]}
|
||||
phx-click={
|
||||
@_truncated &&
|
||||
JS.toggle_class("hidden", to: "##{@_id}-args")
|
||||
|> JS.toggle_class("rotate-180", to: "##{@_id}-chevron")
|
||||
}
|
||||
class="shrink-0 text-seafoam-700 hover:text-seafoam-400 transition-colors mx-1"
|
||||
}
|
||||
>
|
||||
<.tool_call_icon />
|
||||
<span class="text-seafoam-400 font-semibold shrink-0">{@name}</span>
|
||||
<span :if={@_truncated} class="text-seafoam-500 truncate flex-1 min-w-0 ml-1">
|
||||
<.json_display json={@_truncated} inline />
|
||||
</span>
|
||||
<span :if={!@_truncated} class="flex-1" />
|
||||
<svg
|
||||
:if={@_truncated}
|
||||
id={"#{@_id}-chevron"}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 shrink-0 mx-1 text-seafoam-700 transition-transform duration-200"
|
||||
>
|
||||
<svg
|
||||
id={"#{@_id}-chevron"}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 transition-transform duration-200"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span :if={@state == :called} class="flex items-center gap-1 text-seafoam-500/50 shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -376,26 +379,10 @@ defmodule ElixirAiWeb.ChatMessage do
|
||||
attr :arguments, :any, default: nil
|
||||
|
||||
defp tool_call_args(%{arguments: args} = assigns) when not is_nil(args) and args != "" do
|
||||
assigns =
|
||||
assign(
|
||||
assigns,
|
||||
:pretty_args,
|
||||
case args do
|
||||
s when is_binary(s) ->
|
||||
case Jason.decode(s) do
|
||||
{:ok, decoded} -> Jason.encode!(decoded, pretty: true)
|
||||
_ -> s
|
||||
end
|
||||
|
||||
other ->
|
||||
Jason.encode!(other, pretty: true)
|
||||
end
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div class="px-3 py-2 border-b border-seafoam-900/50">
|
||||
<div class="text-seafoam-500 mb-1 uppercase tracking-wider text-[10px]">arguments</div>
|
||||
<pre class="text-seafoam-400 whitespace-pre-wrap break-all">{@pretty_args}</pre>
|
||||
<.json_display json={@arguments} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
132
lib/elixir_ai_web/chat/json_display.ex
Normal file
132
lib/elixir_ai_web/chat/json_display.ex
Normal file
@@ -0,0 +1,132 @@
|
||||
defmodule ElixirAiWeb.JsonDisplay do
|
||||
use Phoenix.Component
|
||||
|
||||
attr :json, :any, required: true
|
||||
attr :class, :string, default: nil
|
||||
attr :inline, :boolean, default: false
|
||||
|
||||
def json_display(%{json: json, inline: inline} = assigns) do
|
||||
formatted =
|
||||
case json do
|
||||
nil ->
|
||||
""
|
||||
|
||||
"" ->
|
||||
""
|
||||
|
||||
s when is_binary(s) ->
|
||||
case Jason.decode(s) do
|
||||
{:ok, decoded} -> Jason.encode!(decoded, pretty: !inline)
|
||||
_ -> s
|
||||
end
|
||||
|
||||
other ->
|
||||
Jason.encode!(other, pretty: !inline)
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :_highlighted, json_to_html(formatted))
|
||||
|
||||
~H"""
|
||||
<pre
|
||||
:if={!@inline}
|
||||
class={["whitespace-pre-wrap break-all text-xs font-mono leading-relaxed", @class]}
|
||||
><%= @_highlighted %></pre>
|
||||
<span :if={@inline} class={["text-xs font-mono truncate", @class]}>{@_highlighted}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@token_colors %{
|
||||
key: "text-sky-300",
|
||||
string: "text-emerald-400/80",
|
||||
keyword: "text-violet-400",
|
||||
number: "text-orange-300/80",
|
||||
colon: "text-seafoam-500/50",
|
||||
punctuation: "text-seafoam-500/50",
|
||||
quote: "text-seafoam-500/50"
|
||||
}
|
||||
|
||||
# Converts a plain JSON string into a Phoenix.HTML.safe value with
|
||||
# <span> tokens coloured by token type.
|
||||
defp json_to_html(""), do: Phoenix.HTML.raw("")
|
||||
|
||||
defp json_to_html(str) do
|
||||
# Capture groups (in order):
|
||||
# 1 string literal "..."
|
||||
# 2 keyword true | false | null
|
||||
# 3 number -?digits with optional frac/exp
|
||||
# 4 punctuation { } [ ] , :
|
||||
# 5 whitespace spaces / newlines / tabs
|
||||
# 6 fallback any other single char
|
||||
token_re =
|
||||
~r/(".(?:[^"\\]|\\.)*")|(true|false|null)|(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}\[\],:])|(\s+)|(.)/s
|
||||
|
||||
tokens = Regex.scan(token_re, str, capture: :all_but_first)
|
||||
|
||||
{parts, _, _} =
|
||||
Enum.reduce(tokens, {[], :val, []}, fn groups, {acc, state, ctx} ->
|
||||
[string_tok, keyword_tok, number_tok, punct_tok, whitespace_tok, fallback_tok] =
|
||||
pad_groups(groups, 6)
|
||||
|
||||
cond do
|
||||
string_tok != "" ->
|
||||
{color, next_state} =
|
||||
if state == :key,
|
||||
do: {@token_colors.key, :after_key},
|
||||
else: {@token_colors.string, :after_val}
|
||||
|
||||
content = string_tok |> String.slice(1..-2//1) |> html_escape()
|
||||
quote_span = color_span(@token_colors.quote, """)
|
||||
|
||||
{[quote_span <> color_span(color, content) <> quote_span | acc], next_state, ctx}
|
||||
|
||||
keyword_tok != "" ->
|
||||
{[color_span(@token_colors.keyword, keyword_tok) | acc], :after_val, ctx}
|
||||
|
||||
number_tok != "" ->
|
||||
{[color_span(@token_colors.number, number_tok) | acc], :after_val, ctx}
|
||||
|
||||
punct_tok != "" ->
|
||||
{next_state, next_ctx} = advance_state(punct_tok, state, ctx)
|
||||
color = if punct_tok == ":", do: @token_colors.colon, else: @token_colors.punctuation
|
||||
{[color_span(color, punct_tok) | acc], next_state, next_ctx}
|
||||
|
||||
whitespace_tok != "" ->
|
||||
{[whitespace_tok | acc], state, ctx}
|
||||
|
||||
fallback_tok != "" ->
|
||||
{[html_escape(fallback_tok) | acc], state, ctx}
|
||||
|
||||
true ->
|
||||
{acc, state, ctx}
|
||||
end
|
||||
end)
|
||||
|
||||
Phoenix.HTML.raw(parts |> Enum.reverse() |> Enum.join())
|
||||
end
|
||||
|
||||
# State transitions driven by punctuation tokens.
|
||||
# State :key → we are about to read an object key.
|
||||
# State :val → we are about to read a value.
|
||||
# State :after_key / :after_val → consumed the token; awaiting : or ,.
|
||||
defp advance_state("{", _, ctx), do: {:key, [:obj | ctx]}
|
||||
defp advance_state("[", _, ctx), do: {:val, [:arr | ctx]}
|
||||
defp advance_state("}", _, [_ | ctx]), do: {:after_val, ctx}
|
||||
defp advance_state("}", _, []), do: {:after_val, []}
|
||||
defp advance_state("]", _, [_ | ctx]), do: {:after_val, ctx}
|
||||
defp advance_state("]", _, []), do: {:after_val, []}
|
||||
defp advance_state(":", _, ctx), do: {:val, ctx}
|
||||
defp advance_state(",", _, [:obj | _] = ctx), do: {:key, ctx}
|
||||
defp advance_state(",", _, ctx), do: {:val, ctx}
|
||||
defp advance_state(_, state, ctx), do: {state, ctx}
|
||||
|
||||
defp pad_groups(list, n), do: list ++ List.duplicate("", max(0, n - length(list)))
|
||||
|
||||
defp color_span(class, content), do: ~s|<span class="#{class}">#{content}</span>|
|
||||
|
||||
defp html_escape(str) do
|
||||
str
|
||||
|> String.replace("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user