This commit is contained in:
68
lib/elixir_ai/ai_tools/ai_controllable.ex
Normal file
68
lib/elixir_ai/ai_tools/ai_controllable.ex
Normal file
@@ -0,0 +1,68 @@
|
||||
defmodule ElixirAi.AiControllable do
|
||||
@moduledoc """
|
||||
Behaviour + macro for LiveViews that expose AI-controllable tools.
|
||||
|
||||
Any LiveView that `use`s this module must implement:
|
||||
|
||||
- `ai_tools/0` — returns a list of tool spec maps
|
||||
- `handle_ai_tool_call(tool_name, args, socket)` — handles a dispatched tool call,
|
||||
returns `{result_string, socket}`.
|
||||
|
||||
The macro injects:
|
||||
|
||||
- A `handle_info` clause that dispatches `{:page_tool_call, tool_name, args, reply_to}`
|
||||
messages to the callback and sends the result back to the caller.
|
||||
- An `on_mount` hook registration that joins the `:pg` group keyed by
|
||||
`voice_session_id` so VoiceLive can discover sibling page LiveViews.
|
||||
|
||||
## Usage
|
||||
|
||||
defmodule MyAppWeb.SomeLive do
|
||||
use MyAppWeb, :live_view
|
||||
use ElixirAi.AiControllable
|
||||
|
||||
@impl ElixirAi.AiControllable
|
||||
def ai_tools do
|
||||
[
|
||||
%{
|
||||
name: "do_something",
|
||||
description: "Does something useful",
|
||||
parameters: %{
|
||||
"type" => "object",
|
||||
"properties" => %{"value" => %{"type" => "string"}},
|
||||
"required" => ["value"]
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
@impl ElixirAi.AiControllable
|
||||
def handle_ai_tool_call("do_something", %{"value" => val}, socket) do
|
||||
{"done: \#{val}", assign(socket, value: val)}
|
||||
end
|
||||
end
|
||||
"""
|
||||
|
||||
@callback ai_tools() :: [map()]
|
||||
@callback handle_ai_tool_call(tool_name :: String.t(), args :: map(), socket :: term()) ::
|
||||
{String.t(), term()}
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour ElixirAi.AiControllable
|
||||
|
||||
on_mount ElixirAi.AiControllable.Hook
|
||||
|
||||
def handle_info({:page_tool_call, tool_name, args, reply_to}, socket) do
|
||||
{result, socket} = handle_ai_tool_call(tool_name, args, socket)
|
||||
send(reply_to, {:page_tool_result, tool_name, result})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:get_ai_tools, reply_to}, socket) do
|
||||
send(reply_to, {:ai_tools_response, self(), ai_tools()})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
35
lib/elixir_ai/ai_tools/ai_controllable_hook.ex
Normal file
35
lib/elixir_ai/ai_tools/ai_controllable_hook.ex
Normal file
@@ -0,0 +1,35 @@
|
||||
defmodule ElixirAi.AiControllable.Hook do
|
||||
@moduledoc """
|
||||
LiveView on_mount hook that registers a page LiveView in the
|
||||
`:ai_page_tools` pg group so VoiceLive can discover it.
|
||||
|
||||
The group key is `{:page, voice_session_id}` where `voice_session_id`
|
||||
comes from the Plug session, tying the page LiveView to the same browser
|
||||
tab as VoiceLive.
|
||||
|
||||
Only joins when the LiveView module implements `ai_tools/0`
|
||||
(i.e. uses `ElixirAi.AiControllable`).
|
||||
"""
|
||||
|
||||
import Phoenix.LiveView
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
|
||||
def on_mount(:default, _params, session, socket) do
|
||||
voice_session_id = session["voice_session_id"]
|
||||
module = socket.view
|
||||
|
||||
if voice_session_id && function_exported?(module, :ai_tools, 0) do
|
||||
if connected?(socket) do
|
||||
try do
|
||||
:pg.join(ElixirAi.PageToolsPG, {:page, voice_session_id}, self())
|
||||
catch
|
||||
:exit, _ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
{:cont, assign(socket, :voice_session_id, voice_session_id)}
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
181
lib/elixir_ai/ai_tools/ai_tools.ex
Normal file
181
lib/elixir_ai/ai_tools/ai_tools.ex
Normal file
@@ -0,0 +1,181 @@
|
||||
defmodule ElixirAi.AiTools do
|
||||
@moduledoc """
|
||||
Central registry of all AI tools available to conversations.
|
||||
|
||||
Tools are split into two categories:
|
||||
|
||||
- **Server tools** (`store_thing`, `read_thing`): functions are fully defined
|
||||
here and always execute regardless of whether a browser session is open.
|
||||
|
||||
- **LiveView tools** (`set_background_color`, `navigate_to`): functions
|
||||
dispatch to the registered LiveView pid. If no browser tab is connected
|
||||
the call still succeeds immediately with a descriptive result so the AI
|
||||
conversation is never blocked.
|
||||
|
||||
Tool names are stored in the `conversations` table (`allowed_tools` column)
|
||||
and act as the gate for which tools are active for a given conversation.
|
||||
"""
|
||||
|
||||
import ElixirAi.ChatUtils, only: [ai_tool: 1]
|
||||
|
||||
@server_tool_names ["store_thing", "read_thing"]
|
||||
@liveview_tool_names ["set_background_color", "navigate_to"]
|
||||
@all_tool_names @server_tool_names ++ @liveview_tool_names
|
||||
|
||||
def server_tool_names, do: @server_tool_names
|
||||
|
||||
def liveview_tool_names, do: @liveview_tool_names
|
||||
|
||||
def all_tool_names, do: @all_tool_names
|
||||
|
||||
def build_server_tools(server, allowed_names) do
|
||||
[store_thing(server), read_thing(server)]
|
||||
|> Enum.filter(&(&1.name in allowed_names))
|
||||
end
|
||||
|
||||
def build_liveview_tools(server, allowed_names) do
|
||||
[set_background_color(server), navigate_to(server)]
|
||||
|> Enum.filter(&(&1.name in allowed_names))
|
||||
end
|
||||
|
||||
@doc "Convenience wrapper — builds all allowed tools (server + liveview)."
|
||||
def build(server, allowed_names) do
|
||||
build_server_tools(server, allowed_names) ++ build_liveview_tools(server, allowed_names)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def store_thing(server) do
|
||||
ai_tool(
|
||||
name: "store_thing",
|
||||
description: "store a key value pair in memory",
|
||||
function: &ElixirAi.ToolTesting.hold_thing/1,
|
||||
parameters: ElixirAi.ToolTesting.hold_thing_params(),
|
||||
server: server
|
||||
)
|
||||
end
|
||||
|
||||
def read_thing(server) do
|
||||
ai_tool(
|
||||
name: "read_thing",
|
||||
description: "read a key value pair that was previously stored with store_thing",
|
||||
function: &ElixirAi.ToolTesting.get_thing/1,
|
||||
parameters: ElixirAi.ToolTesting.get_thing_params(),
|
||||
server: server
|
||||
)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LiveView tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_background_color(server) do
|
||||
ai_tool(
|
||||
name: "set_background_color",
|
||||
description:
|
||||
"set the background color of the chat interface, accepts specified tailwind colors",
|
||||
function: fn args -> dispatch_to_liveview(server, "set_background_color", args) end,
|
||||
parameters: %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"color" => %{
|
||||
"type" => "string",
|
||||
"enum" => [
|
||||
"bg-seafoam-950/30",
|
||||
"bg-red-950/30",
|
||||
"bg-green-950/30",
|
||||
"bg-blue-950/30",
|
||||
"bg-yellow-950/30",
|
||||
"bg-purple-950/30",
|
||||
"bg-pink-950/30"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required" => ["color"]
|
||||
},
|
||||
server: server
|
||||
)
|
||||
end
|
||||
|
||||
def navigate_to(server) do
|
||||
ai_tool(
|
||||
name: "navigate_to",
|
||||
description: """
|
||||
Navigate the user's browser to a page in the application.
|
||||
Only use paths that exist in the app:
|
||||
"/" — home page
|
||||
"/admin" — admin panel
|
||||
"/chat/:name" — a chat conversation, where :name is the conversation name
|
||||
Provide the exact path string including the leading slash.
|
||||
""",
|
||||
function: fn args -> dispatch_to_liveview(server, "navigate_to", args) end,
|
||||
parameters: %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"path" => %{
|
||||
"type" => "string",
|
||||
"description" =>
|
||||
"The application path to navigate to, e.g. \"/\", \"/admin\", \"/chat/my-chat\""
|
||||
}
|
||||
},
|
||||
"required" => ["path"]
|
||||
},
|
||||
server: server
|
||||
)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page tools (dynamic, from AiControllable LiveViews)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Builds tool structs for page tools discovered from AiControllable LiveViews.
|
||||
|
||||
Each entry in `pids_and_specs` is `{page_pid, [tool_spec, ...]}` where
|
||||
`tool_spec` is a map with `:name`, `:description`, and `:parameters`.
|
||||
|
||||
The generated function sends `{:page_tool_call, name, args, self()}` to
|
||||
the page LiveView pid and blocks (inside a Task) waiting for the reply.
|
||||
"""
|
||||
def build_page_tools(server, pids_and_specs) do
|
||||
Enum.flat_map(pids_and_specs, fn {page_pid, tool_specs} ->
|
||||
Enum.map(tool_specs, fn spec ->
|
||||
ai_tool(
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
function: fn args ->
|
||||
send(page_pid, {:page_tool_call, spec.name, args, self()})
|
||||
|
||||
receive do
|
||||
{:page_tool_result, tool_name, result} when tool_name == spec.name ->
|
||||
{:ok, result}
|
||||
after
|
||||
5_000 -> {:ok, "page tool #{spec.name} timed out"}
|
||||
end
|
||||
end,
|
||||
parameters: spec.parameters,
|
||||
server: server
|
||||
)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp dispatch_to_liveview(server, tool_name, args) do
|
||||
pids = GenServer.call(server, {:session, :get_liveview_pids})
|
||||
|
||||
case pids do
|
||||
[] ->
|
||||
{:ok, "no browser session active, #{tool_name} skipped"}
|
||||
|
||||
_ ->
|
||||
Enum.each(pids, &send(&1, {:liveview_tool_call, tool_name, args}))
|
||||
{:ok, "#{tool_name} sent to #{length(pids)} session(s)"}
|
||||
end
|
||||
end
|
||||
end
|
||||
67
lib/elixir_ai/ai_tools/tool_testing.ex
Normal file
67
lib/elixir_ai/ai_tools/tool_testing.ex
Normal file
@@ -0,0 +1,67 @@
|
||||
defmodule ElixirAi.ToolTesting do
|
||||
use GenServer
|
||||
|
||||
def hold_thing(thing) do
|
||||
GenServer.cast(__MODULE__, {:hold_thing, thing})
|
||||
end
|
||||
|
||||
def hold_thing_params do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"name" => %{"type" => "string"},
|
||||
"value" => %{"type" => "string"}
|
||||
},
|
||||
"required" => ["name", "value"]
|
||||
}
|
||||
end
|
||||
|
||||
def get_thing(_) do
|
||||
GenServer.call(__MODULE__, :get_thing)
|
||||
end
|
||||
|
||||
def get_thing_params do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{},
|
||||
"required" => []
|
||||
}
|
||||
end
|
||||
|
||||
def store_thing_params do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"name" => %{"type" => "string"},
|
||||
"value" => %{"type" => "string"}
|
||||
},
|
||||
"required" => ["name", "value"]
|
||||
}
|
||||
end
|
||||
|
||||
def read_thing_definition(name) do
|
||||
%{
|
||||
"type" => "function",
|
||||
"function" => %{
|
||||
"name" => name,
|
||||
"description" => "read key value pair that was previously stored with store_thing"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
def init(args) do
|
||||
{:ok, args}
|
||||
end
|
||||
|
||||
def handle_cast({:hold_thing, thing}, _state) do
|
||||
{:noreply, thing}
|
||||
end
|
||||
|
||||
def handle_call(:get_thing, _from, state) do
|
||||
{:reply, state, state}
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user