general solution to voice control
Some checks failed
CI/CD Pipeline / build (push) Failing after 4s

This commit is contained in:
2026-03-25 09:22:48 -06:00
parent 86ff82a015
commit d857e91241
14 changed files with 309 additions and 31 deletions

View File

@@ -1,5 +1,6 @@
defmodule ElixirAiWeb.ChatLive do
use ElixirAiWeb, :live_view
use ElixirAi.AiControllable
require Logger
import ElixirAiWeb.Spinner
import ElixirAiWeb.ChatMessage
@@ -7,6 +8,38 @@ defmodule ElixirAiWeb.ChatLive do
alias ElixirAi.{AiProvider, ChatRunner, ConversationManager}
import ElixirAi.PubsubTopics
@impl ElixirAi.AiControllable
def ai_tools do
[
%{
name: "set_user_input",
description:
"Set the text in the chat input field. Use this to pre-fill a message for the user. " <>
"The user will still need to press Send (or you can describe what you filled in).",
parameters: %{
"type" => "object",
"properties" => %{
"text" => %{
"type" => "string",
"description" => "The text to place in the chat input field"
}
},
"required" => ["text"]
}
}
]
end
@impl ElixirAi.AiControllable
def handle_ai_tool_call("set_user_input", %{"text" => text}, socket) do
{"user input set to: #{text}", assign(socket, user_input: text)}
end
def handle_ai_tool_call(_tool_name, _args, socket) do
{"unknown tool", socket}
end
@impl Phoenix.LiveView
def mount(%{"name" => name}, _session, socket) do
case ConversationManager.open_conversation(name) do
{:ok, conversation} ->
@@ -50,6 +83,7 @@ defmodule ElixirAiWeb.ChatLive do
end
end
@impl Phoenix.LiveView
def render(assigns) do
~H"""
<div class="flex flex-col h-full rounded-lg">
@@ -119,6 +153,7 @@ defmodule ElixirAiWeb.ChatLive do
"""
end
@impl Phoenix.LiveView
def handle_event("update_user_input", %{"user_input" => user_input}, socket) do
{:noreply, assign(socket, user_input: user_input)}
end
@@ -293,6 +328,7 @@ defmodule ElixirAiWeb.ChatLive do
{:noreply, assign(socket, background_color: color)}
end
@impl Phoenix.LiveView
def terminate(_reason, %{assigns: %{conversation_name: name}} = socket) do
if connected?(socket) do
ChatRunner.deregister_liveview_pid(name, self())

View File

@@ -39,7 +39,7 @@ defmodule ElixirAiWeb.ChatMessage do
def user_message(assigns) do
~H"""
<div class="mb-2 text-sm text-right">
<div class={"w-fit px-3 py-2 rounded-lg bg-seafoam-950 text-seafoam-50 #{max_width_class()} text-left"}>
<div class={"ml-auto w-fit px-3 py-2 rounded-lg bg-seafoam-950 text-seafoam-50 #{max_width_class()} text-left"}>
{@content}
</div>
</div>

View File

@@ -0,0 +1,23 @@
defmodule ElixirAiWeb.Plugs.VoiceSessionId do
@moduledoc """
Ensures a `voice_session_id` exists in the Plug session.
This UUID ties VoiceLive (root layout) to page LiveViews (inner content)
so they can discover each other via `:pg` process groups.
"""
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
case get_session(conn, "voice_session_id") do
nil ->
id = Ecto.UUID.generate()
put_session(conn, "voice_session_id", id)
_existing ->
conn
end
end
end

View File

@@ -4,6 +4,7 @@ defmodule ElixirAiWeb.Router do
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug ElixirAiWeb.Plugs.VoiceSessionId
plug :fetch_live_flash
plug :put_root_layout, html: {ElixirAiWeb.Layouts, :root}
plug :protect_from_forgery

View File

@@ -4,10 +4,12 @@ defmodule ElixirAiWeb.VoiceLive do
alias ElixirAiWeb.Voice.Recording
alias ElixirAiWeb.Voice.VoiceConversation
alias ElixirAi.{AiProvider, ChatRunner, ConversationManager}
alias ElixirAi.{AiProvider, AiTools, ChatRunner, ConversationManager}
import ElixirAi.PubsubTopics
def mount(_params, _session, socket) do
def mount(_params, session, socket) do
voice_session_id = session["voice_session_id"]
{:ok,
assign(socket,
state: :idle,
@@ -17,7 +19,8 @@ defmodule ElixirAiWeb.VoiceLive do
messages: [],
streaming_response: nil,
runner_pid: nil,
ai_error: nil
ai_error: nil,
voice_session_id: voice_session_id
), layout: false}
end
@@ -98,7 +101,10 @@ defmodule ElixirAiWeb.VoiceLive do
if name do
if socket.assigns.runner_pid do
try do
GenServer.call(socket.assigns.runner_pid, {:session, {:deregister_liveview_pid, self()}})
GenServer.call(
socket.assigns.runner_pid,
{:session, {:deregister_liveview_pid, self()}}
)
catch
:exit, _ -> :ok
end
@@ -307,30 +313,51 @@ defmodule ElixirAiWeb.VoiceLive do
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))
try do
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()}})
if runner_pid,
do: GenServer.call(runner_pid, {:session, {:register_liveview_pid, self()}})
send(self(), :sync_streaming)
# Discover and register page tools from AiControllable LiveViews
if runner_pid do
page_tools = discover_and_build_page_tools(socket, runner_pid)
if page_tools != [] do
ChatRunner.register_page_tools(name, page_tools)
end
end
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
)
catch
:exit, reason ->
Logger.error("VoiceLive: failed to connect to conversation #{name}: #{inspect(reason)}")
assign(socket,
state: :transcribed,
transcription: transcription,
conversation_name: nil,
ai_error: "Failed to connect to conversation: process unavailable"
)
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
@@ -343,4 +370,33 @@ defmodule ElixirAiWeb.VoiceLive do
defp get_snapshot(_socket) do
%{id: nil, content: "", reasoning_content: "", tool_calls: []}
end
defp discover_and_build_page_tools(socket, runner_pid) do
voice_session_id = socket.assigns.voice_session_id
if voice_session_id == nil, do: throw(:no_session)
page_pids =
try do
:pg.get_members(ElixirAi.PageToolsPG, {:page, voice_session_id})
catch
:error, _ -> []
end
# Ask each page LiveView for its tool specs
Enum.each(page_pids, &send(&1, {:get_ai_tools, self()}))
pids_and_specs =
Enum.reduce(page_pids, [], fn page_pid, acc ->
receive do
{:ai_tools_response, ^page_pid, tools} ->
[{page_pid, tools} | acc]
after
1_000 -> acc
end
end)
AiTools.build_page_tools(runner_pid, pids_and_specs)
catch
:no_session -> []
end
end