improving tool calling tracking
Some checks failed
CI/CD Pipeline / build (push) Failing after 5s

This commit is contained in:
2026-03-23 12:34:22 -06:00
parent 6fc4a686f8
commit e0ca44df23
12 changed files with 417 additions and 115 deletions

View File

@@ -39,6 +39,7 @@ config :logger, :console,
metadata: [:request_id] metadata: [:request_id]
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason
config :postgrex, :json_library, Jason
if System.get_env("RELEASE_MODE") do if System.get_env("RELEASE_MODE") do
config :kernel, net_ticktime: 2 config :kernel, net_ticktime: 2

149
lib/elixir_ai/ai_tools.ex Normal file
View File

@@ -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

View File

@@ -47,7 +47,7 @@ defmodule ElixirAi.ChatUtils do
} }
end 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 -> Task.start_link(fn ->
api_url = provider.completions_url api_url = provider.completions_url
api_key = provider.api_token api_key = provider.api_token
@@ -69,7 +69,8 @@ defmodule ElixirAi.ChatUtils do
model: model, model: model,
stream: true, stream: true,
messages: messages |> Enum.map(&api_message/1), 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}"}] headers = [{"authorization", "Bearer #{api_key}"}]

View File

@@ -1,14 +1,30 @@
defmodule ElixirAi.ChatRunner do defmodule ElixirAi.ChatRunner do
require Logger require Logger
use GenServer use GenServer
import ElixirAi.ChatUtils, only: [ai_tool: 1] alias ElixirAi.{AiTools, Conversation, Message}
alias ElixirAi.{Conversation, Message}
import ElixirAi.PubsubTopics import ElixirAi.PubsubTopics
defp via(name), do: {:via, Horde.Registry, {ElixirAi.ChatRegistry, name}} defp via(name), do: {:via, Horde.Registry, {ElixirAi.ChatRegistry, name}}
def new_user_message(name, text_content) do def new_user_message(name, text_content, opts \\ []) do
GenServer.cast(via(name), {:user_message, text_content}) 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 end
@spec get_conversation(String.t()) :: any() @spec get_conversation(String.t()) :: any()
@@ -44,13 +60,35 @@ defmodule ElixirAi.ChatRunner do
_ -> nil _ -> nil
end 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 if last_message && last_message.role == :user do
Logger.info( Logger.info(
"Last message role was #{last_message.role}, requesting AI response for conversation #{name}" "Last message role was #{last_message.role}, requesting AI response for conversation #{name}"
) )
broadcast_ui(name, :recovery_restart) 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 end
{:ok, {:ok,
@@ -59,63 +97,19 @@ defmodule ElixirAi.ChatRunner do
messages: messages, messages: messages,
streaming_response: nil, streaming_response: nil,
pending_tool_calls: [], pending_tool_calls: [],
tools: tools(self(), name), allowed_tools: allowed_tools,
provider: provider tool_choice: tool_choice,
server_tools: server_tools,
liveview_tools: liveview_tools,
provider: provider,
liveview_pid: nil,
liveview_monitor_ref: nil
}} }}
end end
def tools(server, name) do def handle_cast({:user_message, text_content, tool_choice_override}, state) do
[ effective_tool_choice = tool_choice_override || state.tool_choice
ai_tool( new_message = %{role: :user, content: text_content, tool_choice: tool_choice_override}
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}
broadcast_ui(state.name, {:user_chat_message, new_message}) broadcast_ui(state.name, {:user_chat_message, new_message})
store_message(state.name, new_message) store_message(state.name, new_message)
new_state = %{state | messages: state.messages ++ [new_message]} new_state = %{state | messages: state.messages ++ [new_message]}
@@ -123,8 +117,9 @@ defmodule ElixirAi.ChatRunner do
ElixirAi.ChatUtils.request_ai_response( ElixirAi.ChatUtils.request_ai_response(
self(), self(),
new_state.messages, new_state.messages,
state.tools, state.server_tools ++ state.liveview_tools,
state.provider state.provider,
effective_tool_choice
) )
{:noreply, new_state} {:noreply, new_state}
@@ -269,7 +264,9 @@ defmodule ElixirAi.ChatRunner do
{failed, pending} -> {failed, pending} ->
with {:ok, decoded_args} <- Jason.decode(tool_call.arguments), with {:ok, decoded_args} <- Jason.decode(tool_call.arguments),
tool when not is_nil(tool) <- 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) tool.run_function.(id, tool_call.id, decoded_args)
{failed, [tool_call.id | pending]} {failed, [tool_call.id | pending]}
else else
@@ -319,8 +316,9 @@ defmodule ElixirAi.ChatRunner do
ElixirAi.ChatUtils.request_ai_response( ElixirAi.ChatUtils.request_ai_response(
self(), self(),
state.messages ++ [new_message], state.messages ++ [new_message],
state.tools, state.server_tools ++ state.liveview_tools,
state.provider state.provider,
state.tool_choice
) )
end end
@@ -362,6 +360,46 @@ defmodule ElixirAi.ChatRunner do
{:reply, state.streaming_response, state} {:reply, state.streaming_response, state}
end 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), defp broadcast_ui(name, msg),
do: Phoenix.PubSub.broadcast(ElixirAi.PubSub, chat_topic(name), msg) do: Phoenix.PubSub.broadcast(ElixirAi.PubSub, chat_topic(name), msg)

