working on redundancy

This commit is contained in:
2026-03-06 16:37:31 -07:00
parent 8059048db2
commit 181c6ca84b
16 changed files with 282 additions and 29 deletions

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# ---- Build stage ----
FROM elixir:1.19.5-otp-28-alpine AS build
RUN apk add --no-cache build-base git nodejs npm
WORKDIR /app
ENV MIX_ENV=prod
RUN mix local.hex --force && mix local.rebar --force
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
RUN mix deps.compile
COPY config config
COPY priv priv
COPY lib lib
COPY assets assets
RUN mix assets.deploy
RUN mix release
# ---- Runtime stage ----
FROM elixir:1.19.5-otp-28-alpine AS runtime
RUN apk add --no-cache libstdc++ openssl ncurses-libs
WORKDIR /app
COPY --from=build /app/_build/prod/rel/elixir_ai ./
RUN touch .env
ENV PHX_SERVER=true
EXPOSE 4000
CMD ["bin/elixir_ai", "start"]

View File

@@ -94,9 +94,16 @@ Hooks.ScrollBottom = {
} }
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
// Retry very aggressively: 100ms, 250ms, 500ms, then cap at 1s indefinitely.
const reconnectAfterMs = (tries) => [100, 250, 500][tries - 1] || 1000
const rejoinAfterMs = (tries) => [100, 250, 500][tries - 1] || 1000
let liveSocket = new LiveSocket("/live", Socket, { let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken}, params: {_csrf_token: csrfToken},
hooks: Hooks hooks: Hooks,
reconnectAfterMs,
rejoinAfterMs
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View File

@@ -51,6 +51,20 @@ config :logger, :console,
# Use Jason for JSON parsing in Phoenix # Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason
# Lower the BEAM node-down detection window from the default 60s.
# Nodes send ticks every (net_ticktime / 4)s; a node is declared down
# after 4 missed ticks (net_ticktime total). 5s means detection in ≤5s.
config :kernel, net_ticktime: 2
# Libcluster — Gossip strategy works for local dev and Docker Compose
# (UDP multicast, zero config). Overridden to Kubernetes.DNS in runtime.exs for prod.
config :libcluster,
topologies: [
gossip: [
strategy: Cluster.Strategy.Gossip
]
]
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs" import_config "#{config_env()}.exs"

View File

@@ -29,11 +29,6 @@ if System.get_env("PHX_SERVER") do
end end
if config_env() == :prod do if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base = secret_key_base =
System.get_env("SECRET_KEY_BASE") || System.get_env("SECRET_KEY_BASE") ||
raise """ raise """
@@ -41,6 +36,33 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret You can generate one by calling: mix phx.gen.secret
""" """
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://user:password@host/database
"""
config :elixir_ai, ElixirAi.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
# In Kubernetes, switch from Gossip to DNS-based discovery via a headless service.
# Set K8S_NAMESPACE and optionally K8S_SERVICE_NAME in your pod spec.
if System.get_env("K8S_NAMESPACE") do
config :libcluster,
topologies: [
k8s_dns: [
strategy: Cluster.Strategy.Kubernetes.DNS,
config: [
service: System.get_env("K8S_SERVICE_NAME") || "elixir-ai-headless",
application_name: "elixir_ai",
namespace: System.get_env("K8S_NAMESPACE")
]
]
]
end
host = System.get_env("PHX_HOST") || "example.com" host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "4000")

View File

@@ -5,7 +5,54 @@ services:
POSTGRES_USER: elixir_ai POSTGRES_USER: elixir_ai
POSTGRES_PASSWORD: elixir_ai POSTGRES_PASSWORD: elixir_ai
POSTGRES_DB: elixir_ai_dev POSTGRES_DB: elixir_ai_dev
command: postgres -c hba_file=/etc/postgresql/pg_hba.conf
ports: ports:
- 5432:5432 - 5432:5432
volumes: volumes:
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql
- ./postgres/pg_hba.conf:/etc/postgresql/pg_hba.conf
healthcheck:
test: ["CMD-SHELL", "pg_isready -U elixir_ai -d elixir_ai_dev"]
interval: 5s
timeout: 5s
retries: 10
node1:
build: .
hostname: node1
env_file: .env
environment:
DATABASE_URL: ecto://elixir_ai:elixir_ai@db/elixir_ai_dev
PHX_HOST: localhost
PORT: 4000
RELEASE_NODE: elixir_ai@node1
RELEASE_COOKIE: secret_cluster_cookie
SECRET_KEY_BASE: F1nY5uSyD0HfoWejcuuQiaQoMQrjrlFigb3bJ7p4hTXwpTza6sPLpmd+jLS7p0Sh
depends_on:
db:
condition: service_healthy
node2:
build: .
hostname: node2
env_file: .env
environment:
DATABASE_URL: ecto://elixir_ai:elixir_ai@db/elixir_ai_dev
PHX_HOST: localhost
PORT: 4000
RELEASE_NODE: elixir_ai@node2
RELEASE_COOKIE: secret_cluster_cookie
SECRET_KEY_BASE: F1nY5uSyD0HfoWejcuuQiaQoMQrjrlFigb3bJ7p4hTXwpTza6sPLpmd+jLS7p0Sh
depends_on:
db:
condition: service_healthy
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- node1
- node2

View File

@@ -7,13 +7,27 @@ defmodule ElixirAi.Application do
children = [ children = [
ElixirAiWeb.Telemetry, ElixirAiWeb.Telemetry,
ElixirAi.Repo, ElixirAi.Repo,
{DNSCluster, query: Application.get_env(:elixir_ai, :dns_cluster_query) || :ignore}, {Cluster.Supervisor,
[Application.get_env(:libcluster, :topologies, []), [name: ElixirAi.ClusterSupervisor]]},
{Phoenix.PubSub, name: ElixirAi.PubSub}, {Phoenix.PubSub, name: ElixirAi.PubSub},
ElixirAi.ToolTesting, ElixirAi.ToolTesting,
ElixirAiWeb.Endpoint, ElixirAiWeb.Endpoint,
{Registry, keys: :unique, name: ElixirAi.ChatRegistry}, {Horde.Registry,
{DynamicSupervisor, name: ElixirAi.ChatRunnerSupervisor, strategy: :one_for_one}, [
ElixirAi.ConversationManager name: ElixirAi.ChatRegistry,
keys: :unique,
members: :auto,
delta_crdt_options: [sync_interval: 100]
]},
{Horde.DynamicSupervisor,
[
name: ElixirAi.ChatRunnerSupervisor,
strategy: :one_for_one,
members: :auto,
delta_crdt_options: [sync_interval: 100],
process_redistribution: :active
]},
ElixirAi.ClusterSingleton
] ]
opts = [strategy: :one_for_one, name: ElixirAi.Supervisor] opts = [strategy: :one_for_one, name: ElixirAi.Supervisor]

View File

@@ -4,7 +4,7 @@ defmodule ElixirAi.ChatRunner do
import ElixirAi.ChatUtils import ElixirAi.ChatUtils
alias ElixirAi.{Conversation, Message} alias ElixirAi.{Conversation, Message}
defp via(name), do: {:via, Registry, {ElixirAi.ChatRegistry, name}} defp via(name), do: {:via, Horde.Registry, {ElixirAi.ChatRegistry, name}}
defp topic(name), do: "ai_chat:#{name}" defp topic(name), do: "ai_chat:#{name}"
defp message_topic(name), do: "conversation_messages:#{name}" defp message_topic(name), do: "conversation_messages:#{name}"
@@ -32,6 +32,13 @@ defmodule ElixirAi.ChatRunner do
_ -> [] _ -> []
end end
last_message = List.last(messages)
if last_message && last_message.role == :user do
Logger.info("Last message role was #{last_message.role}, requesting AI response for conversation #{name}")
request_ai_response(self(), messages, tools(self(), name))
end
{:ok, {:ok,
%{ %{
name: name, name: name,
@@ -282,6 +289,12 @@ defmodule ElixirAi.ChatRunner do
}} }}
end end
def handle_info({:ai_request_error, reason}, state) do
Logger.error("AI request error: #{inspect(reason)}")
broadcast_ui(state.name, {:ai_request_error, reason})
{:noreply, %{state | streaming_response: nil, pending_tool_calls: []}}
end
def handle_call(:get_conversation, _from, state) do def handle_call(:get_conversation, _from, state) do
{:reply, state, state} {:reply, state, state}
end end

View File

@@ -0,0 +1,31 @@
defmodule ElixirAi.ClusterSingleton do
use GenServer
@sync_delay_ms 200
@singletons [ElixirAi.ConversationManager]
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@impl true
def init(_opts) do
Process.send_after(self(), :start_singletons, @sync_delay_ms)
{:ok, :pending}
end
@impl true
def handle_info(:start_singletons, state) do
for module <- @singletons do
case Horde.DynamicSupervisor.start_child(ElixirAi.ChatRunnerSupervisor, module) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
{:error, :already_present} -> :ok
{:error, reason} ->
require Logger
Logger.warning("ClusterSingleton: failed to start #{inspect(module)}: #{inspect(reason)}")
end
end
{:noreply, :started}
end
end

View File

@@ -2,7 +2,19 @@ defmodule ElixirAi.ConversationManager do
use GenServer use GenServer
alias ElixirAi.{Conversation, Message} alias ElixirAi.{Conversation, Message}
def start_link(_opts), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__) @name {:via, Horde.Registry, {ElixirAi.ChatRegistry, __MODULE__}}
def start_link(_opts) do
GenServer.start_link(__MODULE__, nil, name: @name)
end
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
restart: :transient
}
end
def init(_) do def init(_) do
names = Conversation.all_names() names = Conversation.all_names()
@@ -11,19 +23,19 @@ defmodule ElixirAi.ConversationManager do
end end
def create_conversation(name) do def create_conversation(name) do
GenServer.call(__MODULE__, {:create, name}) GenServer.call(@name, {:create, name})
end end
def open_conversation(name) do def open_conversation(name) do
GenServer.call(__MODULE__, {:open, name}) GenServer.call(@name, {:open, name})
end end
def list_conversations do def list_conversations do
GenServer.call(__MODULE__, :list) GenServer.call(@name, :list)
end end
def get_messages(name) do def get_messages(name) do
GenServer.call(__MODULE__, {:get_messages, name}) GenServer.call(@name, {:get_messages, name})
end end
def handle_call({:create, name}, _from, conversations) do def handle_call({:create, name}, _from, conversations) do
@@ -64,10 +76,9 @@ defmodule ElixirAi.ConversationManager do
def handle_info({:store_message, name, message}, conversations) do def handle_info({:store_message, name, message}, conversations) do
messages = Map.get(conversations, name, []) messages = Map.get(conversations, name, [])
position = length(messages)
case Conversation.find_id(name) do case Conversation.find_id(name) do
{:ok, conv_id} -> Message.insert(conv_id, message, position) {:ok, conv_id} -> Message.insert(conv_id, message)
_ -> :ok _ -> :ok
end end
@@ -76,7 +87,7 @@ defmodule ElixirAi.ConversationManager do
defp start_and_subscribe(name) do defp start_and_subscribe(name) do
result = result =
case DynamicSupervisor.start_child( case Horde.DynamicSupervisor.start_child(
ElixirAi.ChatRunnerSupervisor, ElixirAi.ChatRunnerSupervisor,
{ElixirAi.ChatRunner, name: name} {ElixirAi.ChatRunner, name: name}
) do ) do

View File

@@ -7,7 +7,7 @@ defmodule ElixirAi.Conversation do
end end
def create(name) do def create(name) do
case Repo.insert_all("conversations", [[id: Ecto.UUID.generate(), name: name, inserted_at: now(), updated_at: now()]]) do case Repo.insert_all("conversations", [[name: name, inserted_at: now(), updated_at: now()]]) do
{1, _} -> :ok {1, _} -> :ok
_ -> {:error, :db_error} _ -> {:error, :db_error}
end end

View File

@@ -6,7 +6,7 @@ defmodule ElixirAi.Message do
Repo.all( Repo.all(
from m in "messages", from m in "messages",
where: m.conversation_id == ^conversation_id, where: m.conversation_id == ^conversation_id,
order_by: m.position, order_by: m.id,
select: %{ select: %{
role: m.role, role: m.role,
content: m.content, content: m.content,
@@ -18,17 +18,15 @@ defmodule ElixirAi.Message do
|> Enum.map(&decode_message/1) |> Enum.map(&decode_message/1)
end end
def insert(conversation_id, message, position) do def insert(conversation_id, message) do
Repo.insert_all("messages", [ Repo.insert_all("messages", [
[ [
id: Ecto.UUID.generate(),
conversation_id: conversation_id, conversation_id: conversation_id,
role: to_string(message.role), role: to_string(message.role),
content: message[:content], content: message[:content],
reasoning_content: message[:reasoning_content], reasoning_content: message[:reasoning_content],
tool_calls: encode_tool_calls(message[:tool_calls]), tool_calls: encode_tool_calls(message[:tool_calls]),
tool_call_id: message[:tool_call_id], tool_call_id: message[:tool_call_id],
position: position,
inserted_at: DateTime.truncate(DateTime.utc_now(), :second) inserted_at: DateTime.truncate(DateTime.utc_now(), :second)
] ]
]) ])
@@ -40,9 +38,22 @@ defmodule ElixirAi.Message do
defp decode_message(row) do defp decode_message(row) do
row row
|> Map.update!(:role, &String.to_existing_atom/1) |> Map.update!(:role, &String.to_existing_atom/1)
|> Map.update(:tool_calls, nil, fn
nil -> nil
json when is_binary(json) ->
json |> Jason.decode!() |> Enum.map(&atomize_keys/1)
already_decoded -> Enum.map(already_decoded, &atomize_keys/1)
end)
|> drop_nil_fields() |> drop_nil_fields()
end end
defp atomize_keys(map) when is_map(map) do
Map.new(map, fn
{k, v} when is_binary(k) -> {String.to_atom(k), v}
{k, v} -> {k, v}
end)
end
defp drop_nil_fields(map) do defp drop_nil_fields(map) do
Map.reject(map, fn {_k, v} -> is_nil(v) end) Map.reject(map, fn {_k, v} -> is_nil(v) end)
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.14", 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(),
@@ -53,10 +53,12 @@ defmodule ElixirAi.MixProject do
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"}, {:dns_cluster, "~> 0.1.1", only: :dev},
{:libcluster, "~> 3.3"},
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},
{:ecto_sql, "~> 3.11"}, {:ecto_sql, "~> 3.11"},
{:postgrex, ">= 0.0.0"} {:postgrex, ">= 0.0.0"},
{:horde, "~> 0.9"}
] ]
end end

View File

@@ -6,6 +6,7 @@
"ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "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"}, "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"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"delta_crdt": {:hex, :delta_crdt, "0.6.5", "c7bb8c2c7e60f59e46557ab4e0224f67ba22f04c02826e273738f3dcc4767adc", [:mix], [{:merkle_map, "~> 0.2.0", [hex: :merkle_map, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c6ae23a525d30f96494186dd11bf19ed9ae21d9fe2c1f1b217d492a7cc7294ae"},
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
"dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"}, "dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"},
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
@@ -20,11 +21,15 @@
"gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"},
"grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]},
"horde": {:hex, :horde, "0.10.0", "31c6a633057c3ec4e73064d7b11ba409c9f3c518aa185377d76bee441b76ceb0", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.7", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "0b51c435cb698cac9bf9c17391dce3ebb1376ae6154c81f077fc61db771b9432"},
"hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
"libring": {:hex, :libring, "1.7.0", "4f245d2f1476cd7ed8f03740f6431acba815401e40299208c7f5c640e1883bda", [:mix], [], "hexpm", "070e3593cb572e04f2c8470dd0c119bc1817a7a0a7f88229f43cf0345268ec42"},
"merkle_map": {:hex, :merkle_map, "0.2.2", "f36ff730cca1f2658e317a3c73406f50bbf5ac8aff54cf837d7ca2069a6e251c", [:mix], [], "hexpm", "383107f0503f230ac9175e0631647c424efd027e89ea65ab5ea12eeb54257aaf"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},

34
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,34 @@
upstream backend {
least_conn;
server node1:4000 max_fails=1 fail_timeout=5s;
server node2:4000 max_fails=1 fail_timeout=5s;
keepalive 32;
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
# WebSocket support (required for LiveView)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Fast failure detection and automatic retry on the other node
proxy_connect_timeout 2s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 4s;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}

5
postgres/pg_hba.conf Normal file
View File

@@ -0,0 +1,5 @@
# TYPE DATABASE USER ADDRESS METHOD
local all all trust
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
host all all 0.0.0.0/0 scram-sha-256

View File

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