From e0ca44df2300a69cc97fee79e9b46e9072c20a45 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 23 Mar 2026 12:34:22 -0600 Subject: [PATCH] improving tool calling tracking --- config/config.exs | 1 + lib/elixir_ai/ai_tools.ex | 149 ++++++++++++++++ lib/elixir_ai/ai_utils/chat_utils.ex | 5 +- lib/elixir_ai/chat_runner.ex | 166 +++++++++++------- lib/elixir_ai/conversation_manager.ex | 21 ++- lib/elixir_ai/data/ai_provider.ex | 4 +- lib/elixir_ai/data/conversation.ex | 116 +++++++++--- lib/elixir_ai/data/message.ex | 5 + lib/elixir_ai_web/home/home_live.ex | 6 + lib/elixir_ai_web/voice/voice_live.ex | 35 +++- mix.exs | 4 +- postgres/schema/{00-schema.sql => schema.sql} | 20 ++- 12 files changed, 417 insertions(+), 115 deletions(-) create mode 100644 lib/elixir_ai/ai_tools.ex rename postgres/schema/{00-schema.sql => schema.sql} (74%) diff --git a/config/config.exs b/config/config.exs index eeb77cd..b145435 100644 --- a/config/config.exs +++ b/config/config.exs @@ -39,6 +39,7 @@ config :logger, :console, metadata: [:request_id] config :phoenix, :json_library, Jason +config :postgrex, :json_library, Jason if System.get_env("RELEASE_MODE") do config :kernel, net_ticktime: 2 diff --git a/lib/elixir_ai/ai_tools.ex b/lib/elixir_ai/ai_tools.ex new file mode 100644 index 0000000..9e9a234 --- /dev/null +++ b/lib/elixir_ai/ai_tools.ex @@ -0,0 +1,149 @@ +defmodule ElixirAi.AiTools do + @moduledoc """ + Central registry of all AI tools available to conversations. + + Tools are split into two categories: + + - **Server tools** (`store_thing`, `read_thing`): functions are fully defined + here and always execute regardless of whether a browser session is open. + + - **LiveView tools** (`set_background_color`, `navigate_to`): functions + dispatch to the registered LiveView pid. If no browser tab is connected + the call still succeeds immediately with a descriptive result so the AI + conversation is never blocked. + + Tool names are stored in the `conversations` table (`allowed_tools` column) + and act as the gate for which tools are active for a given conversation. + """ + + import ElixirAi.ChatUtils, only: [ai_tool: 1] + + @server_tool_names ["store_thing", "read_thing"] + @liveview_tool_names ["set_background_color", "navigate_to"] + @all_tool_names @server_tool_names ++ @liveview_tool_names + + def server_tool_names, do: @server_tool_names + + def liveview_tool_names, do: @liveview_tool_names + + def all_tool_names, do: @all_tool_names + + def build_server_tools(server, allowed_names) do + [store_thing(server), read_thing(server)] + |> Enum.filter(&(&1.name in allowed_names)) + end + + def build_liveview_tools(server, allowed_names) do + [set_background_color(server), navigate_to(server)] + |> Enum.filter(&(&1.name in allowed_names)) + end + + @doc "Convenience wrapper — builds all allowed tools (server + liveview)." + def build(server, allowed_names) do + build_server_tools(server, allowed_names) ++ build_liveview_tools(server, allowed_names) + end + + # --------------------------------------------------------------------------- + # Server tools + # --------------------------------------------------------------------------- + + def store_thing(server) do + ai_tool( + name: "store_thing", + description: "store a key value pair in memory", + function: &ElixirAi.ToolTesting.hold_thing/1, + parameters: ElixirAi.ToolTesting.hold_thing_params(), + server: server + ) + end + + def read_thing(server) do + ai_tool( + name: "read_thing", + description: "read a key value pair that was previously stored with store_thing", + function: &ElixirAi.ToolTesting.get_thing/1, + parameters: ElixirAi.ToolTesting.get_thing_params(), + server: server + ) + end + + # --------------------------------------------------------------------------- + # LiveView tools + # --------------------------------------------------------------------------- + + def set_background_color(server) do + ai_tool( + name: "set_background_color", + description: + "set the background color of the chat interface, accepts specified tailwind colors", + function: fn args -> dispatch_to_liveview(server, "set_background_color", args) end, + parameters: %{ + "type" => "object", + "properties" => %{ + "color" => %{ + "type" => "string", + "enum" => [ + "bg-cyan-950/30", + "bg-red-950/30", + "bg-green-950/30", + "bg-blue-950/30", + "bg-yellow-950/30", + "bg-purple-950/30", + "bg-pink-950/30" + ] + } + }, + "required" => ["color"] + }, + server: server + ) + end + + def navigate_to(server) do + ai_tool( + name: "navigate_to", + description: """ + Navigate the user's browser to a page in the application. + Only use paths that exist in the app: + "/" — home page + "/admin" — admin panel + "/chat/:name" — a chat conversation, where :name is the conversation name + Provide the exact path string including the leading slash. + """, + function: fn args -> dispatch_to_liveview(server, "navigate_to", args) end, + parameters: %{ + "type" => "object", + "properties" => %{ + "path" => %{ + "type" => "string", + "description" => + "The application path to navigate to, e.g. \"/\", \"/admin\", \"/chat/my-chat\"" + } + }, + "required" => ["path"] + }, + server: server + ) + end + + # --------------------------------------------------------------------------- + # Private + # --------------------------------------------------------------------------- + + + defp dispatch_to_liveview(server, tool_name, args) do + case GenServer.call(server, :get_liveview_pid) do + nil -> + {:ok, "no browser session active, #{tool_name} skipped"} + + liveview_pid -> + send(liveview_pid, {:liveview_tool_call, tool_name, args, self()}) + + receive do + {:liveview_tool_result, result} -> result + after + 5_000 -> {:ok, "browser session timed out, #{tool_name} skipped"} + end + end + end +end diff --git a/lib/elixir_ai/ai_utils/chat_utils.ex b/lib/elixir_ai/ai_utils/chat_utils.ex index 30652e2..45697e8 100644 --- a/lib/elixir_ai/ai_utils/chat_utils.ex +++ b/lib/elixir_ai/ai_utils/chat_utils.ex @@ -47,7 +47,7 @@ defmodule ElixirAi.ChatUtils do } end - def request_ai_response(server, messages, tools, provider) do + def request_ai_response(server, messages, tools, provider, tool_choice \\ "auto") do Task.start_link(fn -> api_url = provider.completions_url api_key = provider.api_token @@ -69,7 +69,8 @@ defmodule ElixirAi.ChatUtils do model: model, stream: true, messages: messages |> Enum.map(&api_message/1), - tools: Enum.map(tools, & &1.definition) + tools: Enum.map(tools, & &1.definition), + tool_choice: tool_choice } headers = [{"authorization", "Bearer #{api_key}"}] diff --git a/lib/elixir_ai/chat_runner.ex b/lib/elixir_ai/chat_runner.ex index d133cbb..9584cc5 100644 --- a/lib/elixir_ai/chat_runner.ex +++ b/lib/elixir_ai/chat_runner.ex @@ -1,14 +1,30 @@ defmodule ElixirAi.ChatRunner do require Logger use GenServer - import ElixirAi.ChatUtils, only: [ai_tool: 1] - alias ElixirAi.{Conversation, Message} + alias ElixirAi.{AiTools, Conversation, Message} import ElixirAi.PubsubTopics defp via(name), do: {:via, Horde.Registry, {ElixirAi.ChatRegistry, name}} - def new_user_message(name, text_content) do - GenServer.cast(via(name), {:user_message, text_content}) + def new_user_message(name, text_content, opts \\ []) do + tool_choice = Keyword.get(opts, :tool_choice) + GenServer.cast(via(name), {:user_message, text_content, tool_choice}) + end + + def set_allowed_tools(name, tool_names) when is_list(tool_names) do + GenServer.call(via(name), {:set_allowed_tools, tool_names}) + end + + def set_tool_choice(name, tool_choice) when tool_choice in ["auto", "none", "required"] do + GenServer.call(via(name), {:set_tool_choice, tool_choice}) + end + + def register_liveview_pid(name, liveview_pid) when is_pid(liveview_pid) do + GenServer.call(via(name), {:register_liveview_pid, liveview_pid}) + end + + def deregister_liveview_pid(name) do + GenServer.call(via(name), :deregister_liveview_pid) end @spec get_conversation(String.t()) :: any() @@ -44,13 +60,35 @@ defmodule ElixirAi.ChatRunner do _ -> nil end + allowed_tools = + case Conversation.find_allowed_tools(name) do + {:ok, tools} -> tools + _ -> AiTools.all_tool_names() + end + + tool_choice = + case Conversation.find_tool_choice(name) do + {:ok, tc} -> tc + _ -> "auto" + end + + server_tools = AiTools.build_server_tools(self(), allowed_tools) + liveview_tools = AiTools.build_liveview_tools(self(), allowed_tools) + if last_message && last_message.role == :user do Logger.info( "Last message role was #{last_message.role}, requesting AI response for conversation #{name}" ) broadcast_ui(name, :recovery_restart) - ElixirAi.ChatUtils.request_ai_response(self(), messages, tools(self(), name), provider) + + ElixirAi.ChatUtils.request_ai_response( + self(), + messages, + server_tools ++ liveview_tools, + provider, + tool_choice + ) end {:ok, @@ -59,63 +97,19 @@ defmodule ElixirAi.ChatRunner do messages: messages, streaming_response: nil, pending_tool_calls: [], - tools: tools(self(), name), - provider: provider + allowed_tools: allowed_tools, + tool_choice: tool_choice, + server_tools: server_tools, + liveview_tools: liveview_tools, + provider: provider, + liveview_pid: nil, + liveview_monitor_ref: nil }} end - def tools(server, name) do - [ - ai_tool( - name: "store_thing", - description: "store a key value pair in memory", - function: &ElixirAi.ToolTesting.hold_thing/1, - parameters: ElixirAi.ToolTesting.hold_thing_params(), - server: server - ), - ai_tool( - name: "read_thing", - description: "read a key value pair that was previously stored with store_thing", - function: &ElixirAi.ToolTesting.get_thing/1, - parameters: ElixirAi.ToolTesting.get_thing_params(), - server: server - ), - ai_tool( - name: "set_background_color", - description: - "set the background color of the chat interface, accepts specified tailwind colors", - function: fn %{"color" => color} -> - Phoenix.PubSub.broadcast( - ElixirAi.PubSub, - chat_topic(name), - {:set_background_color, color} - ) - end, - parameters: %{ - "type" => "object", - "properties" => %{ - "color" => %{ - "type" => "string", - "enum" => [ - "bg-cyan-950/30", - "bg-red-950/30", - "bg-green-950/30", - "bg-blue-950/30", - "bg-yellow-950/30", - "bg-purple-950/30", - "bg-pink-950/30" - ] - } - }, - "required" => ["color"] - }, - server: server - ) - ] - end - - def handle_cast({:user_message, text_content}, state) do - new_message = %{role: :user, content: text_content} + def handle_cast({:user_message, text_content, tool_choice_override}, state) do + effective_tool_choice = tool_choice_override || state.tool_choice + new_message = %{role: :user, content: text_content, tool_choice: tool_choice_override} broadcast_ui(state.name, {:user_chat_message, new_message}) store_message(state.name, new_message) new_state = %{state | messages: state.messages ++ [new_message]} @@ -123,8 +117,9 @@ defmodule ElixirAi.ChatRunner do ElixirAi.ChatUtils.request_ai_response( self(), new_state.messages, - state.tools, - state.provider + state.server_tools ++ state.liveview_tools, + state.provider, + effective_tool_choice ) {:noreply, new_state} @@ -269,7 +264,9 @@ defmodule ElixirAi.ChatRunner do {failed, pending} -> with {:ok, decoded_args} <- Jason.decode(tool_call.arguments), tool when not is_nil(tool) <- - Enum.find(state.tools, fn t -> t.name == tool_call.name end) do + Enum.find(state.server_tools ++ state.liveview_tools, fn t -> + t.name == tool_call.name + end) do tool.run_function.(id, tool_call.id, decoded_args) {failed, [tool_call.id | pending]} else @@ -319,8 +316,9 @@ defmodule ElixirAi.ChatRunner do ElixirAi.ChatUtils.request_ai_response( self(), state.messages ++ [new_message], - state.tools, - state.provider + state.server_tools ++ state.liveview_tools, + state.provider, + state.tool_choice ) end @@ -362,6 +360,46 @@ defmodule ElixirAi.ChatRunner do {:reply, state.streaming_response, state} end + def handle_call(:get_liveview_pid, _from, state) do + {:reply, state.liveview_pid, state} + end + + def handle_call({:register_liveview_pid, liveview_pid}, _from, state) do + # Clear any previous monitor + if state.liveview_monitor_ref, do: Process.demonitor(state.liveview_monitor_ref, [:flush]) + ref = Process.monitor(liveview_pid) + {:reply, :ok, %{state | liveview_pid: liveview_pid, liveview_monitor_ref: ref}} + end + + def handle_call(:deregister_liveview_pid, _from, state) do + if state.liveview_monitor_ref, do: Process.demonitor(state.liveview_monitor_ref, [:flush]) + {:reply, :ok, %{state | liveview_pid: nil, liveview_monitor_ref: nil}} + end + + def handle_call({:set_tool_choice, tool_choice}, _from, state) do + Conversation.update_tool_choice(state.name, tool_choice) + {:reply, :ok, %{state | tool_choice: tool_choice}} + end + + def handle_call({:set_allowed_tools, tool_names}, _from, state) do + Conversation.update_allowed_tools(state.name, tool_names) + server_tools = AiTools.build_server_tools(self(), tool_names) + liveview_tools = AiTools.build_liveview_tools(self(), tool_names) + + {:reply, :ok, + %{ + state + | allowed_tools: tool_names, + server_tools: server_tools, + liveview_tools: liveview_tools + }} + end + + def handle_info({:DOWN, ref, :process, _pid, _reason}, %{liveview_monitor_ref: ref} = state) do + Logger.info("ChatRunner #{state.name}: LiveView disconnected, clearing liveview_pid") + {:noreply, %{state | liveview_pid: nil, liveview_monitor_ref: nil}} + end + defp broadcast_ui(name, msg), do: Phoenix.PubSub.broadcast(ElixirAi.PubSub, chat_topic(name), msg) diff --git a/lib/elixir_ai/conversation_manager.ex b/lib/elixir_ai/conversation_manager.ex index d0943c8..d50692f 100644 --- a/lib/elixir_ai/conversation_manager.ex +++ b/lib/elixir_ai/conversation_manager.ex @@ -1,6 +1,6 @@ defmodule ElixirAi.ConversationManager do use GenServer - alias ElixirAi.{Conversation, Message} + alias ElixirAi.{Conversation, Message, AiTools} import ElixirAi.PubsubTopics, only: [conversation_message_topic: 1] require Logger @@ -24,8 +24,9 @@ defmodule ElixirAi.ConversationManager do {:ok, %{conversations: :loading, subscriptions: MapSet.new(), runners: %{}}} end - def create_conversation(name, ai_provider_id) do - GenServer.call(@name, {:create, name, ai_provider_id}) + def create_conversation(name, ai_provider_id, category \\ "user-web", allowed_tools \\ nil) do + tools = allowed_tools || AiTools.all_tool_names() + GenServer.call(@name, {:create, name, ai_provider_id, category, tools}) end def open_conversation(name) do @@ -54,14 +55,14 @@ defmodule ElixirAi.ConversationManager do end def handle_call( - {:create, name, ai_provider_id}, + {:create, name, ai_provider_id, category, allowed_tools}, _from, %{conversations: conversations} = state ) do if Map.has_key?(conversations, name) do {:reply, {:error, :already_exists}, state} else - case Conversation.create(name, ai_provider_id) do + case Conversation.create(name, ai_provider_id, category, allowed_tools) do :ok -> reply_with_started(name, state, fn new_state -> %{new_state | conversations: Map.put(new_state.conversations, name, [])} @@ -152,7 +153,7 @@ defmodule ElixirAi.ConversationManager do end # Returns {pid} to callers that only need to know the process started (e.g. create). - defp reply_with_started(name, state, update_state \\ fn s -> s end) do + defp reply_with_started(name, state, update_state) do case start_and_subscribe(name, state) do {:ok, pid, new_subscriptions, new_runners} -> new_state = @@ -160,8 +161,12 @@ defmodule ElixirAi.ConversationManager do {:reply, {:ok, pid}, new_state} - {:error, _reason} = error -> - {:reply, error, state} + {:error, reason} -> + Logger.error( + "ConversationManager: failed to start runner for #{name}: #{inspect(reason)}" + ) + + {:reply, {:error, :failed_to_load}, state} end end diff --git a/lib/elixir_ai/data/ai_provider.ex b/lib/elixir_ai/data/ai_provider.ex index 3b3fa5c..dcdd1ef 100644 --- a/lib/elixir_ai/data/ai_provider.ex +++ b/lib/elixir_ai/data/ai_provider.ex @@ -11,8 +11,8 @@ defmodule ElixirAi.AiProvider do id: Zoi.optional(Zoi.string()), name: Zoi.string(), model_name: Zoi.string(), - api_token: Zoi.string(), - completions_url: Zoi.string() + api_token: Zoi.nullish(Zoi.string()), + completions_url: Zoi.nullish(Zoi.string()) }) end diff --git a/lib/elixir_ai/data/conversation.ex b/lib/elixir_ai/data/conversation.ex index 77e055c..61a629a 100644 --- a/lib/elixir_ai/data/conversation.ex +++ b/lib/elixir_ai/data/conversation.ex @@ -9,67 +9,69 @@ defmodule ElixirAi.Conversation do Zoi.object(%{ name: Zoi.string(), model_name: Zoi.string(), - api_token: Zoi.string(), - completions_url: Zoi.string() + api_token: Zoi.nullish(Zoi.string()), + completions_url: Zoi.nullish(Zoi.string()) }) end end defmodule ConversationInfo do - defstruct [:name, :provider] + defstruct [:name, :category, :provider] def schema do Zoi.object(%{ name: Zoi.string(), + category: Zoi.string(), provider: Zoi.object(%{ name: Zoi.string(), model_name: Zoi.string(), - api_token: Zoi.string(), - completions_url: Zoi.string() + api_token: Zoi.nullish(Zoi.string()), + completions_url: Zoi.nullish(Zoi.string()) }) }) end end def all_names do - sql = """ - SELECT c.name, - json_build_object( - 'name', p.name, - 'model_name', p.model_name, - 'api_token', p.api_token, - 'completions_url', p.completions_url - ) as provider - FROM conversations c - LEFT JOIN ai_providers p ON c.ai_provider_id = p.id - """ - + sql = "SELECT name, category FROM conversations" params = %{} - case DbHelpers.run_sql(sql, params, "conversations", ConversationInfo.schema()) do + schema = Zoi.object(%{name: Zoi.string(), category: Zoi.string()}) + + case DbHelpers.run_sql(sql, params, "conversations", schema) do {:error, _} -> [] rows -> Enum.map(rows, fn row -> - struct(ConversationInfo, Map.put(row, :provider, struct(Provider, row.provider))) + struct(ConversationInfo, row) end) end end - def create(name, ai_provider_id) when is_binary(ai_provider_id) do + def create(name, ai_provider_id, category \\ "user-web", allowed_tools \\ nil) + + def create(name, ai_provider_id, category, nil), + do: create(name, ai_provider_id, category, ElixirAi.AiTools.all_tool_names()) + + def create(name, ai_provider_id, category, allowed_tools) + when is_binary(ai_provider_id) and is_binary(category) and is_list(allowed_tools) do case Ecto.UUID.dump(ai_provider_id) do {:ok, binary_id} -> sql = """ INSERT INTO conversations ( name, ai_provider_id, + category, + allowed_tools, inserted_at, updated_at) VALUES ( $(name), $(ai_provider_id), + $(category), + $(allowed_tools)::jsonb, $(inserted_at), $(updated_at) ) @@ -80,6 +82,8 @@ defmodule ElixirAi.Conversation do params = %{ "name" => name, "ai_provider_id" => binary_id, + "category" => category, + "allowed_tools" => Jason.encode!(allowed_tools), "inserted_at" => timestamp, "updated_at" => timestamp } @@ -97,6 +101,78 @@ defmodule ElixirAi.Conversation do end end + def find_allowed_tools(name) do + sql = "SELECT allowed_tools 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, decode_json_list(row["allowed_tools"])} + end + end + + defp decode_json_list(value) when is_list(value), do: value + + defp decode_json_list(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, list} when is_list(list) -> list + _ -> [] + end + end + + defp decode_json_list(_), do: [] + + def update_allowed_tools(name, tool_names) when is_list(tool_names) do + sql = """ + UPDATE conversations + SET allowed_tools = $(allowed_tools)::jsonb, updated_at = $(updated_at) + WHERE name = $(name) + """ + + params = %{ + "name" => name, + "allowed_tools" => Jason.encode!(tool_names), + "updated_at" => now() + } + + case DbHelpers.run_sql(sql, params, "conversations") do + {:error, :db_error} -> {:error, :db_error} + _ -> :ok + end + end + + def find_tool_choice(name) do + sql = "SELECT tool_choice 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["tool_choice"] || "auto"} + end + end + + def update_tool_choice(name, tool_choice) + when tool_choice in ["auto", "none", "required"] do + sql = """ + UPDATE conversations + SET tool_choice = $(tool_choice), updated_at = $(updated_at) + WHERE name = $(name) + """ + + params = %{ + "name" => name, + "tool_choice" => tool_choice, + "updated_at" => now() + } + + case DbHelpers.run_sql(sql, params, "conversations") do + {:error, :db_error} -> {:error, :db_error} + _ -> :ok + end + end + def find_id(name) do sql = "SELECT id FROM conversations WHERE name = $(name) LIMIT 1" params = %{"name" => name} diff --git a/lib/elixir_ai/data/message.ex b/lib/elixir_ai/data/message.ex index 1800a29..5430ab2 100644 --- a/lib/elixir_ai/data/message.ex +++ b/lib/elixir_ai/data/message.ex @@ -25,6 +25,7 @@ defmodule ElixirAi.Message do role: Zoi.string(), content: Zoi.nullish(Zoi.string()), reasoning_content: Zoi.nullish(Zoi.string()), + tool_choice: Zoi.nullish(Zoi.string()), inserted_at: Zoi.any() }) end @@ -125,6 +126,7 @@ defmodule ElixirAi.Message do tm.role, tm.content, tm.reasoning_content, + tm.tool_choice, tm.inserted_at FROM text_messages tm WHERE tm.conversation_id = $(conversation_id) @@ -222,6 +224,7 @@ defmodule ElixirAi.Message do prev_message_table, role, content, + tool_choice, inserted_at ) VALUES ( $(conversation_id), @@ -229,6 +232,7 @@ defmodule ElixirAi.Message do $(prev_message_table), $(role), $(content), + $(tool_choice), $(inserted_at) ) """ @@ -239,6 +243,7 @@ defmodule ElixirAi.Message do "prev_message_table" => prev_table, "role" => "user", "content" => message[:content], + "tool_choice" => message[:tool_choice], "inserted_at" => timestamp } diff --git a/lib/elixir_ai_web/home/home_live.ex b/lib/elixir_ai_web/home/home_live.ex index 02fb21b..fa49937 100644 --- a/lib/elixir_ai_web/home/home_live.ex +++ b/lib/elixir_ai_web/home/home_live.ex @@ -111,6 +111,12 @@ defmodule ElixirAiWeb.HomeLive do {:error, :already_exists} -> {:noreply, assign(socket, error: "A conversation with that name already exists")} + {:error, :failed_to_load} -> + {:noreply, + assign(socket, + error: "Conversation was saved but failed to load" + )} + _ -> {:noreply, assign(socket, error: "Failed to create conversation")} end diff --git a/lib/elixir_ai_web/voice/voice_live.ex b/lib/elixir_ai_web/voice/voice_live.ex index 3be2adb..68cb64a 100644 --- a/lib/elixir_ai_web/voice/voice_live.ex +++ b/lib/elixir_ai_web/voice/voice_live.ex @@ -12,21 +12,28 @@ defmodule ElixirAiWeb.VoiceLive do
<%= if @state == :idle do %> - - + + Voice Input <% end %> <%= if @state == :recording do %> - + + Recording <% end %> <%= if @state == :processing do %> - + + Processing… @@ -42,9 +49,7 @@ defmodule ElixirAiWeb.VoiceLive do
<% end %> <%= if @state == :transcribed do %> -
-

