From 8059048db287b0cae72747188a8c1a96700a7890 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Fri, 6 Mar 2026 15:26:55 -0700 Subject: [PATCH] persisting in postgres --- assets/js/app.js | 2 +- config/config.exs | 1 + config/dev.exs | 9 +++ docker-compose.yml | 11 +++ lib/elixir_ai/application.ex | 1 + lib/elixir_ai/chat_runner.ex | 74 +++++++++++++++------ lib/elixir_ai/conversation_manager.ex | 96 ++++++++++++++++++++------- lib/elixir_ai/data/conversation.ex | 26 ++++++++ lib/elixir_ai/data/message.ex | 49 ++++++++++++++ lib/elixir_ai/data/repo.ex | 5 ++ mix.exs | 4 +- mix.lock | 5 ++ schema.sql | 18 +++++ 13 files changed, 255 insertions(+), 46 deletions(-) create mode 100644 docker-compose.yml create mode 100644 lib/elixir_ai/data/conversation.ex create mode 100644 lib/elixir_ai/data/message.ex create mode 100644 lib/elixir_ai/data/repo.ex create mode 100644 schema.sql diff --git a/assets/js/app.js b/assets/js/app.js index e93ef47..db1f222 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -72,7 +72,7 @@ Hooks.MarkdownStream = { Hooks.ScrollBottom = { mounted() { - this.scrollToBottom() + requestAnimationFrame(() => this.scrollToBottom()) this.observer = new MutationObserver(() => { if (this.isNearBottom()) this.scrollToBottom() }) diff --git a/config/config.exs b/config/config.exs index b7bec67..09f297c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -8,6 +8,7 @@ import Config config :elixir_ai, + ecto_repos: [ElixirAi.Repo], generators: [timestamp_type: :utc_datetime] # Configures the endpoint diff --git a/config/dev.exs b/config/dev.exs index c4cfdaa..abe6048 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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. # diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..17f7aba --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/lib/elixir_ai/application.ex b/lib/elixir_ai/application.ex index 69d6245..4df0b6c 100644 --- a/lib/elixir_ai/application.ex +++ b/lib/elixir_ai/application.ex @@ -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, diff --git a/lib/elixir_ai/chat_runner.ex b/lib/elixir_ai/chat_runner.ex index c5b737b..38e1e7c 100644 --- a/lib/elixir_ai/chat_runner.ex +++ b/lib/elixir_ai/chat_runner.ex @@ -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 diff --git a/lib/elixir_ai/conversation_manager.ex b/lib/elixir_ai/conversation_manager.ex index 82da392..2026e0b 100644 --- a/lib/elixir_ai/conversation_manager.ex +++ b/lib/elixir_ai/conversation_manager.ex @@ -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 diff --git a/lib/elixir_ai/data/conversation.ex b/lib/elixir_ai/data/conversation.ex new file mode 100644 index 0000000..f7673e8 --- /dev/null +++ b/lib/elixir_ai/data/conversation.ex @@ -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 diff --git a/lib/elixir_ai/data/message.ex b/lib/elixir_ai/data/message.ex new file mode 100644 index 0000000..6e20d54 --- /dev/null +++ b/lib/elixir_ai/data/message.ex @@ -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 diff --git a/lib/elixir_ai/data/repo.ex b/lib/elixir_ai/data/repo.ex new file mode 100644 index 0000000..cd9b0bc --- /dev/null +++ b/lib/elixir_ai/data/repo.ex @@ -0,0 +1,5 @@ +defmodule ElixirAi.Repo do + use Ecto.Repo, + otp_app: :elixir_ai, + adapter: Ecto.Adapters.Postgres +end diff --git a/mix.exs b/mix.exs index b901835..eef9288 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index 4eba282..89abd68 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..5fe46d5 --- /dev/null +++ b/schema.sql @@ -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() +);