View File

@@ -1,6 +1,6 @@
defmodule ElixirAi.ConversationManager do defmodule ElixirAi.ConversationManager do
use GenServer use GenServer
alias ElixirAi.{Conversation, Message} alias ElixirAi.{Conversation, Message, AiTools}
import ElixirAi.PubsubTopics, only: [conversation_message_topic: 1] import ElixirAi.PubsubTopics, only: [conversation_message_topic: 1]
require Logger require Logger
@@ -24,8 +24,9 @@ defmodule ElixirAi.ConversationManager do
{:ok, %{conversations: :loading, subscriptions: MapSet.new(), runners: %{}}} {:ok, %{conversations: :loading, subscriptions: MapSet.new(), runners: %{}}}
end end
def create_conversation(name, ai_provider_id) do def create_conversation(name, ai_provider_id, category \\ "user-web", allowed_tools \\ nil) do
GenServer.call(@name, {:create, name, ai_provider_id}) tools = allowed_tools || AiTools.all_tool_names()
GenServer.call(@name, {:create, name, ai_provider_id, category, tools})
end end
def open_conversation(name) do def open_conversation(name) do
@@ -54,14 +55,14 @@ defmodule ElixirAi.ConversationManager do
end end
def handle_call( def handle_call(
{:create, name, ai_provider_id}, {:create, name, ai_provider_id, category, allowed_tools},
_from, _from,
%{conversations: conversations} = state %{conversations: conversations} = state
) do ) do
if Map.has_key?(conversations, name) do if Map.has_key?(conversations, name) do
{:reply, {:error, :already_exists}, state} {:reply, {:error, :already_exists}, state}
else else
case Conversation.create(name, ai_provider_id) do case Conversation.create(name, ai_provider_id, category, allowed_tools) do
:ok -> :ok ->
reply_with_started(name, state, fn new_state -> reply_with_started(name, state, fn new_state ->
%{new_state | conversations: Map.put(new_state.conversations, name, [])} %{new_state | conversations: Map.put(new_state.conversations, name, [])}
@@ -152,7 +153,7 @@ defmodule ElixirAi.ConversationManager do
end end
# Returns {pid} to callers that only need to know the process started (e.g. create). # 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 case start_and_subscribe(name, state) do
{:ok, pid, new_subscriptions, new_runners} -> {:ok, pid, new_subscriptions, new_runners} ->
new_state = new_state =
@@ -160,8 +161,12 @@ defmodule ElixirAi.ConversationManager do
{:reply, {:ok, pid}, new_state} {:reply, {:ok, pid}, new_state}
{:error, _reason} = error -> {:error, reason} ->
{:reply, error, state} Logger.error(
"ConversationManager: failed to start runner for #{name}: #{inspect(reason)}"
)
{:reply, {:error, :failed_to_load}, state}
end end
end end

View File

@@ -11,8 +11,8 @@ defmodule ElixirAi.AiProvider do
id: Zoi.optional(Zoi.string()), id: Zoi.optional(Zoi.string()),
name: Zoi.string(), name: Zoi.string(),
model_name: Zoi.string(), model_name: Zoi.string(),
api_token: Zoi.string(), api_token: Zoi.nullish(Zoi.string()),
completions_url: Zoi.string() completions_url: Zoi.nullish(Zoi.string())
}) })
end end

