# 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`:
```elixir
def deps do
[
{:phoenix_llm_chat, path: "phoenix_llm_chat"}
]
end
```
## Quick Start
### 1. Configure Providers
In `config/config.exs`:
```elixir
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
```elixir
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
```elixir
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
- **`PhoenixLLMChat`** — Main public API
- **`StreamRuntime`** — Async task and streaming event handling
- **`Workspace`** — Multi-tab session management
- **`LLMContext`** — Provider abstraction and dispatch
- **`Core`** — Event handler and message lifecycle
- **`Utilities`** — Response filtering, formatting, error handling
### Hook System
Configure custom behavior via application config:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
test "stream calls llm provider" do
assert {:ok, request_ref} = PhoenixLLMChat.LLMContext.call_llm(socket, "hello")
assert is_reference(request_ref)
end
```
## License
MIT