diff --git a/README.md b/README.md index 61f0425..9b74731 100644 --- a/README.md +++ b/README.md @@ -13,4 +13,7 @@ video ideas: - recommends oban instead of GenServer - can autoscale with flame - tool calling scaling? - - using elixir langchain for tool definitions \ No newline at end of file + - using elixir langchain for tool definitions + + +real time blog: \ No newline at end of file diff --git a/lib/elixir_ai/application.ex b/lib/elixir_ai/application.ex index dae08e4..3d59f87 100644 --- a/lib/elixir_ai/application.ex +++ b/lib/elixir_ai/application.ex @@ -7,6 +7,7 @@ defmodule ElixirAi.Application do children = [ ElixirAiWeb.Telemetry, ElixirAi.Repo, + {Task, fn -> ElixirAi.AiProvider.ensure_default_provider() end}, {Cluster.Supervisor, [Application.get_env(:libcluster, :topologies, []), [name: ElixirAi.ClusterSupervisor]]}, {Phoenix.PubSub, name: ElixirAi.PubSub}, diff --git a/lib/elixir_ai/chat_runner.ex b/lib/elixir_ai/chat_runner.ex index aeea823..843a8b3 100644 --- a/lib/elixir_ai/chat_runner.ex +++ b/lib/elixir_ai/chat_runner.ex @@ -45,7 +45,10 @@ defmodule ElixirAi.ChatRunner do messages: messages, streaming_response: nil, pending_tool_calls: [], - tools: tools(self(), name) + tools: tools(self(), name), + ai_provider_url: Application.get_env(:elixir_ai, :ai_provider_url), + ai_model: Application.get_env(:elixir_ai, :ai_model), + ai_token: Application.get_env(:elixir_ai, :ai_token), }} end diff --git a/lib/elixir_ai/conversation_manager.ex b/lib/elixir_ai/conversation_manager.ex index a74eade..39cf077 100644 --- a/lib/elixir_ai/conversation_manager.ex +++ b/lib/elixir_ai/conversation_manager.ex @@ -17,13 +17,13 @@ defmodule ElixirAi.ConversationManager do end def init(_) do - names = Conversation.all_names() - conversations = Map.new(names, fn name -> {name, []} end) + conversation_list = Conversation.all_names() + conversations = Map.new(conversation_list, fn %{name: name} -> {name, []} end) {:ok, conversations} end - def create_conversation(name) do - GenServer.call(@name, {:create, name}) + def create_conversation(name, ai_provider_id) do + GenServer.call(@name, {:create, name, ai_provider_id}) end def open_conversation(name) do @@ -38,11 +38,11 @@ defmodule ElixirAi.ConversationManager do GenServer.call(@name, {:get_messages, name}) end - def handle_call({:create, name}, _from, conversations) do + def handle_call({:create, name, ai_provider_id}, _from, conversations) do if Map.has_key?(conversations, name) do {:reply, {:error, :already_exists}, conversations} else - case Conversation.create(name) do + case Conversation.create(name, ai_provider_id) do :ok -> case start_and_subscribe(name) do {:ok, _pid} = ok -> {:reply, ok, Map.put(conversations, name, [])} @@ -75,8 +75,6 @@ defmodule ElixirAi.ConversationManager do end def handle_info({:store_message, name, message}, conversations) do - messages = Map.get(conversations, name, []) - case Conversation.find_id(name) do {:ok, conv_id} -> Message.insert(conv_id, message) _ -> :ok diff --git a/lib/elixir_ai/data/ai_provider.ex b/lib/elixir_ai/data/ai_provider.ex new file mode 100644 index 0000000..05e6a46 --- /dev/null +++ b/lib/elixir_ai/data/ai_provider.ex @@ -0,0 +1,89 @@ +defmodule ElixirAi.AiProvider do + import Ecto.Query + alias ElixirAi.Repo + + def all do + Repo.all( + from(p in "ai_providers", + select: %{ + id: p.id, + name: p.name, + model_name: p.model_name, + api_token: p.api_token, + completions_url: p.completions_url + } + ) + ) + end + + def create(attrs) do + now = DateTime.truncate(DateTime.utc_now(), :second) + + case Repo.insert_all("ai_providers", [ + [ + name: attrs.name, + model_name: attrs.model_name, + api_token: attrs.api_token, + completions_url: attrs.completions_url, + inserted_at: now, + updated_at: now + ] + ]) do + {1, _} -> + Phoenix.PubSub.broadcast( + ElixirAi.PubSub, + "ai_providers", + {:provider_added, attrs} + ) + + :ok + + _ -> + {:error, :db_error} + end + rescue + e in Ecto.ConstraintError -> + if e.constraint == "ai_providers_name_key", + do: {:error, :already_exists}, + else: {:error, :db_error} + end + + def find_by_name(name) do + case Repo.one( + from(p in "ai_providers", + where: p.name == ^name, + select: %{ + id: p.id, + name: p.name, + model_name: p.model_name, + api_token: p.api_token, + completions_url: p.completions_url + } + ) + ) do + nil -> {:error, :not_found} + provider -> {:ok, provider} + end + end + + def ensure_default_provider do + case Repo.aggregate(from(p in "ai_providers"), :count) do + 0 -> + attrs = %{ + name: System.get_env("DEFAULT_PROVIDER_NAME", "default_provider"), + model_name: System.get_env("DEFAULT_MODEL_NAME", "gpt-4"), + api_token: System.get_env("DEFAULT_API_TOKEN", ""), + completions_url: + System.get_env( + "DEFAULT_COMPLETIONS_URL", + "https://api.openai.com/v1/chat/completions" + ) + } + + create(attrs) + + _ -> + :ok + end + end +end diff --git a/lib/elixir_ai/data/conversation.ex b/lib/elixir_ai/data/conversation.ex index f372f10..e0e97c4 100644 --- a/lib/elixir_ai/data/conversation.ex +++ b/lib/elixir_ai/data/conversation.ex @@ -3,20 +3,39 @@ defmodule ElixirAi.Conversation do alias ElixirAi.Repo def all_names do - Repo.all(from c in "conversations", select: c.name) + Repo.all( + from(c in "conversations", + left_join: p in "ai_providers", + on: c.ai_provider_id == p.id, + select: %{ + name: c.name, + provider: %{ + name: p.name, + model_name: p.model_name, + api_token: p.api_token, + completions_url: p.completions_url + } + } + ) + ) end - def create(name) do - case Repo.insert_all("conversations", [[name: name, inserted_at: now(), updated_at: now()]]) do + def create(name, ai_provider_id) do + case Repo.insert_all("conversations", [ + [name: name, ai_provider_id: ai_provider_id, 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} + 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 + case Repo.one(from(c in "conversations", where: c.name == ^name, select: c.id)) do nil -> {:error, :not_found} id -> {:ok, id} end diff --git a/lib/elixir_ai_web/components/form_components.ex b/lib/elixir_ai_web/components/form_components.ex new file mode 100644 index 0000000..15ce5d2 --- /dev/null +++ b/lib/elixir_ai_web/components/form_components.ex @@ -0,0 +1,35 @@ +defmodule ElixirAiWeb.FormComponents do + use Phoenix.Component + + @doc """ + Renders a styled input field with label. + + ## Examples + + <.input type="text" name="email" value={@email} label="Email" /> + <.input type="password" name="password" label="Password" /> + """ + attr :type, :string, default: "text" + attr :name, :string, required: true + attr :value, :string, default: "" + attr :label, :string, required: true + attr :autocomplete, :string, default: "off" + attr :rest, :global + + def input(assigns) do + ~H""" +
+ + +
+ """ + end +end diff --git a/lib/elixir_ai_web/live/ai_providers_live.ex b/lib/elixir_ai_web/live/ai_providers_live.ex new file mode 100644 index 0000000..4312b16 --- /dev/null +++ b/lib/elixir_ai_web/live/ai_providers_live.ex @@ -0,0 +1,155 @@ +defmodule ElixirAiWeb.AiProvidersLive do + use ElixirAiWeb, :live_component + import ElixirAiWeb.FormComponents + alias ElixirAi.AiProvider + + def update(assigns, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(ElixirAi.PubSub, "ai_providers") + end + + {:ok, + socket + |> assign(assigns) + |> assign_new(:providers, fn -> AiProvider.all() end) + |> assign_new(:show_form, fn -> false end) + |> assign_new( + :form_data, + fn -> + %{ + "name" => "", + "model_name" => "", + "api_token" => "", + "completions_url" => "https://api.openai.com/v1/chat/completions" + } + end + ) + |> assign_new(:error, fn -> nil end)} + end + + def render(assigns) do + ~H""" +
+
+

AI Providers

+ +
+ + <%= if @show_form do %> +
+ <.input type="text" name="name" value={@form_data["name"]} label="Provider Name" /> + <.input type="text" name="model_name" value={@form_data["model_name"]} label="Model Name" /> + <.input type="password" name="api_token" value={@form_data["api_token"]} label="API Token" /> + <.input + type="text" + name="completions_url" + value={@form_data["completions_url"]} + label="Completions URL" + /> + r +
+ <% end %> + + <%= if @error do %> +

{@error}

+ <% end %> + +
    + <%= if @providers == [] do %> +
  • No providers configured yet.
  • + <% end %> + <%= for provider <- @providers do %> +
  • +
    +
    +

    {provider.name}

    +

    Model: {provider.model_name}

    +

    + Endpoint: {provider.completions_url} +

    +

    + Token: {String.slice(provider.api_token || "", 0..7)}*** +

    +
    +
    +
  • + <% end %> +
+
+ """ + end + + def handle_event("toggle_form", _params, socket) do + {:noreply, assign(socket, show_form: !socket.assigns.show_form, error: nil)} + end + + def handle_event("create_provider", params, socket) do + name = String.trim(params["name"]) + model_name = String.trim(params["model_name"]) + api_token = String.trim(params["api_token"]) + completions_url = String.trim(params["completions_url"]) + + cond do + name == "" -> + {:noreply, assign(socket, error: "Provider name can't be blank")} + + model_name == "" -> + {:noreply, assign(socket, error: "Model name can't be blank")} + + api_token == "" -> + {:noreply, assign(socket, error: "API token can't be blank")} + + completions_url == "" -> + {:noreply, assign(socket, error: "Completions URL can't be blank")} + + true -> + attrs = %{ + name: name, + model_name: model_name, + api_token: api_token, + completions_url: completions_url + } + + case AiProvider.create(attrs) do + :ok -> + {:noreply, + socket + |> assign(show_form: false) + |> assign( + form_data: %{ + "name" => "", + "model_name" => "", + "api_token" => "", + "completions_url" => "https://api.openai.com/v1/chat/completions" + } + ) + |> assign(error: nil)} + + {:error, :already_exists} -> + {:noreply, assign(socket, error: "A provider with that name already exists")} + + _ -> + {:noreply, assign(socket, error: "Failed to create provider")} + end + end + end + + def handle_info({:provider_added, _attrs}, socket) do + {:noreply, assign(socket, providers: AiProvider.all())} + end +end diff --git a/lib/elixir_ai_web/live/home_live.ex b/lib/elixir_ai_web/live/home_live.ex index 9bb6a9f..0be16e0 100644 --- a/lib/elixir_ai_web/live/home_live.ex +++ b/lib/elixir_ai_web/live/home_live.ex @@ -1,79 +1,102 @@ defmodule ElixirAiWeb.HomeLive do use ElixirAiWeb, :live_view - alias ElixirAi.ConversationManager + import ElixirAiWeb.FormComponents + alias ElixirAi.{ConversationManager, AiProvider} def mount(_params, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(ElixirAi.PubSub, "ai_providers") + end + {:ok, socket |> assign(conversations: ConversationManager.list_conversations()) + |> assign(ai_providers: AiProvider.all()) |> assign(new_name: "") |> assign(error: nil)} end def render(assigns) do ~H""" -
-

Conversations

+
+
+

Conversations

-
    - <%= if @conversations == [] do %> -
  • No conversations yet.
  • +
      + <%= if @conversations == [] do %> +
    • No conversations yet.
    • + <% end %> + <%= for name <- @conversations do %> +
    • + <.link + navigate={~p"/chat/#{name}"} + class="block px-4 py-2 rounded-lg border border-cyan-900/40 bg-cyan-950/20 text-cyan-300 hover:border-cyan-700 hover:bg-cyan-950/40 transition-colors text-sm" + > + {name} + +
    • + <% end %> +
    + +
    + <.input type="text" name="name" value={@new_name} label="Conversation Name" /> + + +
    + + <%= if @error do %> +

    {@error}

    <% end %> - <%= for name <- @conversations do %> -
  • - <.link - navigate={~p"/chat/#{name}"} - class="block px-4 py-2 rounded-lg border border-cyan-900/40 bg-cyan-950/20 text-cyan-300 hover:border-cyan-700 hover:bg-cyan-950/40 transition-colors text-sm" - > - {name} - -
  • - <% end %> -
+
-
- - -
- - <%= if @error do %> -

{@error}

- <% end %> +
+ <.live_component module={ElixirAiWeb.AiProvidersLive} id="ai-providers" /> +
""" end - def handle_event("create", %{"name" => name}, socket) do + def handle_event("create", %{"name" => name, "ai_provider_id" => provider_id}, socket) do name = String.trim(name) - if name == "" do - {:noreply, assign(socket, error: "Name can't be blank")} - else - case ConversationManager.create_conversation(name) do - {:ok, _pid} -> - {:noreply, - socket - |> push_navigate(to: ~p"/chat/#{name}") - |> assign(error: nil)} + cond do + name == "" -> + {:noreply, assign(socket, error: "Name can't be blank")} - {:error, :already_exists} -> - {:noreply, assign(socket, error: "A conversation with that name already exists")} + provider_id == "" -> + {:noreply, assign(socket, error: "Please select an AI provider")} - _ -> - {:noreply, assign(socket, error: "Failed to create conversation")} - end + true -> + case ConversationManager.create_conversation(name, provider_id) do + {:ok, _pid} -> + {:noreply, + socket + |> push_navigate(to: ~p"/chat/#{name}") + |> assign(error: nil)} + + {:error, :already_exists} -> + {:noreply, assign(socket, error: "A conversation with that name already exists")} + + _ -> + {:noreply, assign(socket, error: "Failed to create conversation")} + end end end + + def handle_info({:provider_added, _attrs}, socket) do + {:noreply, assign(socket, ai_providers: AiProvider.all())} + end end diff --git a/schema.sql b/schema.sql index d5415f6..3d87b1c 100644 --- a/schema.sql +++ b/schema.sql @@ -1,8 +1,24 @@ +-- drop table if exists messages cascade; +-- drop table if exists conversations cascade; +-- drop table if exists ai_providers cascade; + + +CREATE TABLE IF NOT EXISTS ai_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + model_name TEXT NOT NULL, + api_token TEXT NOT NULL, + completions_url TEXT NOT NULL, + inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS 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() + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + ai_provider_id UUID NOT NULL REFERENCES ai_providers(id) ON DELETE RESTRICT, + inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS messages (