View File

@@ -9,67 +9,69 @@ defmodule ElixirAi.Conversation do
Zoi.object(%{ Zoi.object(%{
name: Zoi.string(), name: Zoi.string(),
model_name: Zoi.string(), model_name: Zoi.string(),
api_token: Zoi.string(), api_token: Zoi.nullish(Zoi.string()),
completions_url: Zoi.string() completions_url: Zoi.nullish(Zoi.string())
}) })
end end
end end
defmodule ConversationInfo do defmodule ConversationInfo do
defstruct [:name, :provider] defstruct [:name, :category, :provider]
def schema do def schema do
Zoi.object(%{ Zoi.object(%{
name: Zoi.string(), name: Zoi.string(),
category: Zoi.string(),
provider: provider:
Zoi.object(%{ Zoi.object(%{
name: Zoi.string(), name: Zoi.string(),
model_name: Zoi.string(), model_name: Zoi.string(),
api_token: Zoi.string(), api_token: Zoi.nullish(Zoi.string()),
completions_url: Zoi.string() completions_url: Zoi.nullish(Zoi.string())
}) })
}) })
end end
end end
def all_names do def all_names do
sql = """ sql = "SELECT name, category FROM conversations"
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
"""
params = %{} 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, _} -> {:error, _} ->
[] []
rows -> rows ->
Enum.map(rows, fn row -> Enum.map(rows, fn row ->
struct(ConversationInfo, Map.put(row, :provider, struct(Provider, row.provider))) struct(ConversationInfo, row)
end) end)
end 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 case Ecto.UUID.dump(ai_provider_id) do
{:ok, binary_id} -> {:ok, binary_id} ->
sql = """ sql = """
INSERT INTO conversations ( INSERT INTO conversations (
name, name,
ai_provider_id, ai_provider_id,
category,
allowed_tools,
inserted_at, inserted_at,
updated_at) updated_at)
VALUES ( VALUES (
$(name), $(name),
$(ai_provider_id), $(ai_provider_id),
$(category),
$(allowed_tools)::jsonb,
$(inserted_at), $(inserted_at),
$(updated_at) $(updated_at)
) )
@@ -80,6 +82,8 @@ defmodule ElixirAi.Conversation do
params = %{ params = %{
"name" => name, "name" => name,
"ai_provider_id" => binary_id, "ai_provider_id" => binary_id,
"category" => category,
"allowed_tools" => Jason.encode!(allowed_tools),
"inserted_at" => timestamp, "inserted_at" => timestamp,
"updated_at" => timestamp "updated_at" => timestamp
} }
@@ -97,6 +101,78 @@ defmodule ElixirAi.Conversation do
end end
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 def find_id(name) do
sql = "SELECT id FROM conversations WHERE name = $(name) LIMIT 1" sql = "SELECT id FROM conversations WHERE name = $(name) LIMIT 1"
params = %{"name" => name} params = %{"name" => name}

View File

