This commit is contained in:
@@ -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
149
lib/elixir_ai/ai_tools.ex
Normal 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
|
||||||
@@ -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}"}]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
<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"/>
|
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" />
|
||||||
</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
|
||||||
|
|||||||
4
mix.exs
4
mix.exs
@@ -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"},
|
||||||
|
|||||||
@@ -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()
|
||||||
);
|
);
|
||||||
|
|
||||||
Reference in New Issue
Block a user