{@transcription}

-
+ <.transcription_display transcription={@transcription} /> <% end %> <%= if @state == :idle do %> <% end %> <%= if @state == :recording do %> @@ -61,7 +68,9 @@ defmodule ElixirAiWeb.VoiceLive do class="w-full flex items-center justify-between px-3 py-1.5 rounded-lg bg-cyan-800 hover:bg-cyan-700 text-cyan-50 text-xs font-medium transition-colors border border-cyan-700" > Stop Recording - Space + + Space + <% end %> <%= if @state == :transcribed do %> @@ -77,6 +86,14 @@ defmodule ElixirAiWeb.VoiceLive do """ end + defp transcription_display(assigns) do + ~H""" +
+

{@transcription}

+
+ """ + end + def handle_event("recording_started", _params, socket) do {:noreply, assign(socket, state: :recording)} end diff --git a/mix.exs b/mix.exs index 248e3f8..b8144ae 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule ElixirAi.MixProject do [ app: :elixir_ai, version: "0.1.0", - elixir: "~> 1.19", + elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), @@ -57,7 +57,7 @@ defmodule ElixirAi.MixProject do {:libcluster, "~> 3.3"}, {:bandit, "~> 1.5"}, {:ecto_sql, "~> 3.11"}, - {:postgrex, ">= 0.0.0"}, + {:postgrex, ">= 0.22.0"}, {:horde, "~> 0.9"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:mimic, "~> 2.3.0"}, diff --git a/postgres/schema/00-schema.sql b/postgres/schema/schema.sql similarity index 74% rename from postgres/schema/00-schema.sql rename to postgres/schema/schema.sql index e882fec..1e174aa 100644 --- a/postgres/schema/00-schema.sql +++ b/postgres/schema/schema.sql @@ -14,19 +14,23 @@ CREATE TABLE IF NOT EXISTS conversations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL UNIQUE, ai_provider_id UUID NOT NULL REFERENCES ai_providers(id) ON DELETE RESTRICT, + category TEXT NOT NULL DEFAULT 'user-web', + allowed_tools JSONB NOT NULL DEFAULT '[]', + tool_choice TEXT NOT NULL DEFAULT 'auto' CHECK (tool_choice IN ('auto', 'none', 'required')), inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS text_messages ( - id BIGSERIAL PRIMARY KEY, - conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - prev_message_id BIGINT, - prev_message_table TEXT CHECK (prev_message_table IN ('text_messages', 'tool_calls_request_messages', 'tool_response_messages')), - role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), - content TEXT, - reasoning_content TEXT, - inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id BIGSERIAL PRIMARY KEY, + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + prev_message_id BIGINT, + prev_message_table TEXT CHECK (prev_message_table IN ('text_messages', 'tool_calls_request_messages', 'tool_response_messages')), + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), + content TEXT, + reasoning_content TEXT, + tool_choice TEXT CHECK (tool_choice IN ('auto', 'none', 'required')), + inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS tool_calls_request_messages (