@@ -25,6 +25,7 @@ defmodule ElixirAi.Message do
role: Zoi.string(), role: Zoi.string(),
content: Zoi.nullish(Zoi.string()), content: Zoi.nullish(Zoi.string()),
reasoning_content: Zoi.nullish(Zoi.string()), reasoning_content: Zoi.nullish(Zoi.string()),
tool_choice: Zoi.nullish(Zoi.string()),
inserted_at: Zoi.any() inserted_at: Zoi.any()
}) })
end end
@@ -125,6 +126,7 @@ defmodule ElixirAi.Message do
tm.role, tm.role,
tm.content, tm.content,
tm.reasoning_content, tm.reasoning_content,
tm.tool_choice,
tm.inserted_at tm.inserted_at
FROM text_messages tm FROM text_messages tm
WHERE tm.conversation_id = $(conversation_id) WHERE tm.conversation_id = $(conversation_id)
@@ -222,6 +224,7 @@ defmodule ElixirAi.Message do
prev_message_table, prev_message_table,
role, role,
content, content,
tool_choice,
inserted_at inserted_at
) VALUES ( ) VALUES (
$(conversation_id), $(conversation_id),
@@ -229,6 +232,7 @@ defmodule ElixirAi.Message do
$(prev_message_table), $(prev_message_table),
$(role), $(role),
$(content), $(content),
$(tool_choice),
$(inserted_at) $(inserted_at)
) )
""" """
@@ -239,6 +243,7 @@ defmodule ElixirAi.Message do
"prev_message_table" => prev_table, "prev_message_table" => prev_table,
"role" => "user", "role" => "user",
"content" => message[:content], "content" => message[:content],
"tool_choice" => message[:tool_choice],
"inserted_at" => timestamp "inserted_at" => timestamp
} }

View File

@@ -111,6 +111,12 @@ defmodule ElixirAiWeb.HomeLive do
{:error, :already_exists} -> {:error, :already_exists} ->
{:noreply, assign(socket, error: "A conversation with that name 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")} {:noreply, assign(socket, error: "Failed to create conversation")}
end end

View File

