Packages

phoenix_kit

1.7.4
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web live modules ai settings.ex
Raw

lib/phoenix_kit_web/live/modules/ai/settings.ex

defmodule PhoenixKitWeb.Live.Modules.AI.Settings do
@moduledoc """
LiveView for AI module configuration and settings management.
This module provides a comprehensive interface for managing AI provider accounts
and model configuration in PhoenixKit.
## Features
- **Account Management**: Add, edit, delete AI provider accounts (OpenRouter, etc.)
- **Model Type Tabs**: Configure slots for Text, Vision, Image Gen, and Embeddings
- **Slot Configuration**: 3 presets per model type with fallback chain support
- **Model Selection**: Choose models from available providers
- **Usage Statistics**: View request history and token usage
## Route
This LiveView is mounted at `{prefix}/admin/ai` and requires
appropriate admin permissions.
## UI States
- **Setup State**: Shown when no accounts are configured yet
- **Configuration State**: Shown when at least one account exists
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.AI
alias PhoenixKit.AI.OpenRouterClient
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
# Model type tabs configuration
@model_types [
%{id: "text", label: "Text", icon: "hero-chat-bubble-left-right", type: :text},
%{id: "vision", label: "Vision", icon: "hero-eye", type: :vision},
%{id: "image_gen", label: "Image Gen", icon: "hero-photo", type: :image_gen},
%{id: "embeddings", label: "Embeddings", icon: "hero-cube-transparent", type: :embeddings}
]
@impl true
def mount(_params, session, socket) do
current_path = get_current_path(socket, session)
project_title = Settings.get_setting("project_title", "PhoenixKit")
# Load accounts
accounts = AI.list_accounts()
has_accounts = not Enum.empty?(accounts)
# Default slot type
default_slot_type = :text
slots = AI.get_slots(default_slot_type)
socket =
socket
|> assign(:current_path, current_path)
|> assign(:page_title, "AI Module")
|> assign(:project_title, project_title)
|> assign(:accounts, accounts)
|> assign(:has_accounts, has_accounts)
|> assign(:active_tab, if(has_accounts, do: "slots", else: "setup"))
# Model type tab (text, vision, image_gen, embeddings)
|> assign(:slot_type, default_slot_type)
|> assign(:model_types, @model_types)
|> assign(:slots, slots)
|> assign(:saving_slots, false)
# Models are cached per {account_id, model_type}
|> assign(:models_cache, %{})
|> assign(:models_loading_for, nil)
|> assign(:models_error, nil)
# Fetch models for any slots that already have accounts
socket = fetch_models_for_existing_slots(socket)
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
tab = params["tab"] || socket.assigns.active_tab
{:noreply, assign(socket, :active_tab, tab)}
end
# ===========================================
# TAB NAVIGATION
# ===========================================
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
socket = assign(socket, :active_tab, tab)
# Lazy load usage data when switching to usage tab
socket =
if tab == "usage" && !socket.assigns[:usage_loaded] do
load_usage_data(socket)
else
socket
end
{:noreply, socket}
end
@impl true
def handle_event("load_more_requests", _params, socket) do
page = socket.assigns.usage_page + 1
{new_requests, _total} = AI.list_requests(page: page, page_size: 20, preload: [:account])
socket =
socket
|> assign(:usage_requests, socket.assigns.usage_requests ++ new_requests)
|> assign(:usage_page, page)
{:noreply, socket}
end
@impl true
def handle_event("switch_slot_type", %{"type" => type_str}, socket) do
type = String.to_existing_atom(type_str)
slots = AI.get_slots(type)
socket =
socket
|> assign(:slot_type, type)
|> assign(:slots, slots)
|> assign(:models_error, nil)
# Fetch models for any slots that already have accounts
socket = fetch_models_for_existing_slots(socket)
{:noreply, socket}
end
# ===========================================
# SLOTS CONFIGURATION
# ===========================================
@impl true
def handle_event("update_slot", params, socket) do
if params["slot"] do
handle_slot_form_update(params["slot"], socket)
else
handle_slot_flat_update(params, socket)
end
end
@impl true
def handle_event("save_slots", _params, socket) do
socket = assign(socket, :saving_slots, true)
slot_type = socket.assigns.slot_type
case AI.update_slots(slot_type, socket.assigns.slots) do
{:ok, slots} ->
type_label = slot_type_label(slot_type)
socket =
socket
|> assign(:slots, slots)
|> assign(:saving_slots, false)
|> put_flash(:info, "#{type_label} configuration saved")
{:noreply, socket}
{:error, _} ->
socket =
socket
|> assign(:saving_slots, false)
|> put_flash(:error, "Failed to save configuration")
{:noreply, socket}
end
end
# Slot update helper functions
defp handle_slot_form_update(slot, socket) do
slots = socket.assigns.slots
slot_type = socket.assigns.slot_type
slot_index = String.to_integer(slot["index"] || "0")
current_slot = Enum.at(slots, slot_index)
new_account_id = parse_int(slot["account_id"])
old_account_id = current_slot["account_id"]
account_changed = new_account_id != old_account_id
new_model = if(account_changed, do: "", else: slot["model"] || "")
new_slot =
build_slot_from_form(slot, current_slot, new_account_id, new_model)
|> add_type_specific_fields(slot, current_slot, slot_type)
updated_slots = List.replace_at(slots, slot_index, new_slot)
socket =
socket
|> assign(:slots, updated_slots)
|> maybe_fetch_models_on_account_change(account_changed, new_account_id)
{:noreply, socket}
end
defp handle_slot_flat_update(params, socket) do
slots = socket.assigns.slots
slot_type = socket.assigns.slot_type
slot_index = String.to_integer(params["slot-index"] || "0")
current_slot = Enum.at(slots, slot_index)
new_slot =
build_slot_from_flat_params(params, current_slot)
|> add_type_specific_fields_flat(params, current_slot, slot_type)
updated_slots = List.replace_at(slots, slot_index, new_slot)
{:noreply, assign(socket, :slots, updated_slots)}
end
defp build_slot_from_form(slot, current_slot, new_account_id, new_model) do
%{
"name" => slot["name"] || current_slot["name"] || "",
"description" => slot["description"] || current_slot["description"] || "",
"account_id" => new_account_id,
"model" => new_model,
"enabled" => current_slot["enabled"] || false
}
end
defp build_slot_from_flat_params(params, current_slot) do
%{
"name" => params["slot-name"] || current_slot["name"] || "",
"description" => params["slot-description"] || current_slot["description"] || "",
"account_id" => parse_int(params["slot-account_id"]) || current_slot["account_id"],
"model" => params["slot-model"] || current_slot["model"] || "",
"enabled" => params["slot-enabled"] == "true"
}
end
defp maybe_fetch_models_on_account_change(socket, true, account_id)
when not is_nil(account_id) do
maybe_fetch_models_for_account(socket, account_id)
end
defp maybe_fetch_models_on_account_change(socket, _, _), do: socket
defp slot_type_label(:text), do: "Text processing"
defp slot_type_label(:vision), do: "Vision processing"
defp slot_type_label(:image_gen), do: "Image generation"
defp slot_type_label(:embeddings), do: "Embeddings"
# Add type-specific fields for form-based params
defp add_type_specific_fields(new_slot, slot, current_slot, slot_type)
when slot_type in [:text, :vision] do
Map.merge(new_slot, %{
"temperature" => parse_float(slot["temperature"], current_slot["temperature"] || 0.7),
"max_tokens" => parse_int_or_nil(slot["max_tokens"], current_slot["max_tokens"]),
"top_p" => parse_float_or_nil(slot["top_p"], current_slot["top_p"]),
"top_k" => parse_int_or_nil(slot["top_k"], current_slot["top_k"]),
"frequency_penalty" =>
parse_float_or_nil(slot["frequency_penalty"], current_slot["frequency_penalty"]),
"presence_penalty" =>
parse_float_or_nil(slot["presence_penalty"], current_slot["presence_penalty"]),
"repetition_penalty" =>
parse_float_or_nil(slot["repetition_penalty"], current_slot["repetition_penalty"]),
"stop" => parse_stop_sequences(slot["stop"], current_slot["stop"]),
"seed" => parse_int_or_nil(slot["seed"], current_slot["seed"])
})
end
defp add_type_specific_fields(new_slot, slot, current_slot, :image_gen) do
Map.merge(new_slot, %{
"size" => slot["size"] || current_slot["size"] || "1024x1024",
"quality" => slot["quality"] || current_slot["quality"] || "standard"
})
end
defp add_type_specific_fields(new_slot, slot, current_slot, :embeddings) do
Map.merge(new_slot, %{
"dimensions" => parse_int(slot["dimensions"], current_slot["dimensions"])
})
end
# Add type-specific fields for flat params (checkbox toggle)
defp add_type_specific_fields_flat(new_slot, params, current_slot, slot_type)
when slot_type in [:text, :vision] do
Map.merge(new_slot, %{
"temperature" =>
parse_float(params["slot-temperature"], current_slot["temperature"] || 0.7),
"max_tokens" => parse_int_or_nil(params["slot-max_tokens"], current_slot["max_tokens"]),
"top_p" => current_slot["top_p"],
"top_k" => current_slot["top_k"],
"frequency_penalty" => current_slot["frequency_penalty"],
"presence_penalty" => current_slot["presence_penalty"],
"repetition_penalty" => current_slot["repetition_penalty"],
"stop" => current_slot["stop"],
"seed" => current_slot["seed"]
})
end
defp add_type_specific_fields_flat(new_slot, params, current_slot, :image_gen) do
Map.merge(new_slot, %{
"size" => params["slot-size"] || current_slot["size"] || "1024x1024",
"quality" => params["slot-quality"] || current_slot["quality"] || "standard"
})
end
defp add_type_specific_fields_flat(new_slot, params, current_slot, :embeddings) do
Map.merge(new_slot, %{
"dimensions" => parse_int(params["slot-dimensions"], current_slot["dimensions"])
})
end
# Parse float, returning nil if empty/invalid
defp parse_float_or_nil(nil, default), do: default
defp parse_float_or_nil("", _default), do: nil
defp parse_float_or_nil(val, _default) when is_float(val), do: val
defp parse_float_or_nil(val, _default) when is_integer(val), do: val / 1
defp parse_float_or_nil(val, default) when is_binary(val) do
case Float.parse(val) do
{float, _} -> float
:error -> default
end
end
# Parse int, returning nil if empty/invalid
defp parse_int_or_nil(nil, default), do: default
defp parse_int_or_nil("", _default), do: nil
defp parse_int_or_nil(val, _default) when is_integer(val), do: val
defp parse_int_or_nil(val, default) when is_binary(val) do
case Integer.parse(val) do
{int, _} -> int
:error -> default
end
end
# Parse stop sequences (comma-separated string to list)
defp parse_stop_sequences(nil, default), do: default
defp parse_stop_sequences("", _default), do: nil
defp parse_stop_sequences(val, _default) when is_binary(val) do
val
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> case do
[] -> nil
sequences -> sequences
end
end
defp parse_stop_sequences(val, _default) when is_list(val), do: val
# ===========================================
# MODEL FETCHING
# ===========================================
@impl true
def handle_info({:fetch_models, account_id, api_key, provider, slot_type}, socket) do
# Currently only OpenRouter is supported, but this can be extended
result =
case {provider, slot_type} do
{"openrouter", :embeddings} ->
OpenRouterClient.fetch_embedding_models_grouped(api_key)
{"openrouter", model_type} ->
OpenRouterClient.fetch_models_by_type(api_key, model_type)
{_, :embeddings} ->
OpenRouterClient.fetch_embedding_models_grouped(api_key)
{_, model_type} ->
OpenRouterClient.fetch_models_by_type(api_key, model_type)
end
case result do
{:ok, grouped} ->
# Flatten grouped models into a single list for easy lookup
all_models =
grouped
|> Enum.flat_map(fn {_provider, models} -> models end)
# Cache models for this {account_id, slot_type} combination
cache_key = {account_id, slot_type}
models_cache =
Map.put(socket.assigns.models_cache, cache_key, %{
models: all_models,
grouped: grouped
})
socket =
socket
|> assign(:models_cache, models_cache)
|> assign(:models_loading_for, nil)
|> assign(:models_error, nil)
# Check if other slots need models fetched
socket = fetch_models_for_existing_slots(socket)
{:noreply, socket}
{:error, reason} ->
socket =
socket
|> assign(:models_loading_for, nil)
|> assign(:models_error, reason)
{:noreply, socket}
end
end
defp maybe_fetch_models_for_account(socket, account_id) do
slot_type = socket.assigns.slot_type
cache_key = {account_id, slot_type}
# Check if already cached for this account + slot_type combination
if Map.has_key?(socket.assigns.models_cache, cache_key) do
socket
else
# Find the account and fetch models
account = Enum.find(socket.assigns.accounts, &(&1.id == account_id))
if account && account.api_key do
send(self(), {:fetch_models, account_id, account.api_key, account.provider, slot_type})
socket
|> assign(:models_loading_for, account_id)
|> assign(:models_error, nil)
else
socket
end
end
end
# Fetch models for all slots that already have an account selected
defp fetch_models_for_existing_slots(socket) do
slots = socket.assigns.slots
slot_type = socket.assigns.slot_type
# Find the first slot with an account that needs models fetched
slot_needing_fetch =
Enum.find(slots, fn slot ->
account_id = slot["account_id"]
cache_key = {account_id, slot_type}
account_id != nil &&
not Map.has_key?(socket.assigns.models_cache, cache_key)
end)
case slot_needing_fetch do
nil ->
socket
slot ->
# Fetch models for this account (will trigger loading for one at a time)
maybe_fetch_models_for_account(socket, slot["account_id"])
end
end
# ===========================================
# USAGE DATA
# ===========================================
defp load_usage_data(socket) do
# Get dashboard statistics
stats = AI.get_dashboard_stats()
# Get recent requests with pagination
{requests, total_requests} = AI.list_requests(page: 1, page_size: 20, preload: [:account])
socket
|> assign(:usage_loaded, true)
|> assign(:usage_stats, stats)
|> assign(:usage_requests, requests)
|> assign(:usage_total_requests, total_requests)
|> assign(:usage_page, 1)
end
# ===========================================
# HELPERS
# ===========================================
defp get_current_path(socket, session) do
case socket.assigns do
%{url_path: path} when is_binary(path) -> path
_ -> session["current_path"] || Routes.path("/admin/ai")
end
end
defp parse_int(nil), do: nil
defp parse_int(""), do: nil
defp parse_int(val) when is_integer(val), do: val
defp parse_int(val) when is_binary(val), do: String.to_integer(val)
defp parse_int(nil, default), do: default
defp parse_int("", default), do: default
defp parse_int(val, _default) when is_integer(val), do: val
defp parse_int(val, default) when is_binary(val) do
case Integer.parse(val) do
{int, _} -> int
:error -> default
end
end
defp parse_float(nil, default), do: default
defp parse_float("", default), do: default
defp parse_float(val, _default) when is_float(val), do: val
defp parse_float(val, _default) when is_integer(val), do: val / 1
defp parse_float(val, default) when is_binary(val) do
case Float.parse(val) do
{float, _} -> float
:error -> default
end
end
@doc false
def format_number(nil), do: "0"
def format_number(num) when is_integer(num) do
num
|> Integer.to_string()
|> String.reverse()
|> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
|> String.reverse()
end
def format_number(num) when is_float(num), do: :erlang.float_to_binary(num, decimals: 2)
def format_number(num), do: to_string(num)
end