PhoenixLLMChat
An extensible Phoenix LiveView chat component with streaming LLM support and multi-session management.
Features
- Streaming LLM Support: Works with Claude, Codex, LM Studio, or custom HTTP providers
- Multi-Tab Sessions: Built-in workspace with session switching, renaming, and deletion
- Async Task Management: Proper request ref correlation and task lifecycle cleanup
- Extensible via Hooks: Configure providers, build system prompts, persist sessions
- Zero Foundry Dependencies: Fully generic — reusable in any Phoenix app
- File-Backed Storage: Default file session store with custom backend support
Installation
Add to your mix.exs:
def deps do
[
{:phoenix_llm_chat, path: "phoenix_llm_chat"}
]
endQuick 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
end3. 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
endArchitecture
Modules
PhoenixLLMChat— Main public APIStreamRuntime— Async task and streaming event handlingWorkspace— Multi-tab session managementLLMContext— Provider abstraction and dispatchCore— Event handler and message lifecycleUtilities— Response filtering, formatting, error handling
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
endExtending for Your Domain
The package provides hook points and a stable public interface. For domain-specific logic:
-
Create a
YourApp.ChatDomainLogicmodule - Implement hooks that call into your logic
- Use the socket assignments as your state store
- 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: ...
endTesting
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)
endLicense
MIT