Current section

Files

Jump to
ash_ai lib mix tasks ash_ai.gen.chat.ex
Raw

lib/mix/tasks/ash_ai.gen.chat.ex

# SPDX-FileCopyrightText: 2024 ash_ai contributors <https://github.com/ash-project/ash_ai/graphs/contributors>
#
# SPDX-License-Identifier: MIT
defmodule Mix.Tasks.AshAi.Gen.Chat.Docs do
@moduledoc false
def short_doc do
"Generates the resources and views for a conversational UI backed by `ash_postgres` and `ash_oban`"
end
def example do
"mix ash_ai.gen.chat --user Your.User.Resource --live"
end
def long_doc do
"""
#{short_doc()}
Generates a complete chat feature for your Ash & Phoenix application, including Ash resources for
conversations and messages, Oban background jobs for LLM responses, PubSub-based real-time streaming,
and optional Phoenix LiveView/LiveComponent UIs. Uses `AshAi.ToolLoop.stream/2` with ReqLLM for
incremental streaming responses.
This is primarily a tool to get started with chat features and is by no means intended to handle
every case you can come up with. The generated code is yours to customize.
## Quick Start
From scratch with a new Phoenix app:
```bash
mix igniter.new my_app \\
--with phx.new \\
--install ash,ash_postgres,ash_phoenix \\
--install ash_authentication_phoenix,ash_oban \\
--install ash_ai@github:ash-project/ash_ai \\
--auth-strategy password
```
Then generate the chat feature:
```bash
mix ash_ai.gen.chat --live
```
Set your LLM API key (OpenAI by default):
```bash
export OPENAI_API_KEY=sk-...
```
Start the server and visit `http://localhost:4000/chat`.
## Examples
### Resources only, no UI:
```bash
mix ash_ai.gen.chat --user MyApp.Accounts.User
# Creates: MyApp.Chat domain with Conversation and Message resources
```
### Full-page LiveView with a named domain and Anthropic provider:
```bash
mix ash_ai.gen.chat --user MyApp.Accounts.User --live --provider anthropic --domain MyApp.SupportChat
# Creates: MyApp.SupportChat resources and SupportChatLive mounted at /chat
```
### Embeddable LiveComponent with a custom domain and Gemini provider:
```bash
mix ash_ai.gen.chat --user MyApp.Accounts.User --live-component --domain MyApp.SupportChat --provider gemini
# Creates: MyApp.SupportChat resources and SupportChatComponent
```
### Both LiveView and LiveComponent with all options:
```bash
mix ash_ai.gen.chat --user MyApp.Accounts.User --live --live-component --domain MyApp.SupportChat --route /support/chat --provider anthropic
# Creates: MyApp.SupportChat resources, SupportChatLive at /support/chat, and SupportChatComponent
```
## Options
* `--user` - The user resource module. If omitted, looks for `YourApp.Accounts.User` automatically. If no user resource is found, the generator still works but produces resources without user associations or actor-based filtering.
* `--domain` - The domain module to place the resources in. E.g., `--domain MyApp.SupportChat` generates `MyApp.SupportChat.Conversation` and `MyApp.SupportChat.Message`. Defaults to `YourApp.Chat`.
* `--route` - The URL path for the chat routes. Defaults to `/chat`. Mounts both `route` and `route/:conversation_id`.
* `--provider` - The LLM provider to use: `openai` (default), `anthropic`, or `gemini`. Sets the default model and configures the appropriate API key in `config/runtime.exs`.
* `--extend` - Extensions to apply to the generated resources, passed through to `mix ash.gen.resource`.
* `--live` - Generate a full-page Phoenix LiveView for the chat UI.
* `--live-component` - Generate a reusable Phoenix LiveComponent for embedding the chat UI in existing pages.
* `--yes` - Skip confirmation prompts.
## What Gets Generated
### Dependencies
The generator ensures the following dependencies are installed and configured:
* `ash_phoenix` - for forms and code interfaces
* `ash_oban` - for background job processing
* `mdex` - for Markdown rendering in the UI
* `lumis` - for syntax highlighting in rendered Markdown code blocks
### Domain Module (`YourApp.Chat`)
A domain with `AshPhoenix` and `AshAi` extensions, providing code interfaces:
* `create_conversation/1` - create a new conversation
* `get_conversation/1` - fetch a conversation by ID
* `my_conversations/0` (or `list_conversations/0` without a user) - list conversations for the current actor
* `create_message/1` - send a message (triggers LLM response via Oban)
* `message_history/1` - fetch messages for a conversation, sorted by `inserted_at` desc
### Conversation Resource
* **Attributes**: `id` (UUID v7), `title` (string), `inserted_at`, `updated_at`
* **Relationships**: `has_many :messages`, `belongs_to :user` (when user resource is provided)
* **Actions**:
* `:create` - accepts `title`, relates actor as user
* `:read` - default read
* `:destroy` - default destroy
* `:my_conversations` - filtered to `user_id == actor(:id)` (when user resource is provided)
* `:generate_name` - uses the LLM to generate a 2-8 word title from the first 10 messages
* **Calculations**: `needs_title` - true when title is nil and the conversation has more than 3 messages (or more than 1 message and is older than 10 minutes)
* **Extensions**: `postgres`, `AshOban`
### Message Resource
* **Attributes**:
* `id` (UUID v7, writable)
* `text` (string, required, allows empty, no trimming)
* `tool_calls` (array of maps) - structured tool invocation data
* `tool_results` (array of maps) - tool execution results
* `source` (enum: `:user` | `:agent`, default `:user`)
* `complete` (boolean, default `true`) - false while streaming
* `inserted_at`, `updated_at`
* **Relationships**: `belongs_to :conversation` (required), `belongs_to :response_to` (self-referential), `has_one :response`
* **Actions**:
* `:create` - accepts `text`, validates non-empty, optionally takes `conversation_id` (creates a new conversation if omitted), triggers the `:respond` Oban job
* `:read` - default read
* `:destroy` - default destroy
* `:for_conversation` - keyset-paginated read filtered by `conversation_id`, sorted by `inserted_at` desc
* `:respond` - update action that runs the `Respond` change (streams LLM response via `AshAi.ToolLoop.stream/2`)
* `:upsert_response` - creates or atomically updates the agent's response message, appending streamed text chunks and tool call/result data
* **Calculations**: `needs_response` - true when `source == :user` and no response message exists
* **Extensions**: `postgres`, `AshOban`
### Respond Change
The generated `Respond` change module:
1. Loads the full message history for the conversation
2. Builds a prompt chain with a system message ("You are a helpful chat bot...") followed by the message history
3. Calls `AshAi.ToolLoop.stream/2` with `tools: true` (all AshAi domain tools available)
4. Streams content chunks, upserting the response message as tokens arrive (enabling real-time UI updates via PubSub)
5. Accumulates tool calls and tool results during the stream
6. Finalizes the response message with the complete text, tool calls, and tool results
7. Handles stream errors gracefully with user-facing error messages
### GenerateName Change
Automatically generates a conversation title by sending the first 10 messages to the LLM with
a system prompt requesting a 2-8 word name. Triggered by the `:name_conversation` Oban trigger
when `needs_title` is true.
### Oban Triggers
* `:respond` - runs on the Message resource when `needs_response` is true. Queue: `chat_responses` (limit 10).
* `:name_conversation` - runs on the Conversation resource when `needs_title` is true. Queue: `conversations` (limit 10).
Both triggers use `scheduler_cron false` (event-driven, not polled) and `lock_for_update? false`.
When a user resource is provided, an `AiAgentActorPersister` module is generated to serialize/deserialize
the actor for Oban jobs. The persisted user gets `chat_agent?: true` metadata so you can differentiate
agent-initiated actions in policies.
### Configuration
The generator adds to your app config:
* `config/runtime.exs` - ReqLLM API key for the selected provider
* `config/config.exs` - Oban queue configuration (`chat_responses` and `conversations`, limit 10 each) and MDEx native syntax highlighting via Lumis
### Provider Models
The default model for each provider:
* `openai` → `openai:gpt-4o`
* `anthropic` → `anthropic:claude-sonnet-4-5`
* `gemini` → `google:gemini-1.5-pro`
Change the model string in the generated `Respond` and `GenerateName` change modules to use a different model.
Model strings follow the `"provider:model-name"` format from ReqLLM.
## LiveView (`--live`)
Generates a full-page Phoenix LiveView with:
* **Conversation sidebar** - lists conversations, "New Chat" button, highlights the active conversation
* **Message stream** - displays messages in a chat bubble layout with avatar icons, auto-scrolls to latest
* **Message input** - text input with send button, auto-focuses on mount
* **Real-time streaming** - subscribes to PubSub topics for the active conversation, updates messages as they stream in
* **Agent responding indicator** - shows a loading animation while the LLM is generating
* **Markdown rendering** - agent messages are rendered as HTML via MDEx with GitHub-flavored extensions (strikethrough, tables, autolinks, task lists, footnotes, code highlighting)
* **Tool call/result badges** - displays tool invocations and results inline with messages
* **Responsive drawer** - sidebar collapses on mobile with a hamburger toggle
Routes are added to your router inside the `ash_authentication_live_session` block:
```elixir
live "/chat", ChatLive
live "/chat/:conversation_id", ChatLive
```
### PubSub Topics
The LiveView subscribes to:
* `chat:messages:<conversation_id>` - new and updated messages for the active conversation
* `chat:conversations:<user_id>` - conversation creates/updates (for sidebar)
### Prerequisites
The chat UI templates use Tailwind CSS and DaisyUI for styling. DaisyUI is included in Phoenix 1.8+.
For older Phoenix apps, [install DaisyUI](https://daisyui.com/docs/install/) first.
## LiveComponent (`--live-component`)
Generates a reusable `Phoenix.LiveComponent` with the same features as the LiveView, but embeddable
in existing pages. After generation, you'll see a notice with integration instructions.
Usage in your parent LiveView:
```elixir
<.live_component
module={YourAppWeb.ChatComponent}
id="chat"
current_user={@current_user}
conversation_id={@conversation_id}
hide_sidebar={false}
/>
```
Your parent LiveView must:
1. Subscribe to PubSub and forward broadcasts:
```elixir
def mount(_params, _session, socket) do
if connected?(socket) do
YourAppWeb.ChatComponent.subscribe(socket.assigns.current_user)
end
{:ok, socket}
end
def handle_info(%Phoenix.Socket.Broadcast{} = broadcast, socket) do
send_update(YourAppWeb.ChatComponent, id: "chat", broadcast: broadcast)
{:noreply, socket}
end
```
2. Handle navigation events from the component:
```elixir
def handle_info({:chat_component_navigate, conversation_id}, socket) do
{:noreply, assign(socket, :conversation_id, conversation_id)}
end
```
## Tool Call/Result UI Extraction
Generated chat UI modules delegate tool call and tool result parsing to `AshAi.ChatUI.Tools.extract/1`.
This keeps generated modules small while preserving a stable override seam.
Override in generated modules if you need custom parsing:
```elixir
@chat_ui_tools MyApp.ChatUITools
```
Your custom module must implement `extract/1` returning `{:ok, %{tool_calls: [...], tool_results: [...]}}` or `{:error, reason}`.
## Starter Tools
Generated chat domains include a small default tool set so tool calling works immediately:
* `:chat_list_conversations` - lists conversations visible to the actor.
* `:chat_message_history` - fetches messages for a specific conversation.
These are registered in the domain's `tools` block. The generated `Respond` change uses `tools: true`
to make all tools from all AshAi-enabled domains available to the LLM. To restrict which tools are
available, change `tools: true` to `tools: [:tool_name_1, :tool_name_2]` in the generated Respond module.
## Adding Your Own Tools
Expose Ash actions as tools in any domain:
```elixir
defmodule MyApp.Blog do
use Ash.Domain, extensions: [AshAi]
tools do
tool :read_posts, MyApp.Blog.Post, :read
tool :create_post, MyApp.Blog.Post, :create
end
end
```
These tools become automatically available to the chat LLM when `tools: true` is set.
## Customizing the System Prompt
The generated `Respond` change module contains a default system prompt:
> You are a helpful chat bot. Your job is to use the tools at your disposal to assist the user.
Edit this directly in the generated change module to customize the LLM's behavior.
"""
end
end
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.AshAi.Gen.Chat do
@shortdoc "#{__MODULE__.Docs.short_doc()}"
@moduledoc __MODULE__.Docs.long_doc()
use Igniter.Mix.Task
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :ash_ai,
example: __MODULE__.Docs.example(),
schema: [
user: :string,
domain: :string,
route: :string,
provider: :string,
extend: :string,
live: :boolean,
live_component: :boolean,
yes: :boolean
],
defaults: [live: false, live_component: false, yes: false, provider: "openai"]
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
{igniter, user} = user_module(igniter)
chat =
if igniter.args.options[:domain] do
Igniter.Project.Module.parse(igniter.args.options[:domain])
else
Igniter.Project.Module.module_name(igniter, "Chat")
end
conversation = Module.concat([chat, Conversation])
message = Module.concat([chat, Message])
otp_app = Igniter.Project.Application.app_name(igniter)
igniter
|> ensure_deps(otp_app)
|> configure()
|> create_conversation(conversation, message, user)
|> create_message(chat, conversation, message, otp_app)
|> add_chat_live(chat, conversation, message, user)
|> add_chat_live_component(chat, conversation, message, user)
|> add_code_interfaces(chat, conversation, message, user)
|> add_triggers(message, conversation, user)
|> Ash.Igniter.codegen("add_ai_chat")
|> Igniter.add_notice("""
AshAi:
The chat feature has been generated using the #{to_string(igniter.args.options[:provider])} provider via ReqLLM.
Please see ReqLLM's documentation if you need to configure a different model or provider settings.
Generated chat includes starter tools (`:chat_list_conversations`, `:chat_message_history`) so tool calling works out of the box.
`tools: true` includes all tools available in your AshAi domains. Change to `tools: [:a, :list, :of, :tools]` to scope tool access.
""")
|> maybe_add_live_component_notice(chat)
end
defp ensure_deps(igniter, otp_app) do
{igniter, install_ash_phoenix?} =
if Igniter.Project.Deps.has_dep?(igniter, :ash_phoenix) do
{igniter, false}
else
{Igniter.Project.Deps.add_dep(igniter, {:ash_phoenix, "~> 2.0"}), true}
end
{igniter, install_ash_oban?} =
if Igniter.Project.Deps.has_dep?(igniter, :ash_oban) do
{igniter, false}
else
{Igniter.Project.Deps.add_dep(igniter, {:ash_oban, "~> 0.4"}), true}
end
{igniter, install_mdex?} =
if Igniter.Project.Deps.has_dep?(igniter, :mdex) do
{igniter, false}
else
{Igniter.Project.Deps.add_dep(igniter, {:mdex, "~> 0.7"}), true}
end
{igniter, install_lumis?} =
if Igniter.Project.Deps.has_dep?(igniter, :lumis) do
{igniter, false}
else
{Igniter.Project.Deps.add_dep(igniter, {:lumis, "~> 0.1"}), true}
end
igniter
|> then(fn igniter ->
if install_ash_phoenix? || install_ash_oban? || install_mdex? || install_lumis? do
if igniter.assigns[:test_mode?] do
igniter
else
Igniter.apply_and_fetch_dependencies(igniter, yes: igniter.args.options[:yes])
end
else
igniter
end
end)
|> then(fn igniter ->
if install_ash_phoenix? do
Igniter.compose_task(igniter, "ash_phoenix.install")
else
igniter
end
end)
|> then(fn igniter ->
if install_ash_oban? do
igniter
|> Igniter.compose_task("oban.install")
|> Igniter.compose_task("ash_oban.install")
else
igniter
end
end)
|> Igniter.Project.Config.configure(
"config.exs",
otp_app,
[Oban, :queues, :chat_responses, :limit],
10
)
|> Igniter.Project.Config.configure(
"config.exs",
otp_app,
[Oban, :queues, :conversations, :limit],
10
)
|> Igniter.Project.Config.configure(
"config.exs",
:mdex_native,
[:syntax_highlighter],
:lumis
)
end
defp create_conversation(igniter, conversation, message, user) do
generate_name = Module.concat([conversation, Changes, GenerateName])
provider = llm_provider_config(igniter.args.options[:provider])
relate_actor_change =
if user do
" change relate_actor(:user)\n"
else
""
end
igniter
|> Igniter.compose_task(
"ash.gen.resource",
[
inspect(conversation),
"--attribute",
"title:string:public",
"--uuid-v7-primary-key",
"id",
"--default-actions",
"read,destroy",
"--relationship",
"has_many:messages:#{inspect(message)}:public",
"--timestamps",
"--extend",
"postgres,AshOban",
"--extend",
igniter.args.options[:extend] || ""
] ++
user_relationship(user)
)
|> Ash.Resource.Igniter.add_new_action(conversation, :create, """
create :create do
accept [:title]
#{relate_actor_change} end
""")
|> Ash.Resource.Igniter.add_new_calculation(conversation, :needs_name, """
calculate :needs_title, :boolean do
calculation expr(is_nil(title) and (count(messages) > 3 or (count(messages) > 1 and inserted_at < ago(10, :minute))))
end
""")
|> Ash.Resource.Igniter.add_new_action(conversation, :generate_name, """
update :generate_name do
accept []
transaction? false
require_atomic? false
change #{inspect(generate_name)}
end
""")
|> Igniter.Project.Module.create_module(generate_name, """
use Ash.Resource.Change
require Ash.Query
alias ReqLLM.Context
@impl true
def change(changeset, _opts, context) do
Ash.Changeset.before_transaction(changeset, fn changeset ->
conversation = changeset.data
messages =
#{inspect(message)}
|> Ash.Query.filter(conversation_id == ^conversation.id)
|> Ash.Query.limit(10)
|> Ash.Query.select([:text, :source])
|> Ash.Query.sort(inserted_at: :asc)
|> Ash.read!(scope: context)
prompt_messages =
[
Context.system(\"""
Provide a short name for the current conversation.
2-8 words, preferring more succinct names.
RESPOND WITH ONLY THE NEW CONVERSATION NAME.
\""")
] ++
Enum.map(messages, fn message ->
if message.source == :agent do
Context.assistant(message.text)
else
Context.user(message.text)
end
end)
ReqLLM.generate_text("#{provider.model}", prompt_messages)
|> case do
{:ok, response} ->
Ash.Changeset.force_change_attribute(
changeset,
:title,
ReqLLM.Response.text(response)
)
{:error, error} ->
{:error, error}
end
end)
end
""")
|> then(fn igniter ->
if user do
Ash.Resource.Igniter.add_new_action(igniter, conversation, :my_conversations, """
read :my_conversations do
filter expr(user_id == ^actor(:id))
end
""")
else
igniter
end
end)
end
defp create_message(igniter, chat, conversation, message, otp_app) do
create_conversation_if_not_provided =
Module.concat([message, Changes, CreateConversationIfNotProvided])
respond = Module.concat([message, Changes, Respond])
provider = llm_provider_config(igniter.args.options[:provider])
source = Module.concat([message, Types, Source])
igniter
|> Igniter.compose_task("ash.gen.enum", [
inspect(source),
"agent,user"
])
|> Igniter.compose_task(
"ash.gen.resource",
[
inspect(message),
"--default-actions",
"read,destroy",
"--relationship",
"belongs_to:conversation:#{inspect(conversation)}:public:required",
"--relationship",
"belongs_to:response_to:__MODULE__:public",
"--timestamps",
"--extend",
"postgres,AshOban",
"--extend",
igniter.args.options[:extend] || ""
]
)
|> Ash.Resource.Igniter.add_new_attribute(message, :id, """
uuid_v7_primary_key :id, writable?: true
""")
|> Ash.Resource.Igniter.add_new_attribute(message, :text, """
attribute :text, :string do
constraints allow_empty?: true, trim?: false
public? true
allow_nil? false
end
""")
|> Ash.Resource.Igniter.add_new_attribute(message, :tool_calls, """
attribute :tool_calls, {:array, :map}
""")
|> Ash.Resource.Igniter.add_new_attribute(message, :tool_results, """
attribute :tool_results, {:array, :map}
""")
|> Ash.Resource.Igniter.add_new_attribute(message, :source, """
attribute :source, #{inspect(source)} do
allow_nil? false
public? true
default :user
end
""")
|> Ash.Resource.Igniter.add_new_attribute(message, :complete, """
attribute :complete, :boolean do
allow_nil? false
default true
end
""")
|> Ash.Resource.Igniter.add_new_relationship(message, :response, """
has_one :response, __MODULE__ do
public? true
destination_attribute :response_to_id
end
""")
|> Igniter.Project.Module.create_module(
create_conversation_if_not_provided,
"""
use Ash.Resource.Change
@impl true
def change(changeset, _opts, context) do
if changeset.arguments[:conversation_id] do
Ash.Changeset.force_change_attribute(
changeset,
:conversation_id,
changeset.arguments.conversation_id
)
else
Ash.Changeset.before_action(changeset, fn changeset ->
conversation = #{inspect(chat)}.create_conversation!(Ash.Context.to_opts(context))
Ash.Changeset.force_change_attribute(changeset, :conversation_id, conversation.id)
end)
end
end
"""
)
|> Ash.Resource.Igniter.add_new_action(message, :for_conversation, """
read :for_conversation do
pagination keyset?: true, required?: false
argument :conversation_id, :uuid, allow_nil?: false
prepare build(default_sort: [inserted_at: :desc])
filter expr(conversation_id == ^arg(:conversation_id))
end
""")
|> Ash.Resource.Igniter.add_new_action(message, :create, """
create :create do
accept [:text]
validate match(:text, ~r/\\S/) do
message "Message cannot be empty"
end
argument :conversation_id, :uuid do
public? false
end
change #{inspect(create_conversation_if_not_provided)}
change run_oban_trigger(:respond)
end
""")
|> Ash.Resource.Igniter.add_new_action(message, :respond, """
update :respond do
accept []
require_atomic? false
transaction? false
change #{inspect(respond)}
end
""")
|> Ash.Resource.Igniter.add_new_action(message, :upsert_response, """
create :upsert_response do
upsert? true
accept [:id, :response_to_id, :conversation_id]
argument :complete, :boolean, default: false
argument :text, :string, allow_nil?: false, constraints: [trim?: false, allow_empty?: true]
argument :tool_calls, {:array, :map}
argument :tool_results, {:array, :map}
# if updating
# if complete, set the text to the provided text
# if streaming still, add the text to the provided text
change atomic_update(:text, {:atomic, expr(
if ^arg(:complete) do
^arg(:text)
else
text <> ^arg(:text)
end
)})
change atomic_update(
:tool_calls,
{:atomic,
expr(
if not is_nil(^arg(:tool_calls)) do
fragment(
"? || ?",
tool_calls,
type(
^arg(:tool_calls),
{:array, :map}
)
)
else
tool_calls
end
)}
)
change atomic_update(
:tool_results,
{:atomic,
expr(
if not is_nil(^arg(:tool_results)) do
fragment(
"? || ?",
tool_results,
type(
^arg(:tool_results),
{:array, :map}
)
)
else
tool_results
end
)}
)
# if creating, set the text attribute to the provided text
change set_attribute(:text, arg(:text))
change set_attribute(:complete, arg(:complete))
change set_attribute(:source, :agent)
change set_attribute(:tool_results, arg(:tool_results))
change set_attribute(:tool_calls, arg(:tool_calls))
# on update, only set complete to its new value
upsert_fields [:complete]
end
""")
|> Ash.Resource.Igniter.add_new_calculation(message, :needs_response, """
calculate :needs_response, :boolean do
calculation expr(source == :user and not exists(response))
end
""")
|> Igniter.Project.Module.create_module(respond, """
use Ash.Resource.Change
require Ash.Query
alias ReqLLM.Context
@impl true
def change(changeset, _opts, context) do
Ash.Changeset.before_transaction(changeset, fn changeset ->
message = changeset.data
messages =
#{inspect(message)}
|> Ash.Query.filter(conversation_id == ^message.conversation_id)
|> Ash.Query.filter(id != ^message.id)
|> Ash.Query.select([:text, :source, :tool_calls, :tool_results])
|> Ash.Query.sort(inserted_at: :asc)
|> Ash.read!(scope: context)
|> Enum.concat([%{source: :user, text: message.text}])
prompt_messages =
[
Context.system(\"""
You are a helpful chat bot.
Your job is to use the tools at your disposal to assist the user.
\""")
] ++ message_chain(messages)
new_message_id = Ash.UUIDv7.generate()
final_state =
prompt_messages
|> AshAi.ToolLoop.stream(
otp_app: :#{otp_app},
tools: true,
model: "#{provider.model}",
actor: context.actor,
tenant: context.tenant,
context: Map.new(Ash.Context.to_opts(context))
)
|> Enum.reduce(%{text: "", tool_calls: [], tool_results: [], stream_error: nil}, fn
{:content, content}, acc ->
if content not in [nil, ""] do
#{inspect(message)}
|> Ash.Changeset.for_create(
:upsert_response,
%{
id: new_message_id,
response_to_id: message.id,
conversation_id: message.conversation_id,
text: content
},
actor: %AshAi{}
)
|> Ash.create!()
end
%{acc | text: acc.text <> (content || "")}
{:tool_call, tool_call}, acc ->
%{acc | tool_calls: append_event(acc.tool_calls, tool_call)}
{:tool_result, %{id: id, result: result}}, acc ->
%{
acc
| tool_results:
append_event(acc.tool_results, normalize_tool_result(id, result))
}
{:error, reason}, acc ->
%{acc | stream_error: reason}
{:done, _}, acc ->
acc
_, acc ->
acc
end)
stream_error_text = stream_error_text(final_state.stream_error)
final_text =
cond do
stream_error_text && String.trim(final_state.text || "") != "" ->
final_state.text <> "\\n\\n" <> stream_error_text
stream_error_text ->
stream_error_text
String.trim(final_state.text || "") == "" &&
(final_state.tool_calls != [] || final_state.tool_results != []) ->
"Completed tool call."
true ->
final_state.text
end
if final_state.stream_error ||
final_state.tool_calls != [] ||
final_state.tool_results != [] ||
final_text != "" do
#{inspect(message)}
|> Ash.Changeset.for_create(
:upsert_response,
%{
id: new_message_id,
response_to_id: message.id,
conversation_id: message.conversation_id,
complete: true,
tool_calls: final_state.tool_calls,
tool_results: final_state.tool_results,
text: final_text
},
actor: %AshAi{}
)
|> Ash.create!()
end
changeset
end)
end
defp message_chain(messages) do
Enum.map(messages, fn
%{source: :agent, text: text} ->
# Historical tool call replay can break provider request validation for prior call IDs.
# Keep replay text-only; current turn tool usage is handled by AshAi.ToolLoop.
Context.assistant(text || "")
%{source: :user, text: text} ->
Context.user(text || "")
end)
end
defp append_event(items, value) when is_list(items), do: items ++ [value]
defp append_event(_items, value), do: [value]
defp normalize_tool_result(tool_call_id, {:ok, content, _raw}) do
%{
tool_call_id: tool_call_id,
content: content,
is_error: false
}
end
defp normalize_tool_result(tool_call_id, {:error, content}) do
%{
tool_call_id: tool_call_id,
content: content,
is_error: true
}
end
defp stream_error_text(nil), do: nil
defp stream_error_text(:max_iterations_reached) do
"I hit a response limit while generating this reply. Please try again."
end
defp stream_error_text(_reason) do
"I hit an error while generating this response. Please try again."
end
""")
end
defp set_conversation_pub_sub(igniter, conversation, endpoint) do
igniter
|> Spark.Igniter.add_extension(conversation, Ash.Resource, :notifiers, Ash.Notifier.PubSub)
|> Igniter.Project.Module.find_and_update_module!(conversation, fn zipper ->
with {:ok, zipper} <- ensure_pub_sub(zipper, endpoint),
{:ok, zipper} <- ensure_prefix(zipper),
{:ok, zipper} <-
add_new_publish(zipper, :create, :publish_all, """
publish_all :create, ["conversations", :user_id] do
transform &(&1.data)
end
"""),
{:ok, zipper} <-
add_new_publish(zipper, :update, :publish_all, """
publish_all :update, ["conversations", :user_id] do
transform &(&1.data)
end
""") do
{:ok, zipper}
else
_ ->
{:ok, zipper}
end
end)
end
defp set_message_pub_sub(igniter, message, endpoint) do
igniter
|> Spark.Igniter.add_extension(message, Ash.Resource, :notifiers, Ash.Notifier.PubSub)
|> Igniter.Project.Module.find_and_update_module!(message, fn zipper ->
with {:ok, zipper} <- ensure_pub_sub(zipper, endpoint),
{:ok, zipper} <- ensure_prefix(zipper),
{:ok, zipper} <-
add_new_publish(zipper, :create, """
publish :create, ["messages", :conversation_id] do
transform fn %{data: message} ->
%{
text: message.text,
id: message.id,
source: message.source,
complete: message.complete,
tool_calls: message.tool_calls,
tool_results: message.tool_results
}
end
end
"""),
{:ok, zipper} <-
add_new_publish(zipper, :upsert_response, """
publish :upsert_response, ["messages", :conversation_id] do
transform fn %{data: message} ->
%{
text: message.text,
id: message.id,
source: message.source,
complete: message.complete,
tool_calls: message.tool_calls,
tool_results: message.tool_results
}
end
end
""") do
{:ok, zipper}
else
_ ->
{:ok, zipper}
end
end)
end
defp add_new_publish(zipper, name, type \\ :publish, code) do
Igniter.Code.Common.within(zipper, fn zipper ->
case Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
type,
[2, 3],
&Igniter.Code.Function.argument_equals?(&1, 0, name)
) do
{:ok, _} ->
{:ok, zipper}
:error ->
{:ok, Igniter.Code.Common.add_code(zipper, code)}
end
end)
end
defp ensure_pub_sub(zipper, endpoint) do
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, :pub_sub, 1),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
{:ok, zipper}
else
_ ->
zipper =
Igniter.Code.Common.add_code(zipper, """
pub_sub do
module #{inspect(endpoint)}
prefix "chat"
end
""")
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, :pub_sub, 1),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
{:ok, zipper}
else
_ ->
:error
end
end
end
defp ensure_prefix(zipper) do
Igniter.Code.Common.within(zipper, fn zipper ->
case Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, :prefix, 1) do
{:ok, _zipper} ->
{:ok, zipper}
:error ->
{:ok,
Igniter.Code.Common.add_code(zipper, """
prefix "chat"
""")}
end
end)
end
defp add_code_interfaces(igniter, chat, conversation, message, user) do
igniter
|> Spark.Igniter.add_extension(chat, Ash.Domain, :extensions, AshPhoenix)
|> Spark.Igniter.add_extension(chat, Ash.Domain, :extensions, AshAi)
|> Ash.Domain.Igniter.add_new_code_interface(
chat,
conversation,
:create_conversation,
"define :create_conversation, action: :create"
)
|> Ash.Domain.Igniter.add_new_code_interface(
chat,
conversation,
:get_conversation,
"define :get_conversation, action: :read, get_by: [:id]"
)
|> Ash.Domain.Igniter.add_new_code_interface(
chat,
message,
:message_history,
"""
define :message_history,
action: :for_conversation,
args: [:conversation_id],
default_options: [query: [sort: [inserted_at: :desc]]]
"""
)
|> Ash.Domain.Igniter.add_new_code_interface(
chat,
message,
:create_message,
"define :create_message, action: :create"
)
|> then(fn igniter ->
if user do
Ash.Domain.Igniter.add_new_code_interface(
igniter,
chat,
conversation,
:my_conversations,
"define :my_conversations"
)
else
Ash.Domain.Igniter.add_new_code_interface(
igniter,
chat,
conversation,
:list_conversations,
"define :list_conversations, action: :read"
)
end
end)
|> add_default_tools(chat, conversation, message, user)
end
defp add_default_tools(igniter, chat, conversation, message, user) do
list_action = if(user, do: :my_conversations, else: :read)
Igniter.Project.Module.find_and_update_module!(igniter, chat, fn zipper ->
with {:ok, zipper} <- ensure_tools(zipper),
{:ok, zipper} <-
add_new_domain_tool(
zipper,
:chat_list_conversations,
conversation,
list_action,
"List chat conversations visible to the current actor."
),
{:ok, zipper} <-
add_new_domain_tool(
zipper,
:chat_message_history,
message,
:for_conversation,
"Read chat messages for a conversation_id."
) do
{:ok, zipper}
else
_ ->
{:ok, zipper}
end
end)
end
defp ensure_tools(zipper) do
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, :tools, 1),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
{:ok, zipper}
else
_ ->
zipper =
Igniter.Code.Common.add_code(zipper, """
tools do
end
""")
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, :tools, 1),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
{:ok, zipper}
else
_ ->
:error
end
end
end
defp add_new_domain_tool(zipper, tool_name, resource, action, description) do
Igniter.Code.Common.within(zipper, fn zipper ->
case Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:tool,
[3, 4],
&Igniter.Code.Function.argument_equals?(&1, 0, tool_name)
) do
{:ok, _} ->
{:ok, zipper}
:error ->
{:ok,
Igniter.Code.Common.add_code(
zipper,
"""
tool #{inspect(tool_name)}, #{inspect(resource)}, :#{action} do
description #{inspect(description)}
end
"""
)}
end
end)
end
defp add_chat_live(igniter, chat, conversation, message, user) do
if igniter.args.options[:live] do
web_module = Igniter.Libs.Phoenix.web_module(igniter)
chat_suffix = chat |> Module.split() |> List.last()
chat_live = Igniter.Libs.Phoenix.web_module_name(igniter, "#{chat_suffix}Live")
live_user_auth = Igniter.Libs.Phoenix.web_module_name(igniter, "LiveUserAuth")
route = igniter.args.options[:route] || "/chat"
{igniter, router} =
Igniter.Libs.Phoenix.select_router(
igniter,
"Which `Phoenix.Router` should be we add the chat routes to?"
)
if router do
{igniter, endpoint} =
Igniter.Libs.Phoenix.select_endpoint(
igniter,
router,
"Which `Phoenix.Endpoint` should be we use for pubsub events?"
)
if endpoint do
{live_user_auth_exists?, igniter} =
Igniter.Project.Module.module_exists(igniter, live_user_auth)
on_mount =
if live_user_auth_exists? do
"on_mount {#{inspect(live_user_auth)}, :live_user_required}"
else
"# on_mount {#{inspect(live_user_auth)}, :live_user_required}"
end
Igniter.Project.Module.create_module(
igniter,
chat_live,
chat_live_contents(web_module, on_mount, endpoint, chat, user, route)
)
|> set_message_pub_sub(message, endpoint)
|> set_conversation_pub_sub(conversation, endpoint)
|> add_chat_live_route(chat_live, router, route)
else
Igniter.add_warning(
igniter,
"Could not find an endpoint for pubsub, or no endpoint was selected, liveviews have been skipped."
)
end
else
Igniter.add_warning(
igniter,
"Could not find a router for placing routes in, or no router was selected, liveviews have been skipped."
)
end
else
igniter
end
end
defp add_chat_live_route(igniter, chat_live, router, route) do
live_module = inspect(Module.split(chat_live) |> Enum.drop(1) |> Module.concat())
live =
"""
live \"#{route}\", #{live_module}
live \"#{route}/:conversation_id\", #{live_module}
"""
if router do
Igniter.Project.Module.find_and_update_module!(igniter, router, fn zipper ->
with {:ok, zipper} <-
Igniter.Code.Common.move_to(
zipper,
&Igniter.Code.Function.function_call?(&1, :ash_authentication_live_session, [
1,
2,
3
])
),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
{:ok, Igniter.Code.Common.add_code(zipper, live, placement: :before)}
else
:error ->
{:warning,
"""
AshAi: Couldn't add the chat routes to `#{inspect(router)}`.
Please add them manually.
#{live}
"""}
end
end)
else
Igniter.add_notice(
igniter,
"""
AshAi: Could not determine a phoenix router, could not add the chat route manually:
#{live}
"""
)
end
end
defp add_chat_live_component(igniter, chat, conversation, message, user) do
if igniter.args.options[:live_component] do
web_module = Igniter.Libs.Phoenix.web_module(igniter)
chat_suffix = chat |> Module.split() |> List.last()
component_name = "#{chat_suffix}Component"
chat_component = Igniter.Libs.Phoenix.web_module_name(igniter, component_name)
{igniter, endpoint} =
Igniter.Libs.Phoenix.select_endpoint(
igniter,
nil,
"Which `Phoenix.Endpoint` should we use for pubsub events?"
)
if endpoint do
Igniter.Project.Module.create_module(
igniter,
chat_component,
chat_live_component_contents(web_module, endpoint, chat, user)
)
|> set_message_pub_sub(message, endpoint)
|> set_conversation_pub_sub(conversation, endpoint)
else
Igniter.add_warning(
igniter,
"Could not find an endpoint for pubsub, or no endpoint was selected, live component has been skipped."
)
end
else
igniter
end
end
defp maybe_add_live_component_notice(igniter, chat) do
if igniter.args.options[:live_component] do
web_module = Igniter.Libs.Phoenix.web_module(igniter)
chat_suffix = chat |> Module.split() |> List.last()
component_module = Module.concat([web_module, :"#{chat_suffix}Component"])
Igniter.add_notice(igniter, """
AshAi LiveComponent:
A #{inspect(component_module)} has been generated.
To embed the chat component in your LiveView:
<.live_component
module={#{inspect(component_module)}}
id="chat"
current_user={@current_user}
conversation_id={@conversation_id}
hide_sidebar={false}
/>
Your parent LiveView must:
1. Subscribe to PubSub topics and forward broadcasts:
def mount(_params, _session, socket) do
if connected?(socket) do
#{inspect(component_module)}.subscribe(socket.assigns.current_user)
end
{:ok, socket}
end
def handle_info(%Phoenix.Socket.Broadcast{} = broadcast, socket) do
send_update(#{inspect(component_module)}, id: "chat", broadcast: broadcast)
{:noreply, socket}
end
2. Handle navigation requests from the component:
def handle_info({:chat_component_navigate, nil}, socket) do
# Handle new chat - update conversation_id assign or navigate
{:noreply, assign(socket, :conversation_id, nil)}
end
def handle_info({:chat_component_navigate, conversation_id}, socket) do
# Handle conversation selection - update conversation_id assign or navigate
{:noreply, assign(socket, :conversation_id, conversation_id)}
end
""")
else
igniter
end
end
defp llm_provider_config(provider) do
case to_string(provider) do
"openai" ->
%{
env_var: "OPENAI_API_KEY",
req_llm_key: :openai_api_key,
model: "openai:gpt-4o"
}
"anthropic" ->
%{
env_var: "ANTHROPIC_API_KEY",
req_llm_key: :anthropic_api_key,
model: "anthropic:claude-sonnet-4-5"
}
"gemini" ->
%{
env_var: "GOOGLE_API_KEY",
req_llm_key: :google_api_key,
model: "google:gemini-1.5-pro"
}
_ ->
%{
env_var: "OPENAI_API_KEY",
req_llm_key: :openai_api_key,
model: "openai:gpt-4o"
}
end
end
defp configure(igniter) do
provider = llm_provider_config(igniter.args.options[:provider])
env_var = provider.env_var
api_key_code =
quote do
System.get_env(unquote(env_var))
end
igniter
|> Igniter.Project.Config.configure_new(
"runtime.exs",
:req_llm,
[provider.req_llm_key],
{:code, api_key_code}
)
|> Igniter.Project.IgniterConfig.add_extension(Igniter.Extensions.Phoenix)
end
defp user_relationship(nil), do: []
defp user_relationship(user) do
["--relationship", "belongs_to:user:#{inspect(user)}:public:required"]
end
defp user_module(igniter) do
if igniter.args.options[:user] do
{igniter, Igniter.Project.Module.parse(igniter.args.options[:user])}
else
default =
Igniter.Project.Module.module_name(igniter, "Accounts.User")
{exists?, igniter} = Igniter.Project.Module.module_exists(igniter, default)
if exists? do
{igniter, default}
else
igniter =
Igniter.add_warning(igniter, """
`ash_ai.gen.chat` works better if a user module is known.
We could not find one automatically, and one was not provided.
Please abort this command and provide one using the `--user` flag,
or install `AshAuthentication` before-hand, being sure its installed
before this command is run.
""")
{igniter, nil}
end
end
end
defp add_triggers(igniter, message, conversation, user) do
actor_persister =
if user do
Igniter.Project.Module.module_name(igniter, "AiAgentActorPersister")
end
respond_worker_module_name = Module.concat([message, "Workers.Respond"])
respond_scheduler_module_name = Module.concat([message, "Schedulers.Respond"])
name_conversation_worker_module_name =
Module.concat([message, "Workers.NameConversation"])
name_conversation_scheduler_module_name =
Module.concat([message, "Schedulers.NameConversation"])
igniter
|> then(fn igniter ->
if actor_persister do
Igniter.Project.Module.find_and_update_or_create_module(
igniter,
actor_persister,
"""
use AshOban.ActorPersister
def store(%#{inspect(user)}{id: id}), do: %{"type" => "user", "id" => id}
def lookup(%{"type" => "user", "id" => id}) do
with {:ok, user} <- Ash.get(#{inspect(user)}, id, authorize?: false) do
# you can change the behavior of actions
# or what your policies allow
# using the `chat_agent?` metadata
{:ok, Ash.Resource.set_metadata(user, %{chat_agent?: true})}
end
end
# This allows you to set a default actor
# in cases where no actor was present
# when scheduling.
def lookup(nil), do: {:ok, nil}
""",
fn zipper -> {:ok, zipper} end
)
else
igniter
end
end)
|> add_new_trigger(message, :respond, """
trigger :respond do
#{if actor_persister, do: "actor_persister #{inspect(actor_persister)}"}
action :respond
queue :chat_responses
lock_for_update? false
scheduler_cron false
worker_module_name #{inspect(respond_worker_module_name)}
scheduler_module_name #{inspect(respond_scheduler_module_name)}
where expr(needs_response)
end
""")
|> add_new_trigger(conversation, :respond, """
trigger :name_conversation do
action :generate_name
queue :conversations
lock_for_update? false
worker_module_name #{inspect(name_conversation_worker_module_name)}
scheduler_module_name #{inspect(name_conversation_scheduler_module_name)}
where expr(needs_title)
end
""")
end
defp add_new_trigger(igniter, conversation, name, code) do
apply(AshOban.Igniter, :add_new_trigger, [igniter, conversation, name, code])
end
defp chat_live_contents(web_module, on_mount, endpoint, chat, user, route) do
interface_name =
if user do
"my_conversations"
else
"list_conversations"
end
actor_required? = !!user
list_conversations_call =
if user do
"#{inspect(chat)}.#{interface_name}!(actor: socket.assigns.current_user)"
else
"#{inspect(chat)}.#{interface_name}!()"
end
get_conversation_call =
if user do
"#{inspect(chat)}.get_conversation!(conversation_id, actor: socket.assigns.current_user)"
else
"#{inspect(chat)}.get_conversation!(conversation_id)"
end
form_with_conversation_call =
if user do
"#{inspect(chat)}.form_to_create_message(actor: socket.assigns.current_user, private_arguments: %{conversation_id: socket.assigns.conversation.id})"
else
"#{inspect(chat)}.form_to_create_message(private_arguments: %{conversation_id: socket.assigns.conversation.id})"
end
form_without_conversation_call =
if user do
"#{inspect(chat)}.form_to_create_message(actor: socket.assigns.current_user)"
else
"#{inspect(chat)}.form_to_create_message()"
end
"""
use #{web_module}, :live_view
@actor_required? #{actor_required?}
@chat_ui_tools AshAi.ChatUI.Tools
#{on_mount}
def render(assigns) do
~H\"""
<div class="drawer md:drawer-open bg-base-200 min-h-dvh max-h-dvh">
<input id="ash-ai-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<div
:if={Phoenix.Flash.get(@flash, :warning)}
class="alert alert-warning m-4 mb-0 text-sm"
>
{Phoenix.Flash.get(@flash, :warning)}
</div>
<div class="navbar bg-base-300 w-full">
<div class="flex-none md:hidden">
<label for="ash-ai-drawer" aria-label="open sidebar" class="btn btn-square btn-ghost">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-6 w-6 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
>
</path>
</svg>
</label>
</div>
<img
src="https://github.com/ash-project/ash_ai/blob/main/logos/ash_ai.png?raw=true"
alt="Logo"
class="h-12"
height="48"
/>
<div class="mx-2 flex-1 px-2">
<p :if={@conversation}>{build_conversation_title_string(@conversation.title)}</p>
<p class="text-xs">AshAi</p>
</div>
</div>
<div class="flex-1 flex flex-col overflow-y-scroll bg-base-200 max-h-[calc(100dvh-8rem)]">
<div
id="message-container"
phx-update="stream"
class="flex-1 overflow-y-auto overflow-x-hidden px-4 py-2 flex flex-col-reverse"
>
<%= for {id, message} <- @streams.messages do %>
<div
id={id}
class={[
"chat",
message.source == :user && "chat-end",
message.source == :agent && "chat-start"
]}
>
<div :if={message.source == :agent} class="chat-image avatar">
<div class="w-10 rounded-full bg-base-300 p-1">
<img
src="https://github.com/ash-project/ash_ai/blob/main/logos/ash_ai.png?raw=true"
alt="Logo"
/>
</div>
</div>
<div :if={message.source == :user} class="chat-image avatar avatar-placeholder">
<div class="w-10 rounded-full bg-base-300">
<.icon name="hero-user-solid" class="block" />
</div>
</div>
<div
:if={message.source == :agent && tool_calls(message) != []}
class="mt-2 flex w-full max-w-[36rem] min-w-0 flex-wrap gap-1 text-[11px] opacity-80"
>
<%= for tool_call <- tool_calls(message) do %>
<span class="badge badge-outline badge-info max-w-full min-w-0 justify-start overflow-hidden text-ellipsis whitespace-nowrap">
tool: {tool_call.name}
<span :if={tool_call.arguments != %{}}>
({tool_call.arguments_preview})
</span>
</span>
<% end %>
</div>
<div
:if={message.source == :agent && tool_results(message) != []}
class="chat-footer mt-1 flex w-full max-w-[36rem] min-w-0 flex-col gap-1"
>
<%= for tool_result <- tool_results(message) do %>
<div
class={[
"rounded max-w-full overflow-hidden px-2 py-1 text-xs leading-relaxed break-words",
tool_result.is_error && "bg-error/20",
!tool_result.is_error && "bg-base-300"
]}
>
<span class="font-semibold">
{if tool_result.is_error, do: "tool_error", else: "tool_result"}
</span>
<span :if={tool_result.name}> ({tool_result.name})</span>
<span class="break-all">
: {tool_result.content_preview}
</span>
</div>
<% end %>
</div>
<div :if={String.trim(message.text || "") != ""} class="chat-bubble">
<%= to_markdown(message.text || "") %>
</div>
</div>
<% end %>
</div>
</div>
<div :if={@agent_responding} class="px-4 py-2 text-xs opacity-80 flex items-center gap-2">
<span class="loading loading-dots loading-sm" />
<span>AshAi is responding...</span>
</div>
<div class="p-4 border-t">
<.form
:let={form}
for={@message_form}
phx-change="validate_message"
phx-debounce="blur"
phx-submit="send_message"
class="flex items-center gap-4"
>
<div class="flex-1">
<input
name={form[:text].name}
value={form[:text].value}
type="text"
phx-mounted={JS.focus()}
placeholder="Type your message..."
class="input input-primary w-full mb-0"
autocomplete="off"
/>
</div>
<button type="submit" class="btn btn-primary rounded-full">
<.icon name="hero-paper-airplane" /> Send
</button>
</.form>
</div>
</div>
<div class="drawer-side border-r bg-base-300 min-w-72">
<div class="py-4 px-6">
<div class="text-lg mb-4">
Conversations
</div>
<div class="mb-4">
<.link navigate={~p"#{route}"} class="btn btn-primary btn-lg mb-2">
<div class="rounded-full bg-primary-content text-primary w-6 h-6 flex items-center justify-center">
<.icon name="hero-plus" />
</div>
<span>New Chat</span>
</.link>
</div>
<ul class="flex flex-col-reverse" phx-update="stream" id="conversations-list">
<%= for {id, conversation} <- @streams.conversations do %>
<li id={id}>
<.link
navigate={~p"#{route}/\#{conversation.id}"}
phx-click="select_conversation"
phx-value-id={conversation.id}
class={"block py-2 px-3 transition border-l-4 pl-2 mb-2 \#{if @conversation && @conversation.id == conversation.id, do: "border-primary font-medium", else: "border-transparent"}"}
>
{build_conversation_title_string(conversation.title)}
</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
\"""
end
def build_conversation_title_string(title) do
cond do
title == nil -> "Untitled conversation"
is_binary(title) && String.length(title) > 25 -> String.slice(title, 0, 25) <> "..."
is_binary(title) && String.length(title) <= 25 -> title
end
end
def mount(_params, _session, socket) do
socket = assign_new(socket, :current_user, fn -> nil end)
if socket.assigns.current_user do
#{inspect(endpoint)}.subscribe("chat:conversations:\#{socket.assigns.current_user.id}")
end
conversations =
if @actor_required? && is_nil(socket.assigns.current_user) do
[]
else
#{list_conversations_call}
end
socket =
socket
|> assign(:page_title, "Chat")
|> stream(:conversations, conversations)
|> assign(:agent_responding, false)
|> assign(:tool_data_warning_shown?, false)
|> assign(:messages, [])
{:ok, socket}
end
def handle_params(%{"conversation_id" => conversation_id}, _, socket) do
if @actor_required? && is_nil(socket.assigns.current_user) do
{:noreply,
socket
|> put_flash(:error, "You must sign in to access conversations")
|> push_navigate(to: ~p"#{route}")}
else
conversation =
#{get_conversation_call}
messages = #{inspect(chat)}.message_history!(conversation.id, stream?: true)
cond do
socket.assigns[:conversation] && socket.assigns[:conversation].id == conversation.id ->
:ok
socket.assigns[:conversation] ->
#{inspect(endpoint)}.unsubscribe("chat:messages:\#{socket.assigns.conversation.id}")
#{inspect(endpoint)}.subscribe("chat:messages:\#{conversation.id}")
true ->
#{inspect(endpoint)}.subscribe("chat:messages:\#{conversation.id}")
end
socket
|> maybe_warn_tool_data(messages)
|> assign(:conversation, conversation)
|> assign(:agent_responding, agent_response_pending?(messages))
|> stream(:messages, messages)
|> assign_message_form()
|> then(&{:noreply, &1})
end
end
def handle_params(_, _, socket) do
if socket.assigns[:conversation] do
#{inspect(endpoint)}.unsubscribe("chat:messages:\#{socket.assigns.conversation.id}")
end
socket
|> assign(:conversation, nil)
|> assign(:agent_responding, false)
|> stream(:messages, [])
|> assign_message_form()
|> then(&{:noreply, &1})
end
def handle_event("validate_message", %{"form" => params}, socket) do
{:noreply, assign(socket, :message_form, AshPhoenix.Form.validate(socket.assigns.message_form, params))}
end
def handle_event("send_message", %{"form" => params}, socket) do
if @actor_required? && is_nil(socket.assigns.current_user) do
{:noreply, put_flash(socket, :error, "You must sign in to send messages")}
else
case AshPhoenix.Form.submit(socket.assigns.message_form, params: params) do
{:ok, message} ->
if socket.assigns.conversation do
socket
|> assign(:agent_responding, true)
|> assign_message_form()
|> stream_insert(:messages, message, at: 0)
|> then(&{:noreply, &1})
else
{:noreply,
socket
|> push_navigate(to: ~p"#{route}/\#{message.conversation_id}")}
end
{:error, form} ->
{:noreply, assign(socket, :message_form, form)}
end
end
end
def handle_info(
%Phoenix.Socket.Broadcast{
topic: "chat:messages:" <> conversation_id,
payload: message
},
socket
) do
if socket.assigns.conversation && socket.assigns.conversation.id == conversation_id do
socket =
socket
|> maybe_warn_tool_data(message)
|> stream_insert(:messages, message, at: 0)
|> update_agent_responding(message)
{:noreply, socket}
else
{:noreply, socket}
end
end
def handle_info(
%Phoenix.Socket.Broadcast{
topic: "chat:conversations:" <> _,
payload: conversation
},
socket
) do
socket =
if socket.assigns.conversation && socket.assigns.conversation.id == conversation.id do
assign(socket, :conversation, conversation)
else
socket
end
{:noreply, stream_insert(socket, :conversations, conversation)}
end
defp assign_message_form(socket) do
form =
if socket.assigns.conversation do
#{form_with_conversation_call}
|> to_form()
else
#{form_without_conversation_call}
|> to_form()
end
assign(
socket,
:message_form,
form
)
end
defp tool_calls(message), do: safe_extract(message).tool_calls
defp tool_results(message), do: safe_extract(message).tool_results
defp safe_extract(message) do
case @chat_ui_tools.extract(message) do
{:ok, extracted} ->
extracted
{:error, _} ->
%{tool_calls: [], tool_results: []}
end
end
defp maybe_warn_tool_data(socket, messages) when is_list(messages) do
Enum.reduce(messages, socket, fn message, acc ->
maybe_warn_tool_data(acc, message)
end)
end
defp maybe_warn_tool_data(socket, message) do
if agent_message?(message) do
case @chat_ui_tools.extract(message) do
{:ok, _} ->
socket
{:error, _} ->
maybe_put_tool_data_warning(socket)
end
else
socket
end
end
defp maybe_put_tool_data_warning(socket) do
if socket.assigns[:tool_data_warning_shown?] do
socket
else
socket
|> put_flash(:warning, "Some tool call data could not be displayed.")
|> assign(:tool_data_warning_shown?, true)
end
end
defp message_source(%{source: source}), do: source
defp message_source(%{"source" => source}), do: source
defp message_source(_), do: nil
defp message_complete?(%{complete: complete}), do: complete in [true, "true"]
defp message_complete?(%{"complete" => complete}), do: complete in [true, "true"]
defp message_complete?(_), do: false
defp user_message?(message), do: message_source(message) in [:user, "user"]
defp agent_message?(message), do: message_source(message) in [:agent, "agent"]
defp update_agent_responding(socket, message) do
cond do
user_message?(message) ->
assign(socket, :agent_responding, true)
agent_message?(message) ->
assign(socket, :agent_responding, !message_complete?(message))
true ->
socket
end
end
defp agent_response_pending?(messages) do
case Enum.find(messages, fn message -> user_message?(message) or agent_message?(message) end) do
nil -> false
message -> user_message?(message) || !message_complete?(message)
end
end
defp to_markdown(text) do
# Note that you must pass the "unsafe: true" option to first generate the raw HTML
# in order to sanitize it. https://hexdocs.pm/mdex/MDEx.html#module-sanitize
MDEx.to_html(text,
extension: [
strikethrough: true,
tagfilter: true,
table: true,
autolink: true,
tasklist: true,
footnotes: true,
shortcodes: true
],
parse: [
smart: true,
relaxed_tasklist_matching: true,
relaxed_autolinks: true
],
render: [
github_pre_lang: true,
unsafe: true
],
sanitize: MDEx.Document.default_sanitize_options()
)
|> case do
{:ok, html} ->
html
|> Phoenix.HTML.raw()
{:error, _} -> text
end
end
"""
end
defp chat_live_component_contents(web_module, endpoint, chat, user) do
interface_name =
if user do
"my_conversations"
else
"list_conversations"
end
actor_required? =
if user do
"true"
else
"false"
end
"""
use #{inspect(web_module)}, :live_component
@chat_ui_tools AshAi.ChatUI.Tools
@impl true
def update(%{broadcast: broadcast}, socket) do
{:ok, handle_broadcast(socket, broadcast)}
end
def update(assigns, socket) do
socket = assign(socket, assigns)
socket =
if !socket.assigns[:initialized] do
conversations =
if #{actor_required?} && is_nil(socket.assigns.current_user) do
[]
else
#{inspect(chat)}.#{interface_name}!(actor: socket.assigns.current_user)
end
socket
|> assign(:initialized, true)
|> assign_new(:hide_sidebar, fn -> false end)
|> assign_new(:conversation, fn -> nil end)
|> assign_new(:conversation_id, fn -> nil end)
|> assign_new(:agent_responding, fn -> false end)
|> assign_new(:tool_data_warning_shown?, fn -> false end)
|> stream(:conversations, conversations)
|> stream(:messages, [])
|> assign_message_form()
else
socket
end
socket =
cond do
socket.assigns[:conversation_id] &&
socket.assigns[:conversation_id] != get_current_conversation_id(socket) ->
load_conversation(socket, socket.assigns.conversation_id)
!socket.assigns[:conversation_id] && socket.assigns.conversation ->
clear_conversation(socket)
true ->
socket
end
{:ok, socket}
end
@doc \"\"\"
Subscribes the calling process to PubSub topics for the given user.
Call this from your parent LiveView's `mount/3`:
if connected?(socket) do
MyAppWeb.ChatComponent.subscribe(socket.assigns.current_user, socket)
end
\"\"\"
def subscribe(current_user, _socket) do
if current_user do
#{inspect(endpoint)}.subscribe("chat:conversations:\#{current_user.id}")
end
end
@impl true
def render(assigns) do
~H\"""
<div id={@id} class="flex bg-base-200 min-h-full max-h-full">
<div :if={!@hide_sidebar} class="w-72 border-r bg-base-300 flex flex-col overflow-y-auto">
<div class="py-4 px-6">
<div class="text-lg mb-4">
Conversations
</div>
<div class="mb-4">
<button phx-click="new_chat" phx-target={@myself} class="btn btn-primary btn-lg mb-2">
<div class="rounded-full bg-primary-content text-primary w-6 h-6 flex items-center justify-center">
<.icon name="hero-plus" />
</div>
<span>New Chat</span>
</button>
</div>
<ul class="flex flex-col-reverse" phx-update="stream" id={"\#{@id}-conversations-list"}>
<%= for {id, conversation} <- @streams.conversations do %>
<li id={id}>
<button
phx-click="select_conversation"
phx-target={@myself}
phx-value-id={conversation.id}
class={"block py-2 px-3 transition border-l-4 pl-2 mb-2 w-full text-left \#{if @conversation && @conversation.id == conversation.id, do: "border-primary font-medium", else: "border-transparent"}"}
>
{build_conversation_title_string(conversation.title)}
</button>
</li>
<% end %>
</ul>
</div>
</div>
<div class="flex-1 flex flex-col">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<div
:if={Phoenix.Flash.get(@flash, :warning)}
class="alert alert-warning m-4 mb-0 text-sm"
>
{Phoenix.Flash.get(@flash, :warning)}
</div>
<div class="navbar bg-base-300 w-full">
<img
src="https://github.com/ash-project/ash_ai/blob/main/logos/ash_ai.png?raw=true"
alt="Logo"
class="h-12"
height="48"
/>
<div class="mx-2 flex-1 px-2">
<p :if={@conversation}>{build_conversation_title_string(@conversation.title)}</p>
<p class="text-xs">AshAi</p>
</div>
</div>
<div class="flex-1 flex flex-col overflow-y-scroll bg-base-200">
<div
id={"\#{@id}-message-container"}
phx-update="stream"
class="flex-1 overflow-y-auto overflow-x-hidden px-4 py-2 flex flex-col-reverse"
>
<%= for {id, message} <- @streams.messages do %>
<div
id={id}
class={[
"chat",
message.source == :user && "chat-end",
message.source == :agent && "chat-start"
]}
>
<div :if={message.source == :agent} class="chat-image avatar">
<div class="w-10 rounded-full bg-base-300 p-1">
<img
src="https://github.com/ash-project/ash_ai/blob/main/logos/ash_ai.png?raw=true"
alt="Logo"
/>
</div>
</div>
<div :if={message.source == :user} class="chat-image avatar avatar-placeholder">
<div class="w-10 rounded-full bg-base-300">
<.icon name="hero-user-solid" class="block" />
</div>
</div>
<div
:if={message.source == :agent && tool_calls(message) != []}
class="mt-2 flex w-full max-w-[36rem] min-w-0 flex-wrap gap-1 text-[11px] opacity-80"
>
<%= for tool_call <- tool_calls(message) do %>
<span class="badge badge-outline badge-info max-w-full min-w-0 justify-start overflow-hidden text-ellipsis whitespace-nowrap">
tool: {tool_call.name}
<span :if={tool_call.arguments != %{}}>
({tool_call.arguments_preview})
</span>
</span>
<% end %>
</div>
<div
:if={message.source == :agent && tool_results(message) != []}
class="chat-footer mt-1 flex w-full max-w-[36rem] min-w-0 flex-col gap-1"
>
<%= for tool_result <- tool_results(message) do %>
<div
class={[
"rounded max-w-full overflow-hidden px-2 py-1 text-xs leading-relaxed break-words",
tool_result.is_error && "bg-error/20",
!tool_result.is_error && "bg-base-300"
]}
>
<span class="font-semibold">
{if tool_result.is_error, do: "tool_error", else: "tool_result"}
</span>
<span :if={tool_result.name}> ({tool_result.name})</span>
<span class="break-all">
: {tool_result.content_preview}
</span>
</div>
<% end %>
</div>
<div :if={String.trim(message.text || "") != ""} class="chat-bubble">
<%= to_markdown(message.text || "") %>
</div>
</div>
<% end %>
</div>
</div>
<div :if={@agent_responding} class="px-4 py-2 text-xs opacity-80 flex items-center gap-2">
<span class="loading loading-dots loading-sm" />
<span>AshAi is responding...</span>
</div>
<div class="p-4 border-t">
<.form
:let={form}
for={@message_form}
phx-change="validate_message"
phx-target={@myself}
phx-debounce="blur"
phx-submit="send_message"
class="flex items-center gap-4"
>
<div class="flex-1">
<input
name={form[:text].name}
value={form[:text].value}
type="text"
phx-mounted={JS.focus()}
placeholder="Type your message..."
class="input input-primary w-full mb-0"
autocomplete="off"
/>
</div>
<button type="submit" class="btn btn-primary rounded-full">
<.icon name="hero-paper-airplane" /> Send
</button>
</.form>
</div>
</div>
</div>
\"""
end
@impl true
def handle_event("validate_message", %{"form" => params}, socket) do
{:noreply, assign(socket, :message_form, AshPhoenix.Form.validate(socket.assigns.message_form, params))}
end
@impl true
def handle_event("send_message", %{"form" => params}, socket) do
if #{actor_required?} && is_nil(socket.assigns.current_user) do
{:noreply, put_flash(socket, :error, "You must sign in to send messages")}
else
case AshPhoenix.Form.submit(socket.assigns.message_form, params: params) do
{:ok, message} ->
if socket.assigns.conversation do
socket
|> assign(:agent_responding, true)
|> assign_message_form()
|> stream_insert(:messages, message, at: 0)
|> then(&{:noreply, &1})
else
send(self(), {:chat_component_navigate, message.conversation_id})
{:noreply, assign_message_form(socket)}
end
{:error, form} ->
{:noreply, assign(socket, :message_form, form)}
end
end
end
@impl true
def handle_event("select_conversation", %{"id" => id}, socket) do
send(self(), {:chat_component_navigate, id})
{:noreply, socket}
end
@impl true
def handle_event("new_chat", _, socket) do
send(self(), {:chat_component_navigate, nil})
{:noreply, socket}
end
defp load_conversation(socket, conversation_id) do
if #{actor_required?} && is_nil(socket.assigns.current_user) do
socket
|> put_flash(:error, "You must sign in to access conversations")
|> clear_conversation()
else
conversation =
#{inspect(chat)}.get_conversation!(conversation_id, actor: socket.assigns.current_user)
messages = #{inspect(chat)}.message_history!(conversation.id, stream?: true)
#{inspect(endpoint)}.subscribe("chat:messages:\#{conversation.id}")
socket
|> maybe_warn_tool_data(messages)
|> assign(:conversation, conversation)
|> assign(:agent_responding, agent_response_pending?(messages))
|> stream(:messages, messages, reset: true)
|> assign_message_form()
end
end
defp clear_conversation(socket) do
if socket.assigns[:conversation] do
#{inspect(endpoint)}.unsubscribe("chat:messages:\#{socket.assigns.conversation.id}")
end
socket
|> assign(:conversation, nil)
|> assign(:agent_responding, false)
|> stream(:messages, [], reset: true)
|> assign_message_form()
end
defp get_current_conversation_id(socket) do
if socket.assigns[:conversation], do: socket.assigns.conversation.id, else: nil
end
defp handle_broadcast(socket, %Phoenix.Socket.Broadcast{
topic: "chat:messages:" <> conversation_id,
payload: message
}) do
if socket.assigns.conversation && socket.assigns.conversation.id == conversation_id do
socket
|> maybe_warn_tool_data(message)
|> stream_insert(:messages, message, at: 0)
|> update_agent_responding(message)
else
socket
end
end
defp handle_broadcast(socket, %Phoenix.Socket.Broadcast{
topic: "chat:conversations:" <> _,
payload: conversation
}) do
socket =
if socket.assigns.conversation && socket.assigns.conversation.id == conversation.id do
assign(socket, :conversation, conversation)
else
socket
end
stream_insert(socket, :conversations, conversation)
end
defp handle_broadcast(socket, _), do: socket
def build_conversation_title_string(title) do
cond do
title == nil -> "Untitled conversation"
is_binary(title) && String.length(title) > 25 -> String.slice(title, 0, 25) <> "..."
is_binary(title) && String.length(title) <= 25 -> title
end
end
defp assign_message_form(socket) do
form =
if socket.assigns.conversation do
#{inspect(chat)}.form_to_create_message(
actor: socket.assigns.current_user,
private_arguments: %{conversation_id: socket.assigns.conversation.id}
)
|> to_form()
else
#{inspect(chat)}.form_to_create_message(actor: socket.assigns.current_user)
|> to_form()
end
assign(
socket,
:message_form,
form
)
end
defp tool_calls(message), do: safe_extract(message).tool_calls
defp tool_results(message), do: safe_extract(message).tool_results
defp safe_extract(message) do
case @chat_ui_tools.extract(message) do
{:ok, extracted} ->
extracted
{:error, _} ->
%{tool_calls: [], tool_results: []}
end
end
defp maybe_warn_tool_data(socket, messages) when is_list(messages) do
Enum.reduce(messages, socket, fn message, acc ->
maybe_warn_tool_data(acc, message)
end)
end
defp maybe_warn_tool_data(socket, message) do
if agent_message?(message) do
case @chat_ui_tools.extract(message) do
{:ok, _} ->
socket
{:error, _} ->
maybe_put_tool_data_warning(socket)
end
else
socket
end
end
defp maybe_put_tool_data_warning(socket) do
if socket.assigns[:tool_data_warning_shown?] do
socket
else
socket
|> put_flash(:warning, "Some tool call data could not be displayed.")
|> assign(:tool_data_warning_shown?, true)
end
end
defp message_source(%{source: source}), do: source
defp message_source(%{"source" => source}), do: source
defp message_source(_), do: nil
defp message_complete?(%{complete: complete}), do: complete in [true, "true"]
defp message_complete?(%{"complete" => complete}), do: complete in [true, "true"]
defp message_complete?(_), do: false
defp user_message?(message), do: message_source(message) in [:user, "user"]
defp agent_message?(message), do: message_source(message) in [:agent, "agent"]
defp update_agent_responding(socket, message) do
cond do
user_message?(message) ->
assign(socket, :agent_responding, true)
agent_message?(message) ->
assign(socket, :agent_responding, !message_complete?(message))
true ->
socket
end
end
defp agent_response_pending?(messages) do
case Enum.find(messages, fn message -> user_message?(message) or agent_message?(message) end) do
nil -> false
message -> user_message?(message) || !message_complete?(message)
end
end
defp to_markdown(text) do
MDEx.to_html(text,
extension: [
strikethrough: true,
tagfilter: true,
table: true,
autolink: true,
tasklist: true,
footnotes: true,
shortcodes: true
],
parse: [
smart: true,
relaxed_tasklist_matching: true,
relaxed_autolinks: true
],
render: [
github_pre_lang: true,
unsafe: true
],
sanitize: MDEx.Document.default_sanitize_options()
)
|> case do
{:ok, html} ->
html
|> Phoenix.HTML.raw()
{:error, _} -> text
end
end
"""
end
end
else
defmodule Mix.Tasks.AshAi.Gen.Chat do
@shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use"
@moduledoc __MODULE__.Docs.long_doc()
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'ash_ai.gen.chat' requires igniter. Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter/readme.html#installation
""")
exit({:shutdown, 1})
end
end
end