Skip to main content

lib/jido/chat/stream_chunk.ex

defmodule Jido.Chat.StreamChunk do
  @moduledoc """
  Typed stream input chunk used by outbound stream payloads.
  """

  alias Jido.Chat.Wire

  @schema Zoi.struct(
            __MODULE__,
            %{
              kind:
                Zoi.enum([:text, :markdown, :status, :plan, :data, :step_start, :step_finish])
                |> Zoi.default(:text),
              text: Zoi.string() |> Zoi.nullish(),
              payload: Zoi.any() |> Zoi.nullish(),
              metadata: Zoi.map() |> Zoi.default(%{})
            },
            coerce: true
          )

  @type t :: unquote(Zoi.type_spec(@schema))
  @type input :: t() | String.t() | map()

  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)

  @doc "Returns the Zoi schema for StreamChunk."
  def schema, do: @schema

  @doc "Creates a stream chunk from normalized map input."
  def new(%__MODULE__{} = chunk), do: chunk
  def new(attrs) when is_map(attrs), do: Jido.Chat.Schema.parse!(__MODULE__, @schema, attrs)

  @doc "Builds a text chunk."
  @spec text(String.t(), keyword() | map()) :: t()
  def text(value, opts \\ []) when is_binary(value) do
    opts = normalize_opts(opts)
    new(Map.merge(opts, %{kind: :text, text: value}))
  end

  @doc "Normalizes supported stream chunk inputs."
  @spec normalize(input()) :: t() | String.t()
  def normalize(%__MODULE__{} = chunk), do: chunk
  def normalize(value) when is_binary(value), do: value
  def normalize(attrs) when is_map(attrs), do: new(attrs)

  @doc "Normalizes a list of stream chunk inputs."
  @spec normalize_many([input()]) :: [t() | String.t()]
  def normalize_many(chunks) when is_list(chunks), do: Enum.map(chunks, &normalize/1)

  @doc "Returns the best text fallback for the chunk."
  @spec fallback_text(t() | String.t()) :: String.t()
  def fallback_text(value) when is_binary(value), do: value

  def fallback_text(%__MODULE__{kind: kind, text: text, payload: payload}) do
    cond do
      is_binary(text) and text != "" ->
        text

      kind in [:step_start, :step_finish] ->
        payload_label(payload)

      kind == :plan ->
        payload_lines(payload)

      kind == :status ->
        payload_label(payload)

      true ->
        payload_label(payload) || ""
    end
  end

  @doc "Serializes a stream chunk into a plain map with type marker."
  @spec to_map(t()) :: map()
  def to_map(%__MODULE__{} = chunk) do
    chunk
    |> Map.from_struct()
    |> Wire.to_plain()
    |> Map.put("__type__", "stream_chunk")
  end

  @doc "Builds a stream chunk from serialized map data."
  @spec from_map(map()) :: t()
  def from_map(map) when is_map(map), do: map |> Map.drop(["__type__", :__type__]) |> new()

  defp payload_label(%{label: label}) when is_binary(label), do: label
  defp payload_label(%{"label" => label}) when is_binary(label), do: label
  defp payload_label(%{title: title}) when is_binary(title), do: title
  defp payload_label(%{"title" => title}) when is_binary(title), do: title
  defp payload_label(payload) when is_binary(payload), do: payload
  defp payload_label(_payload), do: nil

  defp payload_lines(payload) when is_list(payload) do
    payload
    |> Enum.map(&to_string/1)
    |> Enum.join("\n")
  end

  defp payload_lines(payload), do: payload_label(payload) || ""

  defp normalize_opts(opts) when is_list(opts), do: Map.new(opts)
  defp normalize_opts(opts) when is_map(opts), do: opts
end