PhoenixLLMChat

An extensible Phoenix LiveView chat component with streaming LLM support and multi-session management.

Features

Installation

Add to your mix.exs:

def deps do
  [
    {:phoenix_llm_chat, path: "phoenix_llm_chat"}
  ]
end

Quick Start

1. Configure Providers

In config/config.exs:

config :phoenix_llm_chat,
  default_provider: "claude",
  session_store: MyApp.FileSessionStore,
  hooks: %{
    :call_llm_stream => &MyApp.LLM.stream/4,
    :build_system_prompt => &MyApp.LLM.system_prompt/2,
    :persist_session => &MyApp.Storage.persist/2
  }

2. Mount in Your LiveView

defmodule MyAppWeb.ChatLive do
  use MyAppWeb, :live_view

  def mount(_params, session, socket) do
    {:ok, PhoenixLLMChat.mount(socket, session)}
  end

  def handle_event(event, params, socket) do
    PhoenixLLMChat.handle_event(event, params, socket)
  end

  def handle_info(message, socket) do
    PhoenixLLMChat.handle_info(message, socket)
  end

  def terminate(reason, socket) do
    PhoenixLLMChat.terminate(reason, socket)
  end
end

3. Implement a Provider Hook

defmodule MyApp.LLM do
  def stream(:claude, _socket, messages, options) do
    # Start your LLM task and send events back:
    # send(self(), {:llm_stream_delta, request_ref, token})
    # send(self(), {:llm_stream_done, request_ref, metadata})
    {:ok, request_ref}
  end

  def system_prompt(socket, options) do
    {:ok, "You are a helpful assistant..."}
  end
end

Architecture

Modules

Hook System

Configure custom behavior via application config:

config :phoenix_llm_chat,
  hooks: %{
    :call_llm_stream => &handler/4,        # Provider implementation
    :build_system_prompt => &handler/2,    # Custom system prompt
    :persist_session => &handler/2,        # Save session to DB
    :load_session => &handler/1,           # Load session from DB
    :get_provider => &handler/1,           # Select provider at runtime
    :get_provider_config => &handler/1     # Provider-specific config
  }

Session Store Behaviour

Implement PhoenixLLMChat.Behaviours.SessionStore for custom backends:

defmodule MyApp.DBSessionStore do
  @behaviour PhoenixLLMChat.Behaviours.SessionStore

  def load(session_id) do
    case MyApp.Repo.get(MyApp.ChatSession, session_id) do
      nil -> {:error, :not_found}
      session -> {:ok, session.data}
    end
  end

  def save(session_id, data) do
    MyApp.Repo.insert_or_update(%MyApp.ChatSession{id: session_id, data: data})
  end

  def list do
    {:ok, MyApp.Repo.all(MyApp.ChatSession) |> Enum.map(& &1.id)}
  end

  def delete(session_id) do
    MyApp.Repo.delete_all(MyApp.ChatSession, id: session_id)
  end
end

Extending for Your Domain

The package provides hook points and a stable public interface. For domain-specific logic:

  1. Create a YourApp.ChatDomainLogic module
  2. Implement hooks that call into your logic
  3. Use the socket assignments as your state store
  4. Keep domain logic separate from streaming/UI patterns

Example from Foundry:

defmodule FoundryWeb.ChatSessionDomainLogic do
  # Foundry-specific: proposal management, activity tracking, etc.
  def apply_proposal(socket, proposal_id), do: ...
  def create_activity_run(socket, metadata), do: ...
end

Testing

The package is tested in isolation from domain logic. Test your provider hooks separately:

test "stream calls llm provider" do
  assert {:ok, request_ref} = PhoenixLLMChat.LLMContext.call_llm(socket, "hello")
  assert is_reference(request_ref)
end

License

MIT