diff --git a/assets/css/app.css b/assets/css/app.css index 5e15d46..d31f03e 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -20,7 +20,7 @@ --color-seafoam-700: hsl(192.92 72.28% 30.98%); --color-seafoam-800: hsl(194.38 59.57% 27.06%); --color-seafoam-900: hsl(198.18 73.33% 17.65%); - --color-seafoam-950: hsl(198.26 58.97% 7.65%); + --color-seafoam-950: hsl(196.88 72.73% 8.63%); } @variant phx-click-loading (&.phx-click-loading, .phx-click-loading &); diff --git a/assets/css/markdown.css b/assets/css/markdown.css index 9857315..a27d461 100644 --- a/assets/css/markdown.css +++ b/assets/css/markdown.css @@ -92,7 +92,7 @@ } .markdown table { - @apply block w-full border-collapse my-4 text-sm overflow-x-auto; + @apply block w-full border-collapse my-4 text-sm overflow-x-auto max-w-full; } .markdown thead { @apply bg-seafoam-950; diff --git a/assets/js/voice_control.js b/assets/js/voice_control.js index 68db971..613cfba 100644 --- a/assets/js/voice_control.js +++ b/assets/js/voice_control.js @@ -30,6 +30,18 @@ const VoiceControl = { // Button clicks dispatch DOM events to avoid a server round-trip this.el.addEventListener("voice:start", () => this.startRecording()); this.el.addEventListener("voice:stop", () => this.stopRecording()); + + // Handle navigate_to from the server — trigger a live navigation so the + // root layout (and this VoiceLive) is preserved across page changes. + this.handleEvent("navigate_to", ({ path }) => { + let a = document.createElement("a"); + a.href = path; + a.setAttribute("data-phx-link", "redirect"); + a.setAttribute("data-phx-link-state", "push"); + document.body.appendChild(a); + a.click(); + a.remove(); + }); }, destroyed() { diff --git a/lib/elixir_ai/ai_utils/stream_line_utils.ex b/lib/elixir_ai/ai_utils/stream_line_utils.ex index d5e8aec..dcc70be 100644 --- a/lib/elixir_ai/ai_utils/stream_line_utils.ex +++ b/lib/elixir_ai/ai_utils/stream_line_utils.ex @@ -135,6 +135,11 @@ defmodule ElixirAi.AiUtils.StreamLineUtils do :ok end + def handle_stream_line(server, "proxy error" <> _ = error) when is_binary(error) do + Logger.error("Proxy error in AI stream: #{error}") + send(server, {:stream, {:ai_request_error, error}}) + end + def handle_stream_line(server, json) when is_binary(json) do case Jason.decode(json) do {:ok, body} -> diff --git a/lib/elixir_ai/chat_runner/chat_runner.ex b/lib/elixir_ai/chat_runner/chat_runner.ex index 9dd60d3..6c64f0b 100644 --- a/lib/elixir_ai/chat_runner/chat_runner.ex +++ b/lib/elixir_ai/chat_runner/chat_runner.ex @@ -1,7 +1,7 @@ defmodule ElixirAi.ChatRunner do require Logger use GenServer - alias ElixirAi.{AiTools, Conversation, Message} + alias ElixirAi.{AiTools, Conversation, Message, SystemPrompts} import ElixirAi.PubsubTopics import ElixirAi.ChatRunner.OutboundHelpers @@ -94,6 +94,12 @@ defmodule ElixirAi.ChatRunner do _ -> "auto" end + system_prompt = + case Conversation.find_category(name) do + {:ok, category} -> SystemPrompts.for_category(category) + _ -> nil + end + server_tools = AiTools.build_server_tools(self(), allowed_tools) liveview_tools = AiTools.build_liveview_tools(self(), allowed_tools) @@ -106,7 +112,7 @@ defmodule ElixirAi.ChatRunner do ElixirAi.ChatUtils.request_ai_response( self(), - messages, + messages_with_system_prompt(messages, system_prompt), server_tools ++ liveview_tools, provider, tool_choice @@ -117,6 +123,7 @@ defmodule ElixirAi.ChatRunner do %{ name: name, messages: messages, + system_prompt: system_prompt, streaming_response: nil, pending_tool_calls: [], allowed_tools: allowed_tools, diff --git a/lib/elixir_ai/chat_runner/conversation_calls.ex b/lib/elixir_ai/chat_runner/conversation_calls.ex index 78bb7c4..12d2969 100644 --- a/lib/elixir_ai/chat_runner/conversation_calls.ex +++ b/lib/elixir_ai/chat_runner/conversation_calls.ex @@ -10,7 +10,7 @@ defmodule ElixirAi.ChatRunner.ConversationCalls do ElixirAi.ChatUtils.request_ai_response( self(), - new_state.messages, + messages_with_system_prompt(new_state.messages, state.system_prompt), state.server_tools ++ state.liveview_tools, state.provider, effective_tool_choice diff --git a/lib/elixir_ai/chat_runner/outbound_helpers.ex b/lib/elixir_ai/chat_runner/outbound_helpers.ex index 1dc8f37..51de184 100644 --- a/lib/elixir_ai/chat_runner/outbound_helpers.ex +++ b/lib/elixir_ai/chat_runner/outbound_helpers.ex @@ -18,4 +18,7 @@ defmodule ElixirAi.ChatRunner.OutboundHelpers do message end + + def messages_with_system_prompt(messages, nil), do: messages + def messages_with_system_prompt(messages, prompt), do: [prompt | messages] end diff --git a/lib/elixir_ai/chat_runner/stream_handler.ex b/lib/elixir_ai/chat_runner/stream_handler.ex index c506475..c2c8acc 100644 --- a/lib/elixir_ai/chat_runner/stream_handler.ex +++ b/lib/elixir_ai/chat_runner/stream_handler.ex @@ -159,7 +159,7 @@ defmodule ElixirAi.ChatRunner.StreamHandler do ElixirAi.ChatUtils.request_ai_response( self(), - state.messages ++ [new_message], + messages_with_system_prompt(state.messages ++ [new_message], state.system_prompt), state.server_tools ++ state.liveview_tools, state.provider, state.tool_choice diff --git a/lib/elixir_ai/data/conversation.ex b/lib/elixir_ai/data/conversation.ex index ed74609..a268f63 100644 --- a/lib/elixir_ai/data/conversation.ex +++ b/lib/elixir_ai/data/conversation.ex @@ -101,6 +101,17 @@ defmodule ElixirAi.Conversation do end end + def find_category(name) do + sql = "SELECT category FROM conversations WHERE name = $(name) LIMIT 1" + params = %{"name" => name} + + case DbHelpers.run_sql(sql, params, "conversations") do + {:error, :db_error} -> {:error, :db_error} + [] -> {:error, :not_found} + [row | _] -> {:ok, row["category"] || "user-web"} + end + end + def find_allowed_tools(name) do sql = "SELECT allowed_tools FROM conversations WHERE name = $(name) LIMIT 1" params = %{"name" => name} diff --git a/lib/elixir_ai/system_prompts.ex b/lib/elixir_ai/system_prompts.ex new file mode 100644 index 0000000..2dc19c6 --- /dev/null +++ b/lib/elixir_ai/system_prompts.ex @@ -0,0 +1,14 @@ +defmodule ElixirAi.SystemPrompts do + @prompts %{ + "voice" => + "You are responding to voice-transcribed input. Keep replies concise and conversational. The user spoke aloud and their message was transcribed, so minor transcription errors may be present.", + "user-web" => nil + } + + def for_category(category) do + case Map.get(@prompts, category) do + nil -> nil + prompt -> %{role: :system, content: prompt} + end + end +end diff --git a/lib/elixir_ai_web/chat/chat_live.ex b/lib/elixir_ai_web/chat/chat_live.ex index 94ec723..43a7894 100644 --- a/lib/elixir_ai_web/chat/chat_live.ex +++ b/lib/elixir_ai_web/chat/chat_live.ex @@ -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)} diff --git a/lib/elixir_ai_web/chat/chat_message.ex b/lib/elixir_ai_web/chat/chat_message.ex index 366243b..7775e7a 100644 --- a/lib/elixir_ai_web/chat/chat_message.ex +++ b/lib/elixir_ai_web/chat/chat_message.ex @@ -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"""
-
+
{@content}
@@ -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""" -
+
@@ -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"} >
@@ -142,7 +143,7 @@ defmodule ElixirAiWeb.ChatMessage do defp message_bubble(assigns) do ~H""" -
+
<%= if @reasoning_content && @reasoning_content != "" do %>
<% end %> @@ -253,47 +254,49 @@ defmodule ElixirAiWeb.ChatMessage do ) ~H""" -
-
- <.tool_call_icon /> - {@name} - - {@_truncated} - - - + + - case Jason.decode(s) do - {:ok, decoded} -> Jason.encode!(decoded, pretty: true) - _ -> s - end - - other -> - Jason.encode!(other, pretty: true) - end - ) - ~H"""
arguments
-
{@pretty_args}
+ <.json_display json={@arguments} />
""" end diff --git a/lib/elixir_ai_web/chat/json_display.ex b/lib/elixir_ai_web/chat/json_display.ex new file mode 100644 index 0000000..c675d14 --- /dev/null +++ b/lib/elixir_ai_web/chat/json_display.ex @@ -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""" +
<%= @_highlighted %>
+ {@_highlighted} + """ + 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 + # 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|#{content}| + + defp html_escape(str) do + str + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + end +end diff --git a/lib/elixir_ai_web/voice/recording.ex b/lib/elixir_ai_web/voice/recording.ex new file mode 100644 index 0000000..e17a1b6 --- /dev/null +++ b/lib/elixir_ai_web/voice/recording.ex @@ -0,0 +1,90 @@ +defmodule ElixirAiWeb.Voice.Recording do + use Phoenix.Component + alias Phoenix.LiveView.JS + + attr :state, :atom, required: true + + def recording(assigns) do + ~H""" +
+
+
+ <%= if @state == :idle do %> + + + + Voice Input + <% end %> + <%= if @state == :recording do %> + + + + + + Recording + <% end %> + <%= if @state == :processing do %> + + + + + + Processing… + <% end %> +
+ +
+ <%= if @state in [:recording, :processing] do %> +
+ + +
+ <% end %> + <%= if @state == :idle do %> + + <% end %> + <%= if @state == :recording do %> + + <% end %> +
+ """ + end +end diff --git a/lib/elixir_ai_web/voice/voice_conversation.ex b/lib/elixir_ai_web/voice/voice_conversation.ex new file mode 100644 index 0000000..aa70a7b --- /dev/null +++ b/lib/elixir_ai_web/voice/voice_conversation.ex @@ -0,0 +1,97 @@ +defmodule ElixirAiWeb.Voice.VoiceConversation do + use Phoenix.Component + alias Phoenix.LiveView.JS + import ElixirAiWeb.ChatMessage + import ElixirAiWeb.Spinner + + attr :messages, :list, required: true + attr :streaming_response, :any, default: nil + attr :ai_error, :string, default: nil + + def voice_conversation(assigns) do + ~H""" +
+
+ Voice Chat + +
+ <%= if @ai_error do %> + + <% end %> +
+ <%= for msg <- @messages do %> + <%= cond do %> + <% msg.role == :user -> %> + <.user_message content={Map.get(msg, :content) || ""} /> + <% msg.role == :tool -> %> + <.tool_result_message + content={Map.get(msg, :content) || ""} + tool_call_id={Map.get(msg, :tool_call_id) || ""} + /> + <% true -> %> + <.assistant_message + content={Map.get(msg, :content) || ""} + reasoning_content={Map.get(msg, :reasoning_content)} + tool_calls={Map.get(msg, :tool_calls) || []} + /> + <% end %> + <% end %> + <%= if @streaming_response do %> + <.streaming_assistant_message + content={@streaming_response.content} + reasoning_content={@streaming_response.reasoning_content} + tool_calls={@streaming_response.tool_calls} + /> + <.spinner /> + <% end %> +
+
+ + +
+
+ """ + end +end diff --git a/lib/elixir_ai_web/voice/voice_live.ex b/lib/elixir_ai_web/voice/voice_live.ex index 4ebe9ba..1ac55e1 100644 --- a/lib/elixir_ai_web/voice/voice_live.ex +++ b/lib/elixir_ai_web/voice/voice_live.ex @@ -2,15 +2,29 @@ defmodule ElixirAiWeb.VoiceLive do use ElixirAiWeb, :live_view require Logger + alias ElixirAiWeb.Voice.Recording + alias ElixirAiWeb.Voice.VoiceConversation + alias ElixirAi.{AiProvider, ChatRunner, ConversationManager} + import ElixirAi.PubsubTopics + def mount(_params, _session, socket) do - {:ok, assign(socket, state: :idle, transcription: nil, expanded: false), layout: false} + {:ok, + assign(socket, + state: :idle, + transcription: nil, + expanded: false, + conversation_name: nil, + messages: [], + streaming_response: nil, + runner_pid: nil, + ai_error: nil + ), layout: false} end def render(assigns) do ~H"""
<%= if not @expanded do %> - <%!-- Collapsed: semi-transparent mic button, still listens to Ctrl+Space via hook --%> <% else %> - <%!-- Expanded panel --%> -
-
-
- <%= if @state == :idle do %> - - - - Voice Input - <% end %> - <%= if @state == :recording do %> - - - - - - Recording - <% end %> - <%= if @state == :processing do %> - - - - - - Processing… - <% end %> - <%= if @state == :transcribed do %> - Transcription - <% end %> -
- <%!-- Minimize button --%> - -
- <%= if @state in [:recording, :processing] do %> -
- - -
- <% end %> +
<%= if @state == :transcribed do %> - <.transcription_display transcription={@transcription} /> - <% end %> - <%= if @state == :idle do %> - - <% end %> - <%= if @state == :recording do %> - - <% end %> - <%= if @state == :transcribed do %> - + + <% else %> + <% end %>
<% end %> @@ -126,14 +59,6 @@ defmodule ElixirAiWeb.VoiceLive do """ end - defp transcription_display(assigns) do - ~H""" -
-

{@transcription}

-
- """ - end - def handle_event("expand", _params, socket) do {:noreply, assign(socket, expanded: true)} end @@ -168,15 +93,254 @@ defmodule ElixirAiWeb.VoiceLive do end def handle_event("dismiss_transcription", _params, socket) do - {:noreply, assign(socket, state: :idle, transcription: nil, expanded: false)} + name = socket.assigns.conversation_name + + if name do + if socket.assigns.runner_pid do + try do + GenServer.call(socket.assigns.runner_pid, {:session, {:deregister_liveview_pid, self()}}) + catch + :exit, _ -> :ok + end + end + + Phoenix.PubSub.unsubscribe(ElixirAi.PubSub, chat_topic(name)) + end + + {:noreply, + assign(socket, + state: :idle, + transcription: nil, + expanded: false, + conversation_name: nil, + messages: [], + streaming_response: nil, + runner_pid: nil, + ai_error: nil + )} end + # Transcription received — open conversation and send as user message def handle_info({:transcription_result, {:ok, text}}, socket) do - {:noreply, assign(socket, state: :transcribed, transcription: text)} + socket = start_voice_conversation(socket, text) + {:noreply, socket} end def handle_info({:transcription_result, {:error, reason}}, socket) do Logger.error("VoiceLive: transcription failed: #{inspect(reason)}") {:noreply, assign(socket, state: :idle)} end + + # --- Chat PubSub handlers (same pattern as ChatLive) --- + + def handle_info({:user_chat_message, message}, socket) do + {:noreply, + socket + |> update(:messages, &(&1 ++ [message])) + |> push_event("scroll_to_bottom", %{})} + end + + def handle_info( + {:start_ai_response_stream, + %{id: _id, reasoning_content: "", content: ""} = starting_response}, + socket + ) do + {:noreply, assign(socket, streaming_response: starting_response)} + end + + def handle_info( + {:reasoning_chunk_content, reasoning_content}, + %{assigns: %{streaming_response: nil}} = socket + ) do + base = get_snapshot(socket) |> Map.update!(:reasoning_content, &(&1 <> reasoning_content)) + {:noreply, assign(socket, streaming_response: base)} + end + + def handle_info({:reasoning_chunk_content, reasoning_content}, socket) do + updated_response = %{ + socket.assigns.streaming_response + | reasoning_content: + socket.assigns.streaming_response.reasoning_content <> reasoning_content + } + + {:noreply, + socket + |> assign(streaming_response: updated_response) + |> push_event("reasoning_chunk", %{chunk: reasoning_content})} + end + + def handle_info( + {:text_chunk_content, text_content}, + %{assigns: %{streaming_response: nil}} = socket + ) do + base = get_snapshot(socket) |> Map.update!(:content, &(&1 <> text_content)) + {:noreply, assign(socket, streaming_response: base)} + end + + def handle_info({:text_chunk_content, text_content}, socket) do + updated_response = %{ + socket.assigns.streaming_response + | content: socket.assigns.streaming_response.content <> text_content + } + + {:noreply, + socket + |> assign(streaming_response: updated_response) + |> push_event("md_chunk", %{chunk: text_content})} + end + + def handle_info(:tool_calls_finished, socket) do + {:noreply, assign(socket, streaming_response: nil)} + end + + def handle_info({:tool_request_message, tool_request_message}, socket) do + {:noreply, update(socket, :messages, &(&1 ++ [tool_request_message]))} + end + + def handle_info({:one_tool_finished, tool_response}, socket) do + {:noreply, update(socket, :messages, &(&1 ++ [tool_response]))} + end + + def handle_info({:end_ai_response, final_message}, socket) do + {:noreply, + socket + |> update(:messages, &(&1 ++ [final_message])) + |> assign(streaming_response: nil)} + end + + def handle_info({:ai_request_error, reason}, socket) do + error_message = + case reason do + "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)} + end + + def handle_info({:db_error, reason}, socket) do + Logger.error("VoiceLive: db error: #{inspect(reason)}") + {:noreply, socket} + end + + def handle_info({:liveview_tool_call, "navigate_to", %{"path" => path}}, socket) do + {:noreply, push_event(socket, "navigate_to", %{path: path})} + end + + def handle_info({:liveview_tool_call, _tool_name, _args}, socket) do + {:noreply, socket} + end + + def handle_info(:sync_streaming, %{assigns: %{runner_pid: pid}} = socket) + when is_pid(pid) do + case GenServer.call(pid, {:conversation, :get_streaming_response}) do + nil -> + {:noreply, assign(socket, streaming_response: nil)} + + %{content: content, reasoning_content: reasoning_content} = snapshot -> + socket = + socket + |> assign(streaming_response: snapshot) + |> then(fn s -> + if content != "", do: push_event(s, "md_chunk", %{chunk: content}), else: s + end) + |> then(fn s -> + if reasoning_content != "", + do: push_event(s, "reasoning_chunk", %{chunk: reasoning_content}), + else: s + end) + + {:noreply, socket} + end + end + + def handle_info(:sync_streaming, socket), do: {:noreply, socket} + + def handle_info(:recovery_restart, socket) do + {:noreply, assign(socket, streaming_response: nil, ai_error: nil)} + end + + # --- Private helpers --- + + defp start_voice_conversation(socket, transcription) do + name = "voice-#{System.system_time(:second)}" + + case AiProvider.find_by_name("default") do + {:ok, provider} -> + case ConversationManager.create_conversation(name, provider.id, "voice") do + {:ok, _pid} -> + case ConversationManager.open_conversation(name) do + {:ok, conv} -> + connect_and_send(socket, name, conv, transcription) + + {:error, reason} -> + assign(socket, + state: :transcribed, + ai_error: "Failed to open voice conversation: #{inspect(reason)}" + ) + end + + {:error, reason} -> + assign(socket, + state: :transcribed, + ai_error: "Failed to create voice conversation: #{inspect(reason)}" + ) + end + + {:error, reason} -> + assign(socket, + state: :transcribed, + ai_error: "No default AI provider found: #{inspect(reason)}" + ) + end + end + + defp connect_and_send(socket, name, conversation, transcription) do + runner_pid = Map.get(conversation, :runner_pid) + + if connected?(socket) do + Phoenix.PubSub.subscribe(ElixirAi.PubSub, chat_topic(name)) + + if runner_pid, + do: GenServer.call(runner_pid, {:session, {:register_liveview_pid, self()}}) + + send(self(), :sync_streaming) + end + + if runner_pid do + GenServer.cast(runner_pid, {:conversation, {:user_message, transcription, nil}}) + else + ChatRunner.new_user_message(name, transcription) + end + + assign(socket, + state: :transcribed, + transcription: transcription, + conversation_name: name, + messages: conversation.messages, + streaming_response: conversation.streaming_response, + runner_pid: runner_pid, + ai_error: nil + ) + end + + defp get_snapshot(%{assigns: %{runner_pid: pid}}) when is_pid(pid) do + case GenServer.call(pid, {:conversation, :get_streaming_response}) do + nil -> %{id: nil, content: "", reasoning_content: "", tool_calls: []} + snapshot -> snapshot + end + end + + defp get_snapshot(_socket) do + %{id: nil, content: "", reasoning_content: "", tool_calls: []} + end end