Packages
AI module for PhoenixKit — endpoints, prompts, completions, and usage tracking
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_ai/web/prompts.ex
defmodule PhoenixKitAI.Web.Prompts do
@moduledoc """
LiveView for AI prompts management.
This module provides an interface for managing reusable AI prompt templates
with variable substitution support.
## Features
- **Prompt Management**: Add, edit, delete, enable/disable AI prompts
- **Variable Display**: Shows extracted variables from prompt content
- **Usage Tracking**: View usage count and last used time
## Route
This LiveView is mounted at `{prefix}/admin/ai/prompts` and requires
appropriate admin permissions.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
require Logger
alias PhoenixKit.Settings
alias PhoenixKitAI, as: AI
alias PhoenixKitAI.Web.AuthHelpers
alias PhoenixKitAI.Web.SortHelpers
# Static field list (atoms) for compile-time `?sort=…` validation.
# Labels live in `sort_options/0` so `gettext/1` sees a literal at
# extract time.
@sort_field_keys [:sort_order, :name, :usage_count, :last_used_at, :inserted_at]
defp sort_options do
[
{:sort_order, gettext("Order")},
{:name, gettext("Name")},
{:usage_count, gettext("Usage")},
{:last_used_at, gettext("Last Used")},
{:inserted_at, gettext("Created")}
]
end
@page_size 20
@impl true
def mount(_params, session, socket) do
current_path = get_current_path(socket, session)
project_title = Settings.get_project_title()
# Subscribe to real-time updates
if connected?(socket) do
AI.subscribe_prompts()
end
socket =
socket
|> assign(:current_path, current_path)
|> assign(:page_title, "AI Prompts")
|> assign(:project_title, project_title)
|> assign(:prompts, [])
|> assign(:sort_by, :sort_order)
|> assign(:sort_dir, :asc)
|> assign(:sort_options, sort_options())
|> assign(:page, 1)
|> assign(:page_size, @page_size)
|> assign(:total_prompts, 0)
{:ok, socket}
end
@impl true
def handle_params(params, uri, socket) do
{sort_by, sort_dir, page} = parse_sort_params(params)
current_path = URI.parse(uri).path
socket =
socket
|> assign(:sort_by, sort_by)
|> assign(:sort_dir, sort_dir)
|> assign(:page, page)
|> assign(:current_path, current_path)
|> reload_prompts()
{:noreply, socket}
end
@valid_sort_fields Enum.map(@sort_field_keys, &Atom.to_string/1)
defp parse_sort_params(params) do
SortHelpers.parse_sort_params(params,
valid_fields: @valid_sort_fields,
default_sort: :sort_order,
default_dir: :asc
)
end
# ===========================================
# PROMPT ACTIONS
# ===========================================
@impl true
def handle_event("toggle_prompt", %{"uuid" => uuid}, socket) do
prompt = AI.get_prompt!(uuid)
case AI.update_prompt(prompt, %{enabled: !prompt.enabled}, actor_opts(socket)) do
{:ok, updated} ->
message =
if updated.enabled, do: gettext("Prompt enabled"), else: gettext("Prompt disabled")
{:noreply,
socket
|> reload_prompts()
|> put_flash(:info, message)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update prompt"))}
end
end
@impl true
def handle_event("delete_prompt", %{"uuid" => uuid}, socket) do
prompt = AI.get_prompt!(uuid)
case AI.delete_prompt(prompt, actor_opts(socket)) do
{:ok, _} ->
{:noreply,
socket
|> reload_prompts()
|> put_flash(:info, gettext("Prompt deleted"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to delete prompt"))}
end
end
@impl true
def handle_event("sort", %{"by" => field}, socket) do
# Validate field before converting to atom to prevent crashes from malicious input
field =
if field in @valid_sort_fields do
String.to_existing_atom(field)
else
:sort_order
end
current_sort_by = socket.assigns.sort_by
current_sort_dir = socket.assigns.sort_dir
# Toggle direction if same field, otherwise default to desc for usage/last_used, asc for others
sort_dir =
if field == current_sort_by do
if current_sort_dir == :asc, do: :desc, else: :asc
else
if field in [:usage_count, :last_used_at, :inserted_at], do: :desc, else: :asc
end
# Reset to page 1 when sorting changes
path = PhoenixKitAI.Routes.ai_path() <> "/prompts?sort=#{field}&dir=#{sort_dir}"
{:noreply, push_patch(socket, to: path)}
end
# `<.sort_selector>` event. The field select sends `sort_by` only; the
# direction button sends `sort_dir` only. Each missing param is read
# from `socket.assigns` so two events in flight can't clobber each
# other (race-free by construction). See sort_selector moduledoc.
@impl true
def handle_event("sort_form", params, socket) do
field_str = params["sort_by"] || Atom.to_string(socket.assigns.sort_by)
dir_str = params["sort_dir"] || Atom.to_string(socket.assigns.sort_dir)
field = SortHelpers.parse_sort_field(field_str, @valid_sort_fields, socket.assigns.sort_by)
dir = SortHelpers.parse_sort_dir(dir_str, socket.assigns.sort_dir)
path = PhoenixKitAI.Routes.ai_path() <> "/prompts?sort=#{field}&dir=#{dir}"
{:noreply, push_patch(socket, to: path)}
end
@impl true
def handle_event("goto_page", %{"page" => page_str}, socket) do
case Integer.parse(page_str) do
{page, ""} when page > 0 ->
sort_by = socket.assigns.sort_by
sort_dir = socket.assigns.sort_dir
path = build_prompts_url(sort_by, sort_dir, page)
{:noreply, push_patch(socket, to: path)}
_ ->
{:noreply, socket}
end
end
# ===========================================
# PUBSUB HANDLERS - Real-time updates
# ===========================================
@impl true
def handle_info({event, _prompt}, socket)
when event in [:prompt_created, :prompt_updated, :prompt_deleted] do
# Reload prompts list when any prompt changes
{:noreply, reload_prompts(socket)}
end
# Catch-all for unmatched messages (PubSub from other modules, late
# replies after navigation, etc.). Log at :debug per the workspace
# sync precedent — never silently swallow a message we didn't expect.
@impl true
def handle_info(msg, socket) do
Logger.debug(fn ->
"[PhoenixKitAI.Web.Prompts] unhandled handle_info: #{inspect(msg)}"
end)
{:noreply, socket}
end
# ===========================================
# PRIVATE HELPERS
# ===========================================
defp reload_prompts(socket) do
sort_by = socket.assigns.sort_by
sort_dir = socket.assigns.sort_dir
page = socket.assigns.page
page_size = socket.assigns.page_size
{prompts, total} =
AI.list_prompts(
sort_by: sort_by,
sort_dir: sort_dir,
page: page,
page_size: page_size
)
socket
|> assign(:prompts, prompts)
|> assign(:total_prompts, total)
end
defp build_prompts_url(sort_by, sort_dir, page) do
base = PhoenixKitAI.Routes.ai_path() <> "/prompts?sort=#{sort_by}&dir=#{sort_dir}"
if page > 1 do
base <> "&page=#{page}"
else
base
end
end
defp get_current_path(socket, session) do
case socket.assigns do
%{__changed__: _, current_path: path} when is_binary(path) -> path
_ -> session["current_path"] || PhoenixKitAI.Routes.ai_path() <> "/prompts"
end
end
# Activity attribution — passed through to AI.update_prompt/3 and
# AI.delete_prompt/2 so the mutation is logged against the right actor.
defp actor_opts(socket), do: AuthHelpers.actor_opts(socket)
end