working on redundancy
This commit is contained in:
38
Dockerfile
Normal file
38
Dockerfile
Normal 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"]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
31
lib/elixir_ai/cluster_singleton.ex
Normal file
31
lib/elixir_ai/cluster_singleton.ex
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
8
mix.exs
8
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.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
|
||||||
|
|
||||||
|
|||||||
5
mix.lock
5
mix.lock
@@ -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
34
nginx/nginx.conf
Normal 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
5
postgres/pg_hba.conf
Normal 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
|
||||||
@@ -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()
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user