diff --git a/README.md b/README.md index 9b74731..97caa4a 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,13 @@ video ideas: - using elixir langchain for tool definitions -real time blog: \ No newline at end of file +real time blog: + + + + +elixir clustering examples and architecture: + + +process groups for distributing workers across cluster: + diff --git a/lib/elixir_ai/conversation_manager.ex b/lib/elixir_ai/conversation_manager.ex index 39cf077..4b39182 100644 --- a/lib/elixir_ai/conversation_manager.ex +++ b/lib/elixir_ai/conversation_manager.ex @@ -1,6 +1,7 @@ defmodule ElixirAi.ConversationManager do use GenServer alias ElixirAi.{Conversation, Message} + require Logger @name {:via, Horde.Registry, {ElixirAi.ChatRegistry, __MODULE__}} @@ -17,8 +18,19 @@ defmodule ElixirAi.ConversationManager do end def init(_) do + Logger.info("ConversationManager initializing...") conversation_list = Conversation.all_names() + Logger.info("Loaded #{length(conversation_list)} conversations from DB") + + # Log each conversation and check for UTF-8 issues + Enum.each(conversation_list, fn conv -> + Logger.info( + "Conversation: #{inspect(conv, limit: :infinity, printable_limit: :infinity, binaries: :as_binaries)}" + ) + end) + conversations = Map.new(conversation_list, fn %{name: name} -> {name, []} end) + Logger.info("Conversation map keys: #{inspect(Map.keys(conversations))}") {:ok, conversations} end @@ -67,7 +79,13 @@ defmodule ElixirAi.ConversationManager do end def handle_call(:list, _from, conversations) do - {:reply, Map.keys(conversations), conversations} + keys = Map.keys(conversations) + + Logger.debug( + "list_conversations returning: #{inspect(keys, limit: :infinity, printable_limit: :infinity, binaries: :as_binaries)}" + ) + + {:reply, keys, conversations} end def handle_call({:get_messages, name}, _from, conversations) do diff --git a/lib/elixir_ai/data/ai_provider.ex b/lib/elixir_ai/data/ai_provider.ex index 05e6a46..14a5d37 100644 --- a/lib/elixir_ai/data/ai_provider.ex +++ b/lib/elixir_ai/data/ai_provider.ex @@ -1,21 +1,34 @@ defmodule ElixirAi.AiProvider do import Ecto.Query alias ElixirAi.Repo + alias ElixirAi.Data.AiProviderSchema + require Logger 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 - } + results = + Repo.all( + from(p in AiProviderSchema, + select: %{ + id: p.id, + name: p.name, + model_name: p.model_name + } + ) ) - ) + |> Enum.map(&convert_id_to_string/1) + + Logger.debug("AiProvider.all() returning: #{inspect(results)}") + + results end + # Convert binary UUID to string for frontend + defp convert_id_to_string(%{id: id} = provider) when is_binary(id) do + %{provider | id: Ecto.UUID.cast!(id)} + end + + defp convert_id_to_string(provider), do: provider + def create(attrs) do now = DateTime.truncate(DateTime.utc_now(), :second) @@ -62,7 +75,7 @@ defmodule ElixirAi.AiProvider do ) ) do nil -> {:error, :not_found} - provider -> {:ok, provider} + provider -> {:ok, convert_id_to_string(provider)} end end @@ -70,14 +83,10 @@ defmodule ElixirAi.AiProvider 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" - ) + name: "default", + model_name: Application.fetch_env!(:elixir_ai, :ai_model), + api_token: Application.fetch_env!(:elixir_ai, :ai_token), + completions_url: Application.fetch_env!(:elixir_ai, :ai_endpoint) } create(attrs) diff --git a/lib/elixir_ai/data/conversation.ex b/lib/elixir_ai/data/conversation.ex index e0e97c4..d8c4baa 100644 --- a/lib/elixir_ai/data/conversation.ex +++ b/lib/elixir_ai/data/conversation.ex @@ -1,31 +1,91 @@ defmodule ElixirAi.Conversation do import Ecto.Query alias ElixirAi.Repo + alias ElixirAi.Data.ConversationSchema + alias ElixirAi.Data.AiProviderSchema + require Logger - def all_names do - 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 - } - } - ) - ) + defmodule Provider do + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:name, :string) + field(:model_name, :string) + field(:api_token, :string) + field(:completions_url, :string) + end + + def changeset(provider, attrs) do + provider + |> cast(attrs, [:name, :model_name, :api_token, :completions_url]) + |> validate_required([:name, :model_name, :api_token, :completions_url]) + end end - 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} + defmodule ConversationInfo do + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:name, :string) + embeds_one(:provider, Provider) + end + + def changeset(conversation, attrs) do + conversation + |> cast(attrs, [:name]) + |> validate_required([:name]) + |> cast_embed(:provider, with: &Provider.changeset/2, required: true) + end + end + + def all_names do + results = + Repo.all( + from(c in ConversationSchema, + left_join: p in AiProviderSchema, + 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 + } + } + ) + ) + + Enum.map(results, fn attrs -> + changeset = ConversationInfo.changeset(%ConversationInfo{}, attrs) + + if changeset.valid? do + Ecto.Changeset.apply_changes(changeset) + else + Logger.error("Invalid conversation data: #{inspect(changeset.errors)}") + raise ArgumentError, "Invalid conversation data: #{inspect(changeset.errors)}" + end + end) + end + + def create(name, ai_provider_id) when is_binary(ai_provider_id) do + # Convert string UUID from frontend to binary UUID for database + case Ecto.UUID.dump(ai_provider_id) do + {:ok, binary_id} -> + Repo.insert_all("conversations", [ + [name: name, ai_provider_id: binary_id, inserted_at: now(), updated_at: now()] + ]) + |> case do + {1, _} -> :ok + _ -> {:error, :db_error} + end + + :error -> + {:error, :invalid_uuid} end rescue e in Ecto.ConstraintError -> @@ -35,7 +95,7 @@ defmodule ElixirAi.Conversation do 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 ConversationSchema, where: c.name == ^name, select: c.id)) do nil -> {:error, :not_found} id -> {:ok, id} end diff --git a/lib/elixir_ai/data/message.ex b/lib/elixir_ai/data/message.ex index d54e884..385dcbd 100644 --- a/lib/elixir_ai/data/message.ex +++ b/lib/elixir_ai/data/message.ex @@ -1,10 +1,11 @@ defmodule ElixirAi.Message do import Ecto.Query alias ElixirAi.Repo + alias ElixirAi.Data.MessageSchema def load_for_conversation(conversation_id) do Repo.all( - from m in "messages", + from m in MessageSchema, where: m.conversation_id == ^conversation_id, order_by: m.id, select: %{ diff --git a/lib/elixir_ai/data/schemas/ai_provider_schema.ex b/lib/elixir_ai/data/schemas/ai_provider_schema.ex new file mode 100644 index 0000000..cfc1a83 --- /dev/null +++ b/lib/elixir_ai/data/schemas/ai_provider_schema.ex @@ -0,0 +1,15 @@ +defmodule ElixirAi.Data.AiProviderSchema do + use Ecto.Schema + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "ai_providers" do + field(:name, :string) + field(:model_name, :string) + field(:api_token, :string) + field(:completions_url, :string) + + timestamps(type: :utc_datetime) + end +end diff --git a/lib/elixir_ai/data/schemas/conversation_schema.ex b/lib/elixir_ai/data/schemas/conversation_schema.ex new file mode 100644 index 0000000..3d43743 --- /dev/null +++ b/lib/elixir_ai/data/schemas/conversation_schema.ex @@ -0,0 +1,13 @@ +defmodule ElixirAi.Data.ConversationSchema do + use Ecto.Schema + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "conversations" do + field(:name, :string) + belongs_to(:ai_provider, ElixirAi.Data.AiProviderSchema, type: :binary_id) + + timestamps(type: :utc_datetime) + end +end diff --git a/lib/elixir_ai/data/schemas/message_schema.ex b/lib/elixir_ai/data/schemas/message_schema.ex new file mode 100644 index 0000000..f6dded3 --- /dev/null +++ b/lib/elixir_ai/data/schemas/message_schema.ex @@ -0,0 +1,16 @@ +defmodule ElixirAi.Data.MessageSchema do + use Ecto.Schema + + @primary_key {:id, :id, autogenerate: true} + + schema "messages" do + belongs_to(:conversation, ElixirAi.Data.ConversationSchema, type: :binary_id) + field(:role, :string) + field(:content, :string) + field(:reasoning_content, :string) + field(:tool_calls, :map) + field(:tool_call_id, :string) + + timestamps(inserted_at: :inserted_at, updated_at: false, type: :utc_datetime) + end +end diff --git a/lib/elixir_ai_web/live/ai_providers_live.ex b/lib/elixir_ai_web/live/ai_providers_live.ex index 4312b16..346d379 100644 --- a/lib/elixir_ai_web/live/ai_providers_live.ex +++ b/lib/elixir_ai_web/live/ai_providers_live.ex @@ -79,12 +79,6 @@ defmodule ElixirAiWeb.AiProvidersLive do {provider.name} Model: {provider.model_name} - - Endpoint: {provider.completions_url} - - - Token: {String.slice(provider.api_token || "", 0..7)}*** - diff --git a/lib/elixir_ai_web/live/home_live.ex b/lib/elixir_ai_web/live/home_live.ex index 0be16e0..b117d95 100644 --- a/lib/elixir_ai_web/live/home_live.ex +++ b/lib/elixir_ai_web/live/home_live.ex @@ -2,16 +2,29 @@ defmodule ElixirAiWeb.HomeLive do use ElixirAiWeb, :live_view import ElixirAiWeb.FormComponents alias ElixirAi.{ConversationManager, AiProvider} + require Logger def mount(_params, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(ElixirAi.PubSub, "ai_providers") end + conversations = ConversationManager.list_conversations() + + Logger.debug( + "Conversations: #{inspect(conversations, limit: :infinity, printable_limit: :infinity)}" + ) + + ai_providers = AiProvider.all() + + Logger.debug( + "AI Providers: #{inspect(ai_providers, limit: :infinity, printable_limit: :infinity)}" + ) + {:ok, socket - |> assign(conversations: ConversationManager.list_conversations()) - |> assign(ai_providers: AiProvider.all()) + |> assign(conversations: conversations) + |> assign(ai_providers: ai_providers) |> assign(new_name: "") |> assign(error: nil)} end @@ -62,9 +75,9 @@ defmodule ElixirAiWeb.HomeLive do <% end %> - + <%!-- <.live_component module={ElixirAiWeb.AiProvidersLive} id="ai-providers" /> - + --%> """ end
Model: {provider.model_name}
- Endpoint: {provider.completions_url} -
- Token: {String.slice(provider.api_token || "", 0..7)}*** -