persisting in postgres

This commit is contained in:
2026-03-06 15:26:55 -07:00
parent b9db6408b1
commit 8059048db2
13 changed files with 255 additions and 46 deletions

View File

@@ -72,7 +72,7 @@ Hooks.MarkdownStream = {
Hooks.ScrollBottom = {
mounted() {
this.scrollToBottom()
requestAnimationFrame(() => this.scrollToBottom())
this.observer = new MutationObserver(() => {
if (this.isNearBottom()) this.scrollToBottom()
})

View File

@@ -8,6 +8,7 @@
import Config
config :elixir_ai,
ecto_repos: [ElixirAi.Repo],
generators: [timestamp_type: :utc_datetime]
# Configures the endpoint

View File

@@ -1,5 +1,14 @@
import Config
config :elixir_ai, ElixirAi.Repo,
username: "elixir_ai",
password: "elixir_ai",
hostname: "localhost",
database: "elixir_ai_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
#

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
db:
image: postgres:17
environment:
POSTGRES_USER: elixir_ai
POSTGRES_PASSWORD: elixir_ai
POSTGRES_DB: elixir_ai_dev
ports:
- 5432:5432
volumes:
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql

View File

@@ -6,6 +6,7 @@ defmodule ElixirAi.Application do
def start(_type, _args) do
children = [
ElixirAiWeb.Telemetry,
ElixirAi.Repo,
{DNSCluster, query: Application.get_env(:elixir_ai, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: ElixirAi.PubSub},
ElixirAi.ToolTesting,

View File

@@ -2,9 +2,11 @@ defmodule ElixirAi.ChatRunner do
require Logger
use GenServer
import ElixirAi.ChatUtils
alias ElixirAi.{Conversation, Message}
defp via(name), do: {:via, Registry, {ElixirAi.ChatRegistry, name}}
defp topic(name), do: "ai_chat:#{name}"
defp message_topic(name), do: "conversation_messages:#{name}"
def new_user_message(name, text_content) do
GenServer.cast(via(name), {:user_message, text_content})
@@ -24,13 +26,20 @@ defmodule ElixirAi.ChatRunner do
end
def init(name) do
{:ok, %{
name: name,
messages: [],
streaming_response: nil,
pending_tool_calls: [],
tools: tools(self(), name)
}}
messages =
case Conversation.find_id(name) do
{:ok, conv_id} -> Message.load_for_conversation(conv_id)
_ -> []
end
{:ok,
%{
name: name,
messages: messages,
streaming_response: nil,
pending_tool_calls: [],
tools: tools(self(), name)
}}
end
def tools(server, name) do
@@ -54,7 +63,11 @@ defmodule ElixirAi.ChatRunner do
description:
"set the background color of the chat interface, accepts specified tailwind colors",
function: fn %{"color" => color} ->
Phoenix.PubSub.broadcast(ElixirAi.PubSub, "ai_chat:#{name}", {:set_background_color, color})
Phoenix.PubSub.broadcast(
ElixirAi.PubSub,
"ai_chat:#{name}",
{:set_background_color, color}
)
end,
parameters: %{
"type" => "object",
@@ -73,7 +86,8 @@ defmodule ElixirAi.ChatRunner do
def handle_cast({:user_message, text_content}, state) do
new_message = %{role: :user, content: text_content}
broadcast(state.name, {:user_chat_message, new_message})
broadcast_ui(state.name, {:user_chat_message, new_message})
store_message(state.name, new_message)
new_state = %{state | messages: state.messages ++ [new_message]}
request_ai_response(self(), new_state.messages, state.tools)
@@ -82,7 +96,7 @@ defmodule ElixirAi.ChatRunner do
def handle_info({:start_new_ai_response, id}, state) do
starting_response = %{id: id, reasoning_content: "", content: "", tool_calls: []}
broadcast(state.name, {:start_ai_response_stream, starting_response})
broadcast_ui(state.name, {:start_ai_response_stream, starting_response})
{:noreply, %{state | streaming_response: starting_response}}
end
@@ -100,7 +114,7 @@ defmodule ElixirAi.ChatRunner do
end
def handle_info({:ai_reasoning_chunk, _id, reasoning_content}, state) do
broadcast(state.name, {:reasoning_chunk_content, reasoning_content})
broadcast_ui(state.name, {:reasoning_chunk_content, reasoning_content})
{:noreply,
%{
@@ -113,7 +127,7 @@ defmodule ElixirAi.ChatRunner do
end
def handle_info({:ai_text_chunk, _id, text_content}, state) do
broadcast(state.name, {:text_chunk_content, text_content})
broadcast_ui(state.name, {:text_chunk_content, text_content})
{:noreply,
%{
@@ -137,7 +151,8 @@ defmodule ElixirAi.ChatRunner do
tool_calls: state.streaming_response.tool_calls
}
broadcast(state.name, {:end_ai_response, final_message})
broadcast_ui(state.name, {:end_ai_response, final_message})
store_message(state.name, final_message)
{:noreply,
%{
@@ -202,13 +217,14 @@ defmodule ElixirAi.ChatRunner do
tool_calls: state.streaming_response.tool_calls
}
broadcast(state.name, {:tool_request_message, tool_request_message})
broadcast_ui(state.name, {:tool_request_message, tool_request_message})
{failed_call_messages, pending_call_ids} =
Enum.reduce(state.streaming_response.tool_calls, {[], []}, fn tool_call, {failed, pending} ->
Enum.reduce(state.streaming_response.tool_calls, {[], []}, fn tool_call,
{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
tool when not is_nil(tool) <-
Enum.find(state.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
@@ -224,6 +240,8 @@ defmodule ElixirAi.ChatRunner do
end
end)
store_message(state.name, [tool_request_message] ++ failed_call_messages)
{:noreply,
%{
state
@@ -235,7 +253,8 @@ defmodule ElixirAi.ChatRunner do
def handle_info({:tool_response, _id, tool_call_id, result}, state) do
new_message = %{role: :tool, content: inspect(result), tool_call_id: tool_call_id}
broadcast(state.name, {:one_tool_finished, new_message})
broadcast_ui(state.name, {:one_tool_finished, new_message})
store_message(state.name, new_message)
new_pending_tool_calls =
Enum.filter(state.pending_tool_calls, fn id -> id != tool_call_id end)
@@ -250,7 +269,7 @@ defmodule ElixirAi.ChatRunner do
end
if new_pending_tool_calls == [] do
broadcast(state.name, :tool_calls_finished)
broadcast_ui(state.name, :tool_calls_finished)
request_ai_response(self(), state.messages ++ [new_message], state.tools)
end
@@ -271,5 +290,20 @@ defmodule ElixirAi.ChatRunner do
{:reply, state.streaming_response, state}
end
defp broadcast(name, msg), do: Phoenix.PubSub.broadcast(ElixirAi.PubSub, topic(name), msg)
defp broadcast_ui(name, msg), do: Phoenix.PubSub.broadcast(ElixirAi.PubSub, topic(name), msg)
defp store_message(name, messages) when is_list(messages) do
Enum.each(messages, &store_message(name, &1))
messages
end
defp store_message(name, message) do
Phoenix.PubSub.broadcast(
ElixirAi.PubSub,
message_topic(name),
{:store_message, name, message}
)
message
end
end

View File

@@ -1,8 +1,14 @@
defmodule ElixirAi.ConversationManager do
use GenServer
alias ElixirAi.{Conversation, Message}
def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
def init(names), do: {:ok, names}
def start_link(_opts), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)
def init(_) do
names = Conversation.all_names()
conversations = Map.new(names, fn name -> {name, []} end)
{:ok, conversations}
end
def create_conversation(name) do
GenServer.call(__MODULE__, {:create, name})
@@ -16,34 +22,76 @@ defmodule ElixirAi.ConversationManager do
GenServer.call(__MODULE__, :list)
end
def handle_call({:create, name}, _from, names) do
if name in names do
{:reply, {:error, :already_exists}, names}
def get_messages(name) do
GenServer.call(__MODULE__, {:get_messages, name})
end
def handle_call({:create, name}, _from, conversations) do
if Map.has_key?(conversations, name) do
{:reply, {:error, :already_exists}, conversations}
else
{:reply, start_runner(name), [name | names]}
case Conversation.create(name) do
:ok ->
case start_and_subscribe(name) do
{:ok, _pid} = ok -> {:reply, ok, Map.put(conversations, name, [])}
error -> {:reply, error, conversations}
end
{:error, _} = error ->
{:reply, error, conversations}
end
end
end
def handle_call({:open, name}, _from, names) do
if name in names do
{:reply, start_runner(name), names}
def handle_call({:open, name}, _from, conversations) do
if Map.has_key?(conversations, name) do
case start_and_subscribe(name) do
{:ok, _pid} = ok -> {:reply, ok, conversations}
error -> {:reply, error, conversations}
end
else
{:reply, {:error, :not_found}, names}
end
end
def handle_call(:list, _from, names) do
{:reply, names, names}
end
defp start_runner(name) do
case DynamicSupervisor.start_child(
ElixirAi.ChatRunnerSupervisor,
{ElixirAi.ChatRunner, name: name}
) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
error -> error
{:reply, {:error, :not_found}, conversations}
end
end
def handle_call(:list, _from, conversations) do
{:reply, Map.keys(conversations), conversations}
end
def handle_call({:get_messages, name}, _from, conversations) do
{:reply, Map.get(conversations, name, []), conversations}
end
def handle_info({:store_message, name, message}, conversations) do
messages = Map.get(conversations, name, [])
position = length(messages)
case Conversation.find_id(name) do
{:ok, conv_id} -> Message.insert(conv_id, message, position)
_ -> :ok
end
{:noreply, Map.update(conversations, name, [message], &(&1 ++ [message]))}
end
defp start_and_subscribe(name) do
result =
case DynamicSupervisor.start_child(
ElixirAi.ChatRunnerSupervisor,
{ElixirAi.ChatRunner, name: name}
) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
error -> error
end
case result do
{:ok, _pid} ->
Phoenix.PubSub.subscribe(ElixirAi.PubSub, "conversation_messages:#{name}")
result
_ ->
result
end
end
end

View File

@@ -0,0 +1,26 @@
defmodule ElixirAi.Conversation do
import Ecto.Query
alias ElixirAi.Repo
def all_names do
Repo.all(from c in "conversations", select: c.name)
end
def create(name) do
case Repo.insert_all("conversations", [[id: Ecto.UUID.generate(), name: name, inserted_at: now(), updated_at: now()]]) do
{1, _} -> :ok
_ -> {:error, :db_error}
end
rescue
e in Ecto.ConstraintError -> if e.constraint == "conversations_name_index", do: {:error, :already_exists}, else: {:error, :db_error}
end
def find_id(name) do
case Repo.one(from c in "conversations", where: c.name == ^name, select: c.id) do
nil -> {:error, :not_found}
id -> {:ok, id}
end
end
defp now, do: DateTime.truncate(DateTime.utc_now(), :second)
end

View File

@@ -0,0 +1,49 @@
defmodule ElixirAi.Message do
import Ecto.Query
alias ElixirAi.Repo
def load_for_conversation(conversation_id) do
Repo.all(
from m in "messages",
where: m.conversation_id == ^conversation_id,
order_by: m.position,
select: %{
role: m.role,
content: m.content,
reasoning_content: m.reasoning_content,
tool_calls: m.tool_calls,
tool_call_id: m.tool_call_id
}
)
|> Enum.map(&decode_message/1)
end
def insert(conversation_id, message, position) do
Repo.insert_all("messages", [
[
id: Ecto.UUID.generate(),
conversation_id: conversation_id,
role: to_string(message.role),
content: message[:content],
reasoning_content: message[:reasoning_content],
tool_calls: encode_tool_calls(message[:tool_calls]),
tool_call_id: message[:tool_call_id],
position: position,
inserted_at: DateTime.truncate(DateTime.utc_now(), :second)
]
])
end
defp encode_tool_calls(nil), do: nil
defp encode_tool_calls(calls), do: Jason.encode!(calls)
defp decode_message(row) do
row
|> Map.update!(:role, &String.to_existing_atom/1)
|> drop_nil_fields()
end
defp drop_nil_fields(map) do
Map.reject(map, fn {_k, v} -> is_nil(v) end)
end
end

View File

@@ -0,0 +1,5 @@
defmodule ElixirAi.Repo do
use Ecto.Repo,
otp_app: :elixir_ai,
adapter: Ecto.Adapters.Postgres
end

View File

@@ -54,7 +54,9 @@ defmodule ElixirAi.MixProject do
{:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"},
{:bandit, "~> 1.5"}
{:bandit, "~> 1.5"},
{:ecto_sql, "~> 3.11"},
{:postgrex, ">= 0.0.0"}
]
end

View File

@@ -4,9 +4,13 @@
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"},
"ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
"dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"},
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
@@ -42,6 +46,7 @@
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"},

18
schema.sql Normal file
View File

@@ -0,0 +1,18 @@
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool')),
content TEXT,
reasoning_content TEXT,
tool_calls JSONB,
tool_call_id TEXT,
position INTEGER NOT NULL,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);