defmodule Eai.Chat do @moduledoc "Main conversation GenServer managing multi-session chat history and async LLM tasks." use GenServer require Logger alias Eai.Adapter.Anthropic, as: AdapterAnthropic alias Eai.Adapter.OpenAI, as: AdapterOpenAI alias Eai.Chat.Context alias Eai.LLM.Direct alias Eai.Message alias Eai.ResultCollector, as: TaskResult alias Eai.Utils # ── 客户端 API ─────────────────────────────────────────────────── def start_link(_opts \\ []) do GenServer.start_link(__MODULE__, [], name: Eai.Naming.chat()) end @doc """ Send a message to LLM and get response (or enter interactive mode). ## Options * `:content` (string) — User message. Required for `:function` mode. * `:mod` (`:function` | `:human`) — Execution mode. Default: `:human` - `:function` — one-shot, synchronous, returns `{:ok, reply}` or `{:error, reason}` - `:human` — interactive, type `/s` to send, `/c` to cancel * `:timeout` (integer) — Max wait for reply in milliseconds. Default: `:infinity` * `:model` (atom) — Model name: `:deepseek`, `:claude_opus`, `:gpt4o`, etc. Default: `:deepseek` * `:prompt` (atom) — System prompt: `:coder`, `:analyst`, `:momoka`, etc. Default: `:momoka` * `:chara_card` (atom) — Override model + prompt with character card. Example: `:backend_engineer`. Overrides `:model` and `:prompt`. * `:chat_session` (string) — Conversation history isolation. Default: `"default"` * `:pty_session_id` (string) — PTY sandbox isolation. Default: same as `:chat_session` * `:temperature` (float | integer | nil) — Sampling temperature forwarded to the LLM provider as the `"temperature"` field in the HTTP body. Default: nil (provider default). * `:top_p` (float | nil) — Nucleus sampling cutoff (Anthropic / OpenAI / Bedrock `inferenceConfig.topP`). Default: nil (provider default). * `:top_k` (integer | nil) — Top-K sampling (Anthropic / Bedrock `inferenceConfig.topK`). OpenAI does NOT support — dropped at the OpenAI adapter. Default: nil (provider default). * `:min_p` (float | nil) — Min-P sampling. Reserved for future adapter support; no current adapter emits this field. Default: nil (provider default). * `:max_tokens` (integer | nil) — Maximum output tokens (Anthropic / OpenAI / Bedrock `inferenceConfig.maxTokens`). Default: nil (provider default). * `:repetition_penalty` (float | nil) — Repetition penalty. Reserved for future adapter support; no current adapter emits this field. Default: nil (provider default). * `:frequency_penalty` (float | nil) — OpenAI frequency penalty. Anthropic and Bedrock do NOT support — dropped at those adapters. Default: nil (provider default). * `:presence_penalty` (float | nil) — OpenAI presence penalty. Anthropic and Bedrock do NOT support — dropped at those adapters. Default: nil (provider default). * `:stop_sequences` (list of strings | nil) — Custom stop sequences (Anthropic `stop_sequences`, OpenAI `stop`, Bedrock `inferenceConfig.stopSequences`). Default: nil (provider default). * `:seed` (integer | nil) — Random seed for reproducibility (OpenAI; Bedrock `inferenceConfig.seed`). Anthropic does NOT support. Default: nil (provider default). * `:anthropic_beta` (list of strings | nil) — Optional list of Anthropic beta header strings to send (e.g. `["output-128k-2025-02-19"]`). When multiple are given, joined with ", " per Anthropic's convention. Default: nil (no beta header sent — provider's default cap applies). Currently consumed only by `:anthropic` provider calls; harmless on other providers (the adapter does not read the value). **Precedence (per field, all 10 sampler fields + `:anthropic_beta`):** `talk/1` explicit opt > `config/models/.exs` value > nil/omit. Step 7 stores the defaults in model config files (NOT in chara cards). ## Examples # Interactive iex> Eai.Chat.talk() # One-shot, defaults iex> Eai.Chat.talk(content: "what time is it?", mod: :function) # With model + prompt iex> Eai.Chat.talk(content: "refactor", mod: :function, model: :claude_opus, prompt: :coder, timeout: 60_000) # Multi-session isolation iex> Eai.Chat.talk(content: "analyze", chat_session: "research", pty_session_id: "isolated") # With chara card (overrides model + prompt) iex> Eai.Chat.talk(content: "code review", mod: :function, chara_card: :backend_engineer) ## Available Models, Prompts & Cards iex> Eai.Models.names() # => [:deepseek, :claude_opus, :gpt4o, ...] iex> Eai.Prompts.names() # => [:momoka, :coder, :analyst] iex> Eai.Card.names() # => [:backend_engineer, :frontend_dev, ...] ## Returns * `:function` mode — `{:ok, reply}` on success, `{:error, reason}` on failure. When the chat session is currently busy, returns `{:error, :busy, message}` (note the 3-tuple shape, unified with `:human` mode). * `:human` mode — `:ok` after the interactive session ends, or `{:error, :busy, message}` if the session is already busy. * `{:error, :invalid_mod}` for an unknown `:mod` value. """ def talk(opts \\ []) do chat_session = opts |> Keyword.get(:chat_session, "default") |> to_string() ctx = %Context{ timeout: Keyword.get(opts, :timeout, :infinity), chat_session: chat_session, pty_session: opts |> Keyword.get(:pty_session_id, chat_session) |> to_string(), model_opt: Keyword.get(opts, :model), prompt_opt: Keyword.get(opts, :prompt), chara_card_opt: Keyword.get(opts, :chara_card), temperature_opt: Keyword.get(opts, :temperature), top_p_opt: Keyword.get(opts, :top_p), top_k_opt: Keyword.get(opts, :top_k), min_p_opt: Keyword.get(opts, :min_p), max_tokens_opt: Keyword.get(opts, :max_tokens), repetition_penalty_opt: Keyword.get(opts, :repetition_penalty), frequency_penalty_opt: Keyword.get(opts, :frequency_penalty), presence_penalty_opt: Keyword.get(opts, :presence_penalty), stop_sequences_opt: Keyword.get(opts, :stop_sequences), seed_opt: Keyword.get(opts, :seed), anthropic_beta_opt: Keyword.get(opts, :anthropic_beta) } mode = Keyword.get(opts, :mod, :human) content = Keyword.get(opts, :content) case {mode, content} do {m, nil} when m in [:h, :human] -> case GenServer.call(Eai.Naming.chat(), {:status, ctx.chat_session}) do :busy -> msg = "A task is already running in session '#{ctx.chat_session}'. Please wait or interrupt it first." IO.puts(msg) {:error, :busy, msg} _ -> IO.puts( "EAI Chat [#{ctx.chat_session}]. Type '/s' on a new line to send your message. Type '/c' on a new line to cancel" ) read_lines(ctx, []) :ok end {m, msg} when not is_nil(msg) and m in [:f, :function, :h, :human] -> GenServer.call( Eai.Naming.chat(), {:talk, msg, ctx}, :infinity ) {m, _} -> IO.puts( "Invalid mod: #{inspect(m)}. Use :h/:human (interactive) or :f/:function (single-line with content)." ) {:error, :invalid_mod} end end @doc """ Get full message history for a chat session. ## Options * `chat_session` (string) — Session name. Default: `"default"` ## Returns List of `Eai.Message.t()` in conversation order. """ def get_history(chat_session \\ "default") do GenServer.call(Eai.Naming.chat(), {:get_history, to_string(chat_session)}) end @doc """ Force interrupt current LLM task (async mode only). Sets interrupt flag; next task poll injects Ctrl+C to running PTY process. Only works in async `:human` mode (sync mode blocks IEx). ## Options * `chat_session` (string) — Session to interrupt. Default: `"default"` ## Example iex> Eai.Chat.interrupt!("work") :ok """ def interrupt!(chat_session \\ "default") do GenServer.call(Eai.Naming.chat(), {:interrupt!, to_string(chat_session)}) end @doc """ Explicitly close a chat session and release its history. Cannot close `"default"` session. ## Options * `name` (string) — Session name to close. ## Returns `:ok` on success. `{:error, :cannot_close_default}` when closing the `"default"` session. `{:error, :not_found}` when the session does not exist. `{:error, :busy, message}` when a task is still running in the session. ## Example iex> Eai.Chat.close_chat_session("research") :ok """ def close_chat_session(name) do GenServer.call(Eai.Naming.chat(), {:close_chat_session, to_string(name)}) end @doc """ List all active chat sessions with message count and status. ## Returns `[{session_name, message_count, status}, ...]` where status is `:idle` or `:busy` """ def list_chat_sessions do GenServer.call(Eai.Naming.chat(), :list_chat_sessions) end @doc """ Ensure a chat session exists in `state.sessions`. If the session already exists, this is a no-op (returns `:ok` and state is unchanged). If the session does NOT exist, an empty session is materialized in `state.sessions` and `:ok` is returned. This is used by `Eai.System.restore_from_gzip/1` to PRE-CREATE every session in the snapshot before writing any messages, so that `Eai.Chat.list_chat_sessions/0` sees the full set of sessions during the restore window. Without this pre-create, sessions appear one-by-one as `replace_history/3` is called, and a concurrent observer could see an inconsistent state. ## Options * `chat_session` (string) — Session name. Default: `"default"`. ## Returns `:ok` """ def ensure_session_exists(chat_session \\ "default") do GenServer.call(Eai.Naming.chat(), {:ensure_session, to_string(chat_session)}) end @doc """ Export chat session history to a gzip file. Called by LLM tool `export_chat_session_context` or manually. ## Options * `file_path` (string) — Destination file path. * `chat_session` (string) — Session to export. Default: `"default"` ## Returns `{:ok, info}` or `{:error, reason}` ## Example iex> Eai.Chat.export_history("/tmp/session.gz", "work") {:ok, %{size_bytes: 5432, message_count: 42}} """ def export_history(file_path, chat_session \\ "default") do GenServer.call(Eai.Naming.chat(), {:export_history, file_path, to_string(chat_session)}) end @doc """ Load chat history from a gzip file and replace session's messages. ## Options * `file_path` (string) — Source gzip file path. * `chat_session` (string) — Target session. Default: `"default"` * `format` (string) — Message format. Default: `"converse"` - `"converse"` — already in `Eai.Message` IR format - `"openai"` — needs OpenAI adapter conversion - `"anthropic"` — needs Anthropic adapter conversion ## Returns `{:ok, info}` or `{:error, reason}` ## Example iex> Eai.Chat.replace_history("/tmp/session.gz", "work_restored") {:ok, %{message_count: 42, chat_session: "work_restored"}} """ def replace_history(file_path, chat_session \\ "default", format \\ "converse") do GenServer.call( Eai.Naming.chat(), {:replace_history, file_path, to_string(chat_session), format} ) end # ── 私有:多行读取循环 ────────────────────────────────────────── defp read_lines(%Context{} = ctx, lines) do case IO.gets("> ") do :eof -> IO.puts("Cancelled.") {:error, reason} -> IO.puts("Error: #{inspect(reason)}") line -> trimmed = String.trim(line) cond do trimmed == "/s" -> submit_or_restart(%{ctx | message: Enum.join(Enum.reverse(lines), "\n")}) trimmed == "/c" -> IO.puts("Cancelled.") true -> read_lines(ctx, [trimmed | lines]) end end end defp submit_or_restart(%Context{} = ctx) do if String.trim(ctx.message) == "" do IO.puts("No message to send. Starting over.") read_lines(ctx, []) else GenServer.cast( Eai.Naming.chat(), {:talk_async, ctx} ) IO.puts( ~s|Task submitted. Use Eai.Chat.interrupt!("#{ctx.chat_session}") to cancel its current task, or Eai.ResultCollector.trigger_timeout_window("#{ctx.pty_session}") to stop the loop and nudge the model to wrap up.| ) end end # ── 服务端回调 ─────────────────────────────────────────────────── @impl true def init(_) do {:ok, %{ sessions: %{"default" => new_session()}, ref_to_session: %{} }} end @impl true def handle_call({:status, chat_session_id}, _from, state) do session = get_session(state, chat_session_id) {:reply, if(is_nil(session.task_ref), do: :idle, else: :busy), state} end @impl true def handle_call({:ensure_session, chat_session_id}, _from, state) do case Map.fetch(state.sessions, chat_session_id) do {:ok, _session} -> {:reply, :ok, state} :error -> {:reply, :ok, put_session(state, chat_session_id, new_session())} end end @impl true def handle_call( {:talk, text, %Context{} = ctx}, from, state ) do session = get_session(state, ctx.chat_session) if is_nil(session.task_ref) do sanitized = Utils.sanitize_value(text) user_msg = Message.new(:user, sanitized) new_messages = session.messages ++ [user_msg] run_opts = build_run_opts(ctx) :telemetry.execute( [:eai, :chat, :session, :start], %{system_time: System.system_time()}, %{ chat_session_id: ctx.chat_session, pty_session_id: ctx.pty_session, msg_count: length(new_messages) } ) task = Task.Supervisor.async_nolink( Eai.Naming.task_supervisor(), fn -> Direct.run(new_messages, ctx.pty_session, run_opts) end ) remind_timer = if ctx.timeout != :infinity and is_integer(ctx.timeout) and ctx.timeout > 0 do Process.send_after( self(), {:remind_model, ctx.pty_session, ctx.chat_session}, ctx.timeout ) end new_session = %{ session | messages: new_messages, from: from, task_ref: task.ref, remind_timer: remind_timer } new_state = state |> put_session(ctx.chat_session, new_session) |> put_ref(task.ref, ctx.chat_session) {:noreply, new_state} else {:reply, {:error, :busy, "Another task is running in session '#{ctx.chat_session}'."}, state} end end @impl true def handle_call({:interrupt!, chat_session_id}, _from, state) do session = get_session(state, chat_session_id) if is_nil(session.task_ref) do {:reply, {:error, :no_task}, state} else TaskResult.set_interrupt_flag(chat_session_id) {:reply, :ok, state} end end @impl true def handle_call({:get_history, chat_session_id}, _from, state) do {:reply, get_session(state, chat_session_id).messages, state} end @impl true def handle_call(:list_chat_sessions, _from, state) do result = Map.new(state.sessions, fn {id, session} -> {id, %{ message_count: length(session.messages), status: if(is_nil(session.task_ref), do: "idle", else: "busy") }} end) {:reply, result, state} end @impl true def handle_call({:close_chat_session, "default"}, _from, state) do {:reply, {:error, :cannot_close_default}, state} end @impl true def handle_call({:close_chat_session, name}, _from, state) do case Map.fetch(state.sessions, name) do :error -> {:reply, {:error, :not_found}, state} {:ok, session} when not is_nil(session.task_ref) -> {:reply, {:error, :busy, "Cannot close session '#{name}': a task is still running in it."}, state} {:ok, _session} -> :telemetry.execute( [:eai, :chat, :session, :close], %{system_time: System.system_time()}, %{chat_session_id: name} ) # Step 2: cascade-clean any subagent entries that are queued for # this session. Without this, an LLM polling a queued subagent # after the session is closed would get a permanently `pending` # status (the dequeue trigger — finishing a subagent task — will # never fire for a closed session). # # We snapshot the queue first, fail every entry, then drop the # queue key. Wrapped in `transaction/2` with `queue_key` as the # lock target so a concurrent dequeue call (from a sibling task # finishing in the same millisecond) cannot pop a task that is # about to be cancelled. Each per-task result overwrite is a # separate `cache.put` so a single bad entry can't poison the # whole batch. cascade_close_queue(name) # Step 3 fix: also drop any ref_to_session entries that point at # the closed session. Without this, a late-arriving DOWN message # would re-enter the handle_info body, call put_session to # resurrect the session in state.sessions, and trigger a # dequeue — resurrecting a session the user just closed. We # also notify any still-pending monitors that the ref is gone. new_ref_to_session = state.ref_to_session |> Enum.reject(fn {_ref, sid} -> sid == name end) |> Map.new() {:reply, :ok, %{state | sessions: Map.delete(state.sessions, name), ref_to_session: new_ref_to_session}} end end @impl true def handle_call({:export_history, file_path, chat_session_id}, _from, state) do session = get_session(state, chat_session_id) result = try do compressed = snapshot_messages_bytes_from(session.messages) File.mkdir_p!(Path.dirname(file_path)) File.write!(file_path, compressed) {:ok, file_path} rescue e -> {:error, Exception.message(e)} end {:reply, result, state} end @impl true def handle_call({:replace_history, file_path, chat_session_id, format}, _from, state) do result = try do compressed = File.read!(file_path) decompressed = :zlib.gunzip(compressed) raw = :erlang.binary_to_term(decompressed) messages = convert_imported_messages(raw, format) {:ok, messages} rescue e -> {:error, Exception.message(e)} end case result do {:ok, messages} -> new_session = %{get_session(state, chat_session_id) | messages: messages} {:reply, {:ok, length(messages)}, put_session(state, chat_session_id, new_session)} {:error, reason} -> {:reply, {:error, reason}, state} end end # Step 4 — public helper extracted from the original export_history # handle_call body so Eai.System.snapshot_to_gzip/1 can reuse the # exact same format. Returns the gzip blob directly (no wrapper tuple). # # Reads messages live from the GenServer (one GenServer.call). The # caller (Eai.System) is responsible for await_idle/1 first to avoid # racing with in-flight mutations. def snapshot_messages_bytes(chat_session_id) do chat_session_id |> get_history() |> snapshot_messages_bytes_from() end defp snapshot_messages_bytes_from(messages) do messages |> Utils.sanitize_messages() |> :erlang.term_to_binary() |> :zlib.gzip() end @impl true def handle_cast({:talk_async, %Context{} = ctx}, state) do session = get_session(state, ctx.chat_session) sanitized = Utils.sanitize_value(ctx.message) user_msg = Message.new(:user, sanitized) new_messages = session.messages ++ [user_msg] run_opts = build_run_opts(ctx) task = Task.Supervisor.async_nolink( Eai.Naming.task_supervisor(), fn -> Direct.run(new_messages, ctx.pty_session, run_opts) end ) remind_timer = if ctx.timeout != :infinity and is_integer(ctx.timeout) and ctx.timeout > 0 do Process.send_after( self(), {:remind_model, ctx.pty_session, ctx.chat_session}, ctx.timeout ) end new_session = %{ session | messages: new_messages, task_ref: task.ref, remind_timer: remind_timer, from: nil } new_state = state |> put_session(ctx.chat_session, new_session) |> put_ref(task.ref, ctx.chat_session) {:noreply, new_state} end # ── 消息处理 ───────────────────────────────────────────────────── # 任务正常完成:{ref, result} + 从 ref_to_session 反查 chat_session_id @impl true def handle_info({ref, result}, state) when is_reference(ref) do case Map.pop(state.ref_to_session, ref) do {nil, _} -> # 非 ref 或不属于此 GenServer 的 Task,忽略 {:noreply, state} {chat_session_id, new_ref_map} -> session = get_session(state, chat_session_id) if session.remind_timer, do: Process.cancel_timer(session.remind_timer) {reply_to_caller, new_messages} = case result do {:ok, reply, full_history} -> IO.puts("\n🧙‍♀️ Assistant [##{chat_session_id}]: #{reply}") {{:ok, reply}, full_history} {:error, reason, partial_history} -> IO.puts(" ❌ Error [#{chat_session_id}]: #{inspect(reason)}") {{:error, reason}, partial_history} end Phoenix.PubSub.broadcast( Eai.Naming.pubsub(), "chat_updates:#{chat_session_id}", {:new_message, new_messages} ) if session.from, do: GenServer.reply(session.from, reply_to_caller) new_session = %{ session | messages: new_messages, task_ref: nil, from: nil, remind_timer: nil } new_state = %{state | ref_to_session: new_ref_map} |> put_session(chat_session_id, new_session) # Step 3: trigger dequeue from the chat GenServer AFTER task_ref # has been cleared. Mailbox ordering guarantees the dequeue's # spawned Task sees the now-idle session, closing the # microsecond race documented in step2_changes.md §F. # credo:disable-for-next-line apply(Eai.Tool.CallSubagent, :dequeue_next_subagent, [chat_session_id]) {:noreply, new_state} end end # 任务崩溃:处理 :DOWN 消息 @impl true def handle_info({:DOWN, ref, :process, _pid, reason}, state) do case Map.pop(state.ref_to_session, ref) do {nil, _} -> {:noreply, state} {chat_session_id, new_ref_map} -> session = get_session(state, chat_session_id) if session.remind_timer, do: Process.cancel_timer(session.remind_timer) Logger.error("Chat: task crashed in session '#{chat_session_id}' — #{inspect(reason)}") Phoenix.PubSub.broadcast( Eai.Naming.pubsub(), "chat_updates:#{chat_session_id}", {:new_message, session.messages} ) IO.puts(" 💥 Task crashed [#{chat_session_id}]: #{inspect(reason)}") if session.from, do: GenServer.reply(session.from, {:error, {:task_crashed, reason}}) new_session = %{session | task_ref: nil, from: nil, remind_timer: nil} new_state = %{state | ref_to_session: new_ref_map} |> put_session(chat_session_id, new_session) # Step 3: same as the {ref, result} branch — trigger dequeue from # the chat GenServer after task_ref is cleared. # credo:disable-for-next-line apply(Eai.Tool.CallSubagent, :dequeue_next_subagent, [chat_session_id]) {:noreply, new_state} end end # 超时提醒 @impl true def handle_info({:remind_model, pty_session_id, chat_session_id}, state) do Logger.info( "Chat: timeout reached, triggering timeout window for pty=#{pty_session_id} chat=#{chat_session_id}" ) TaskResult.trigger_timeout_window(pty_session_id) case Map.fetch(state.sessions, chat_session_id) do {:ok, session} -> {:noreply, put_session(state, chat_session_id, %{session | remind_timer: nil})} :error -> {:noreply, state} end end # 其他一切消息 @impl true def handle_info(_msg, state), do: {:noreply, state} # ── 内部辅助函数 ───────────────────────────────────────────────── defp new_session, do: %{messages: [], from: nil, task_ref: nil, remind_timer: nil} defp get_session(state, id), do: Map.get(state.sessions, id, new_session()) defp put_session(state, id, session), do: %{state | sessions: Map.put(state.sessions, id, session)} defp put_ref(state, ref, session_id), do: %{state | ref_to_session: Map.put(state.ref_to_session, ref, session_id)} defp build_run_opts(%Context{} = ctx) do card_opts = if ctx.chara_card_opt, do: Eai.Card.to_opts(Eai.Card.get!(ctx.chara_card_opt)), else: [] # Build optional fields dynamically using omit-when-nil pattern. # Fields present in the map are forwarded to Direct.run/3 as overrides; # absent fields fall back to the model config (config/models/.exs), # and if absent there too they are omitted from the HTTP body. optional = [ model: ctx.model_opt, system_prompt: ctx.prompt_opt, temperature: ctx.temperature_opt, top_p: ctx.top_p_opt, top_k: ctx.top_k_opt, min_p: ctx.min_p_opt, max_tokens: ctx.max_tokens_opt, repetition_penalty: ctx.repetition_penalty_opt, frequency_penalty: ctx.frequency_penalty_opt, presence_penalty: ctx.presence_penalty_opt, stop_sequences: ctx.stop_sequences_opt, seed: ctx.seed_opt, anthropic_beta: ctx.anthropic_beta_opt ] %{chat_session_id: ctx.chat_session} |> Map.merge(Map.new(card_opts)) |> Map.merge(Map.new(Enum.reject(optional, fn {_k, v} -> is_nil(v) end))) end # Convert imported raw messages to Eai.Message IR based on format defp convert_imported_messages(raw_messages, "converse") do # Already in Converse/Eai.Message format (from erlang term) raw_messages end defp convert_imported_messages(raw_messages, "openai") do AdapterOpenAI.from_messages(raw_messages) end defp convert_imported_messages(raw_messages, "anthropic") do AdapterAnthropic.from_messages(raw_messages) end defp convert_imported_messages(raw_messages, _unknown) do # Default: assume converse format raw_messages end defp cascade_close_queue(chat_session_id) do cache = Eai.Naming.cache() queue_key = "session_queue:#{chat_session_id}" close_opts = [keys: [queue_key]] case cache.transaction( close_opts, fn -> queue = cache.get(queue_key) || [] cache.delete(queue_key) queue end ) do [] -> :ok queue when is_list(queue) -> Enum.each(queue, fn entry -> {task_id, pty_session_id} = extract_queue_error_fields(entry) cache.put("subagent_result:#{task_id}", %{ status: "error", reason: "session_closed", chat_session: chat_session_id, pty_session_id: pty_session_id }) end) :ok _ -> :ok end end defp extract_queue_error_fields(%{task_id: tid, pty_session_id: pid}), do: {tid, pid} defp extract_queue_error_fields(_), do: {nil, nil} end