@@ -12,21 +12,28 @@ defmodule ElixirAiWeb.VoiceLive do
<div class="fixed top-4 right-4 w-72 bg-cyan-950/95 border border-cyan-800 rounded-2xl shadow-2xl z-50 p-4 flex flex-col gap-3 backdrop-blur"> <div class="fixed top-4 right-4 w-72 bg-cyan-950/95 border border-cyan-800 rounded-2xl shadow-2xl z-50 p-4 flex flex-col gap-3 backdrop-blur">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<%= if @state == :idle do %> <%= if @state == :idle do %>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-cyan-500 shrink-0" viewBox="0 0 24 24" fill="currentColor"> <svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-cyan-500 shrink-0"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 1a4 4 0 0 1 4 4v7a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v7a2 2 0 1 0 4 0V5a2 2 0 0 0-2-2zm-7 9a7 7 0 0 0 14 0h2a9 9 0 0 1-8 8.94V23h-2v-2.06A9 9 0 0 1 3 12H5z" /> <path d="M12 1a4 4 0 0 1 4 4v7a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v7a2 2 0 1 0 4 0V5a2 2 0 0 0-2-2zm-7 9a7 7 0 0 0 14 0h2a9 9 0 0 1-8 8.94V23h-2v-2.06A9 9 0 0 1 3 12H5z" />
</svg> </svg>
<span class="text-cyan-400 font-semibold text-sm">Voice Input</span> <span class="text-cyan-400 font-semibold text-sm">Voice Input</span>
<% end %> <% end %>
<%= if @state == :recording do %> <%= if @state == :recording do %>
<span class="relative flex h-3 w-3 shrink-0"> <span class="relative flex h-3 w-3 shrink-0">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"></span> <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75">
</span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span> <span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span> </span>
<span class="text-cyan-50 font-semibold text-sm">Recording</span> <span class="text-cyan-50 font-semibold text-sm">Recording</span>
<% end %> <% end %>
<%= if @state == :processing do %> <%= if @state == :processing do %>
<span class="relative flex h-3 w-3 shrink-0"> <span class="relative flex h-3 w-3 shrink-0">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span> <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75">
</span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-cyan-400"></span> <span class="relative inline-flex rounded-full h-3 w-3 bg-cyan-400"></span>
</span> </span>
<span class="text-cyan-50 font-semibold text-sm">Processing…</span> <span class="text-cyan-50 font-semibold text-sm">Processing…</span>
@@ -42,9 +49,7 @@ defmodule ElixirAiWeb.VoiceLive do
</div> </div>
<% end %> <% end %>
<%= if @state == :transcribed do %> <%= if @state == :transcribed do %>
<div class="rounded-xl bg-cyan-900/60 border border-cyan-700 px-3 py-2"> <.transcription_display transcription={@transcription} />
<p class="text-sm text-cyan-50 leading-relaxed">{@transcription}</p>
</div>
<% end %> <% end %>
<%= if @state == :idle do %> <%= if @state == :idle do %>
<button <button
@@ -52,7 +57,9 @@ defmodule ElixirAiWeb.VoiceLive do
class="w-full flex items-center justify-between px-3 py-1.5 rounded-lg bg-cyan-700 hover:bg-cyan-600 text-cyan-50 text-xs font-medium transition-colors" class="w-full flex items-center justify-between px-3 py-1.5 rounded-lg bg-cyan-700 hover:bg-cyan-600 text-cyan-50 text-xs font-medium transition-colors"
> >
<span>Start Recording</span> <span>Start Recording</span>
<kbd class="text-cyan-300 bg-cyan-800 border border-cyan-600 px-1.5 py-0.5 rounded font-mono">Ctrl+Space</kbd> <kbd class="text-cyan-300 bg-cyan-800 border border-cyan-600 px-1.5 py-0.5 rounded font-mono">
Ctrl+Space
</kbd>
</button> </button>
<% end %> <% end %>
<%= if @state == :recording do %> <%= 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" 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"
> >
<span>Stop Recording</span> <span>Stop Recording</span>
<kbd class="text-cyan-300 bg-cyan-900 border border-cyan-700 px-1.5 py-0.5 rounded font-mono">Space</kbd> <kbd class="text-cyan-300 bg-cyan-900 border border-cyan-700 px-1.5 py-0.5 rounded font-mono">
Space
</kbd>
</button> </button>
<% end %> <% end %>
<%= if @state == :transcribed do %> <%= if @state == :transcribed do %>
@@ -77,6 +86,14 @@ defmodule ElixirAiWeb.VoiceLive do
""" """
end end
defp transcription_display(assigns) do
~H"""
<div class="rounded-xl bg-cyan-900/60 border border-cyan-700 px-3 py-2">
<p class="text-sm text-cyan-50 leading-relaxed">{@transcription}</p>
</div>
"""
end
def handle_event("recording_started", _params, socket) do def handle_event("recording_started", _params, socket) do
{:noreply, assign(socket, state: :recording)} {:noreply, assign(socket, state: :recording)}
end end

View File

@@ -5,7 +5,7 @@ defmodule ElixirAi.MixProject do
[ [
app: :elixir_ai, app: :elixir_ai,
version: "0.1.0", version: "0.1.0",
elixir: "~> 1.19", elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
aliases: aliases(), aliases: aliases(),
@@ -57,7 +57,7 @@ defmodule ElixirAi.MixProject do
{:libcluster, "~> 3.3"}, {:libcluster, "~> 3.3"},
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},
{:ecto_sql, "~> 3.11"}, {:ecto_sql, "~> 3.11"},
{:postgrex, ">= 0.0.0"}, {:postgrex, ">= 0.22.0"},
{:horde, "~> 0.9"}, {:horde, "~> 0.9"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:mimic, "~> 2.3.0"}, {:mimic, "~> 2.3.0"},

View File

@@ -14,6 +14,9 @@ CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
ai_provider_id UUID NOT NULL REFERENCES ai_providers(id) ON DELETE RESTRICT, 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(), inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -26,6 +29,7 @@ CREATE TABLE IF NOT EXISTS text_messages (
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT, content TEXT,
reasoning_content TEXT, reasoning_content TEXT,
tool_choice TEXT CHECK (tool_choice IN ('auto', 'none', 'required')),
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );