defmodule Jido.Chat.AI do
@moduledoc """
Framework-agnostic conversion helpers for turning chat history into AI-ready messages.
"""
alias Jido.Chat.{Media, Message}
@type ai_content_part :: map()
@type ai_message :: %{
required(:role) => String.t(),
required(:content) => String.t() | [ai_content_part()]
}
@doc """
Converts normalized chat messages into AI-friendly role/content maps.
Options stay Elixir-native by default, but Chat SDK-style camelCase aliases are
accepted to make cross-port migration less brittle:
- `include_names` or `includeNames`
- `transform` or `transformMessage`
- `unsupported_attachment` or `onUnsupportedAttachment`
- `fetch_attachment` or `fetchAttachment`
"""
@spec to_messages([Message.t() | map()], keyword() | map()) :: [ai_message()]
def to_messages(messages, opts \\ []) when is_list(messages) do
opts = normalize_opts(opts)
messages
|> Enum.map(&normalize_message/1)
|> sort_messages()
|> Enum.map(&to_ai_message(&1, opts))
end
@doc "Alias for `to_messages/2`."
@spec to_ai_messages([Message.t() | map()], keyword() | map()) :: [ai_message()]
def to_ai_messages(messages, opts \\ []), do: to_messages(messages, opts)
defp normalize_message(%Message{} = message), do: message
defp normalize_message(message) when is_map(message), do: Message.new(message)
defp sort_messages(messages) do
messages
|> Enum.with_index()
|> Enum.sort_by(fn {message, index} -> {message_sort_key(message), index} end)
|> Enum.map(&elem(&1, 0))
end
defp message_sort_key(%Message{created_at: %DateTime{} = dt}),
do: DateTime.to_unix(dt, :microsecond)
defp message_sort_key(%Message{created_at: %NaiveDateTime{} = dt}),
do: NaiveDateTime.to_iso8601(dt)
defp message_sort_key(%Message{created_at: created_at}) when is_integer(created_at),
do: created_at
defp message_sort_key(%Message{created_at: created_at}) when is_binary(created_at),
do: created_at
defp message_sort_key(_message), do: :infinity
defp to_ai_message(%Message{} = message, opts) do
parts =
[]
|> maybe_add_text_part(message.text || message.formatted)
|> Kernel.++(attachment_parts(message, opts))
ai_message =
%{
role: role_for(message),
content: if(length(parts) <= 1 and text_only_parts?(parts), do: single_text(parts), else: parts)
}
|> maybe_put_name(message, opts)
transform_message(ai_message, message, opts)
end
defp maybe_add_text_part(parts, nil), do: parts
defp maybe_add_text_part(parts, ""), do: parts
defp maybe_add_text_part(parts, text), do: parts ++ [%{type: "text", text: text}]
defp attachment_parts(%Message{attachments: attachments} = message, opts) do
Enum.flat_map(attachments || [], fn
%Media{} = attachment ->
attachment_to_parts(attachment, message, opts)
attachment when is_map(attachment) ->
attachment |> Media.normalize() |> attachment_to_parts(message, opts)
_other ->
[]
end)
end
defp attachment_to_parts(%Media{kind: :image} = attachment, _message, _opts) do
[
%{
type: "image",
url: attachment.url,
media_type: attachment.media_type,
metadata: attachment.metadata || %{}
}
]
end
defp attachment_to_parts(%Media{kind: :file} = attachment, message, opts) do
if text_like_file?(attachment) do
case resolve_file_text(attachment, opts) do
nil ->
unsupported_attachment_parts(attachment, message, opts)
text ->
[%{type: "text", text: text, filename: attachment.filename}]
end
else
unsupported_attachment_parts(attachment, message, opts)
end
end
defp attachment_to_parts(%Media{} = attachment, message, opts) do
unsupported_attachment_parts(attachment, message, opts)
end
defp unsupported_attachment_parts(attachment, message, opts) do
case option(opts, [
:unsupported_attachment,
:on_unsupported_attachment,
:onUnsupportedAttachment
]) do
nil ->
[]
callback when is_function(callback, 2) ->
case callback.(attachment, message) do
nil -> []
:skip -> []
value when is_binary(value) -> [%{type: "text", text: value}]
value when is_map(value) -> [value]
values when is_list(values) -> values
_other -> []
end
_other ->
[]
end
end
defp resolve_file_text(%Media{metadata: metadata} = attachment, opts) do
case metadata[:data] || metadata["data"] do
data when is_binary(data) ->
data
_ ->
case option(opts, [:fetch_attachment, :fetchAttachment]) do
callback when is_function(callback, 1) ->
case callback.(attachment) do
{:ok, value} when is_binary(value) -> value
value when is_binary(value) -> value
_other -> nil
end
_ ->
nil
end
end
end
defp text_like_file?(%Media{media_type: media_type, filename: filename}) do
cond do
is_binary(media_type) and String.starts_with?(media_type, "text/") ->
true
is_binary(media_type) and media_type in ["application/json", "application/xml"] ->
true
is_binary(filename) and Path.extname(filename) in [".txt", ".md", ".json", ".csv", ".xml"] ->
true
true ->
false
end
end
defp role_for(%Message{metadata: metadata, author: author}) do
role = metadata[:role] || metadata["role"]
cond do
role in [:system, "system"] -> "system"
role in [:assistant, "assistant"] -> "assistant"
role in [:user, "user"] -> "user"
author && author.is_me -> "assistant"
true -> "user"
end
end
defp maybe_put_name(message, %Message{author: author}, opts) do
if option(opts, [:include_names, :includeNames]) && author do
Map.put(message, :name, author.user_name || author.full_name)
else
message
end
end
defp transform_message(ai_message, message, opts) do
case option(opts, [:transform, :transform_message, :transformMessage]) do
callback when is_function(callback, 2) -> callback.(ai_message, message)
callback when is_function(callback, 1) -> callback.(ai_message)
_ -> ai_message
end
end
defp normalize_opts(opts) when is_list(opts), do: Map.new(opts)
defp normalize_opts(opts) when is_map(opts), do: opts
defp normalize_opts(_opts), do: %{}
defp option(opts, keys) when is_map(opts) do
Enum.find_value(keys, fn key ->
Map.get(opts, key) || Map.get(opts, Atom.to_string(key))
end)
end
defp text_only_parts?(parts), do: Enum.all?(parts, &match?(%{type: "text"}, &1))
defp single_text([]), do: ""
defp single_text([%{text: text}]), do: text
defp single_text(parts), do: Enum.map_join(parts, "\n", &Map.get(&1, :text, ""))
end