diff --git a/assets/js/app.js b/assets/js/app.js index b0b94ba..4faaf95 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -15,27 +15,25 @@ // import "some-package" // -// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. -import "phoenix_html" -// Establish Phoenix Socket and LiveView configuration. -import {Socket} from "phoenix" -import {LiveSocket} from "phoenix_live_view" -import topbar from "../vendor/topbar" +import "phoenix_html"; +import { Socket } from "phoenix"; +import { LiveSocket } from "phoenix_live_view"; +import topbar from "../vendor/topbar"; -let Hooks = {} +let Hooks = {}; // Renders a complete markdown string client-side on mount. // The raw markdown is passed as the data-md attribute. Hooks.MarkdownRender = { mounted() { - const smd = window.smd - const content = this.el.dataset.md - if (!content) return - const parser = smd.parser(smd.default_renderer(this.el)) - smd.parser_write(parser, content) - smd.parser_end(parser) - } -} + const smd = window.smd; + const content = this.el.dataset.md; + if (!content) return; + const parser = smd.parser(smd.default_renderer(this.el)); + smd.parser_write(parser, content); + smd.parser_end(parser); + }, +}; // Streams markdown chunks into the element using the streaming-markdown parser. // The server sends push_event(socket, eventName, %{chunk: "..."}) for each chunk. @@ -44,82 +42,78 @@ Hooks.MarkdownRender = { // data-event on the element controls which event this hook listens for. Hooks.MarkdownStream = { mounted() { - const smd = window.smd - const DOMPurify = window.DOMPurify - this._chunks = "" - this._parser = smd.parser(smd.default_renderer(this.el)) - const eventName = this.el.dataset.event - this.handleEvent(eventName, ({chunk}) => { - this._chunks += chunk + const smd = window.smd; + const DOMPurify = window.DOMPurify; + this._chunks = ""; + this._parser = smd.parser(smd.default_renderer(this.el)); + const eventName = this.el.dataset.event; + this.handleEvent(eventName, ({ chunk }) => { + this._chunks += chunk; // Sanitize all accumulated chunks to detect injection attacks. - DOMPurify.sanitize(this._chunks) + DOMPurify.sanitize(this._chunks); if (DOMPurify.removed.length > 0) { // Insecure content detected — stop rendering immediately. - smd.parser_end(this._parser) - this._parser = null - return + smd.parser_end(this._parser); + this._parser = null; + return; } - if (this._parser) smd.parser_write(this._parser, chunk) - }) + if (this._parser) smd.parser_write(this._parser, chunk); + }); }, destroyed() { if (this._parser) { - window.smd.parser_end(this._parser) - this._parser = null + window.smd.parser_end(this._parser); + this._parser = null; } - } -} + }, +}; Hooks.ScrollBottom = { mounted() { - requestAnimationFrame(() => this.scrollToBottom()) + requestAnimationFrame(() => this.scrollToBottom()); this.observer = new MutationObserver(() => { - if (this.isNearBottom()) this.scrollToBottom() - }) - this.observer.observe(this.el, {childList: true, subtree: true}) + if (this.isNearBottom()) this.scrollToBottom(); + }); + this.observer.observe(this.el, { childList: true, subtree: true }); this.handleEvent("scroll_to_bottom", () => { - this.scrollToBottom() - }) + this.scrollToBottom(); + }); }, updated() { - if (this.isNearBottom()) this.scrollToBottom() + if (this.isNearBottom()) this.scrollToBottom(); }, destroyed() { - this.observer.disconnect() + this.observer.disconnect(); }, isNearBottom() { - const closeToBottomThreshold = 200 - return this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight <= closeToBottomThreshold + const closeToBottomThreshold = 200; + return ( + this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight <= + closeToBottomThreshold + ); }, scrollToBottom() { - this.el.scrollTop = this.el.scrollHeight - } -} + this.el.scrollTop = this.el.scrollHeight; + }, +}; -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 +const reconnectAfterMs = (tries) => [100, 250, 500][tries - 1] || 1000; +const rejoinAfterMs = (tries) => [100, 250, 500][tries - 1] || 1000; let liveSocket = new LiveSocket("/live", Socket, { - params: {_csrf_token: csrfToken}, + params: { _csrf_token: csrfToken }, hooks: Hooks, reconnectAfterMs, - rejoinAfterMs -}) + rejoinAfterMs, +}); -// Show progress bar on live navigation and form submits -topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) -window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) -window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) - -// connect if there are any LiveViews on the page -liveSocket.connect() - -// expose liveSocket on window for web console debug logs and latency simulation: -// >> liveSocket.enableDebug() -// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session -// >> liveSocket.disableLatencySim() -window.liveSocket = liveSocket +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300)); +window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); +liveSocket.connect(); +window.liveSocket = liveSocket; diff --git a/config/dev.exs b/config/dev.exs index abe6048..b01dfd3 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -16,9 +16,7 @@ config :elixir_ai, ElixirAi.Repo, # watchers to your application. For example, we can use it # to bundle .js and .css sources. config :elixir_ai, ElixirAiWeb.Endpoint, - # Binding to loopback ipv4 address prevents access from other machines. - # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}, port: 4000], + http: [ip: {0, 0, 0, 0}, port: 4000], check_origin: false, code_reloader: true, debug_errors: true, @@ -28,7 +26,6 @@ config :elixir_ai, ElixirAiWeb.Endpoint, tailwind: {Tailwind, :install_and_run, [:elixir_ai, ~w(--watch)]} ] - # Watch static and templates for browser reloading. config :elixir_ai, ElixirAiWeb.Endpoint, live_reload: [ diff --git a/config/runtime.exs b/config/runtime.exs index 51114e6..c837ed5 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -28,6 +28,15 @@ if System.get_env("PHX_SERVER") do config :elixir_ai, ElixirAiWeb.Endpoint, server: true end +# In dev mode, if DATABASE_URL is set (e.g., in Docker), use it instead of defaults +if config_env() == :dev do + if database_url = System.get_env("DATABASE_URL") do + config :elixir_ai, ElixirAi.Repo, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") + end +end + if config_env() == :prod do secret_key_base = System.get_env("SECRET_KEY_BASE") || diff --git a/dev.Dockerfile b/dev.Dockerfile new file mode 100644 index 0000000..030083f --- /dev/null +++ b/dev.Dockerfile @@ -0,0 +1,17 @@ +FROM elixir:1.19.5-otp-28-alpine + +RUN apk add --no-cache build-base git bash wget nodejs npm inotify-tools + +WORKDIR /app + +ENV USER="elixir" +RUN addgroup -g 1000 $USER && \ + adduser -D -u 1000 -G $USER $USER + +RUN mkdir -p /app/_build && \ + chown -R elixir:elixir /app + +USER elixir + +RUN mix local.hex --force && \ + mix local.rebar --force diff --git a/docker-compose.yml b/docker-compose.yml index 12daad0..e48d0ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,39 +18,83 @@ services: retries: 10 node1: - build: . + build: + context: . + dockerfile: dev.Dockerfile + container_name: node1 hostname: node1 env_file: .env environment: DATABASE_URL: ecto://elixir_ai:elixir_ai@db/elixir_ai_dev PHX_HOST: localhost + PHX_SERVER: "true" PORT: 4000 + MIX_ENV: dev RELEASE_NODE: elixir_ai@node1 RELEASE_COOKIE: secret_cluster_cookie SECRET_KEY_BASE: F1nY5uSyD0HfoWejcuuQiaQoMQrjrlFigb3bJ7p4hTXwpTza6sPLpmd+jLS7p0Sh + user: root + command: | + sh -c ' + chown -R elixir:elixir /app/_build + chown -R elixir:elixir /app/deps + su elixir -c "elixir --sname elixir_ai@node1 --cookie secret_cluster_cookie -S mix phx.server" + ' + volumes: + - .:/app + - /app/_build + ports: + - "4001:4000" depends_on: db: condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:4000/health"] + interval: 10s + timeout: 5s + retries: 5 node2: - build: . + build: + context: . + dockerfile: dev.Dockerfile + container_name: node2 hostname: node2 env_file: .env environment: DATABASE_URL: ecto://elixir_ai:elixir_ai@db/elixir_ai_dev PHX_HOST: localhost + PHX_SERVER: "true" PORT: 4000 + MIX_ENV: dev RELEASE_NODE: elixir_ai@node2 RELEASE_COOKIE: secret_cluster_cookie SECRET_KEY_BASE: F1nY5uSyD0HfoWejcuuQiaQoMQrjrlFigb3bJ7p4hTXwpTza6sPLpmd+jLS7p0Sh + user: root + command: | + sh -c ' + chown -R elixir:elixir /app/_build + chown -R elixir:elixir /app/deps + su elixir -c "elixir --sname elixir_ai@node2 --cookie secret_cluster_cookie -S mix phx.server" + ' + volumes: + - .:/app + - /app/_build + ports: + - "4002:4000" depends_on: db: condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:4000/health"] + interval: 10s + timeout: 5s + retries: 5 nginx: image: nginx:alpine ports: - - "80:80" + - "8080:80" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: diff --git a/lib/elixir_ai_web/components/chat_message.ex b/lib/elixir_ai_web/components/chat_message.ex index b4f9eb0..ca04fcf 100644 --- a/lib/elixir_ai_web/components/chat_message.ex +++ b/lib/elixir_ai_web/components/chat_message.ex @@ -2,15 +2,26 @@ defmodule ElixirAiWeb.ChatMessage do use Phoenix.Component alias Phoenix.LiveView.JS + defp max_width_class, do: "max-w-300" + attr :content, :string, required: true attr :tool_call_id, :string, required: true def tool_result_message(assigns) do ~H""" -
+
- - + + tool result {@tool_call_id} @@ -27,7 +38,7 @@ defmodule ElixirAiWeb.ChatMessage do def user_message(assigns) do ~H"""
-
+
{@content}
@@ -41,7 +52,10 @@ defmodule ElixirAiWeb.ChatMessage do def assistant_message(assigns) do assigns = assigns - |> assign(:_reasoning_id, "reasoning-#{:erlang.phash2({assigns.content, assigns.reasoning_content, assigns.tool_calls})}") + |> assign( + :_reasoning_id, + "reasoning-#{:erlang.phash2({assigns.content, assigns.reasoning_content, assigns.tool_calls})}" + ) |> assign(:_expanded, false) ~H""" @@ -101,8 +115,9 @@ defmodule ElixirAiWeb.ChatMessage do phx-hook="MarkdownStream" phx-update="ignore" data-event="reasoning_chunk" - class="reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs max-w-prose mb-1 markdown" - >
+ class={"reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs #{max_width_class()} mb-1 markdown"} + > +
<%= for tool_call <- @tool_calls do %> <.tool_call_item tool_call={tool_call} /> @@ -112,8 +127,9 @@ defmodule ElixirAiWeb.ChatMessage do phx-hook="MarkdownStream" phx-update="ignore" data-event="md_chunk" - class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50" - >
+ class={"inline-block px-3 py-2 rounded-lg #{max_width_class()} markdown bg-cyan-950/50"} + > + """ end @@ -160,10 +176,11 @@ defmodule ElixirAiWeb.ChatMessage do phx-update="ignore" data-md={@reasoning_content} class={[ - "reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs max-w-prose mb-1 markdown", + "reasoning-content block px-3 py-2 rounded-lg bg-cyan-950/50 text-cyan-400 italic text-xs #{max_width_class()} mb-1 markdown", !@expanded && "collapsed" ]} - > + > + <% end %> <%= for tool_call <- @tool_calls do %> <.tool_call_item tool_call={tool_call} /> @@ -174,8 +191,9 @@ defmodule ElixirAiWeb.ChatMessage do phx-hook="MarkdownRender" phx-update="ignore" data-md={@content} - class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50" - > + class={"inline-block px-3 py-2 rounded-lg #{max_width_class()} markdown bg-cyan-950/50"} + > + <% end %> """ @@ -219,7 +237,7 @@ defmodule ElixirAiWeb.ChatMessage do defp pending_tool_call(assigns) do ~H""" -
+
<.tool_call_icon /> {@name} @@ -239,19 +257,32 @@ defmodule ElixirAiWeb.ChatMessage do defp success_tool_call(assigns) do assigns = - assign(assigns, :result_str, case assigns.result do - s when is_binary(s) -> s - other -> inspect(other, pretty: true, limit: :infinity) - end) + assign( + assigns, + :result_str, + case assigns.result do + s when is_binary(s) -> s + other -> inspect(other, pretty: true, limit: :infinity) + end + ) ~H""" -
+
<.tool_call_icon /> {@name} - - + + done @@ -271,12 +302,17 @@ defmodule ElixirAiWeb.ChatMessage do defp error_tool_call(assigns) do ~H""" -
+
<.tool_call_icon /> {@name} - + error @@ -295,10 +331,14 @@ defmodule ElixirAiWeb.ChatMessage do defp tool_call_args(%{arguments: args} = assigns) when args != "" do assigns = - assign(assigns, :pretty_args, case Jason.decode(args) do - {:ok, decoded} -> Jason.encode!(decoded, pretty: true) - _ -> args - end) + assign( + assigns, + :pretty_args, + case Jason.decode(args) do + {:ok, decoded} -> Jason.encode!(decoded, pretty: true) + _ -> args + end + ) ~H"""
@@ -312,8 +352,17 @@ defmodule ElixirAiWeb.ChatMessage do defp tool_call_icon(assigns) do ~H""" - - + + """ end