use std::collections::HashMap; use async_trait::async_trait; use tokio::sync::broadcast; use crate::events::GlobalEvent; use crate::interface_tool::InterfaceTool; use crate::message_meta::{Attachment, MessageMetadata}; // ── SendMessageOptions ──────────────────────────────────────────────────────── /// Optional parameters for a [`ChatHubApi::send_message`] call. #[derive(Default)] pub struct SendMessageOptions { /// Agent to use for this source's session. When unset, falls back to the /// hub's owner-resolved default entry agent (the caller's role `attrs.chat_agent`, /// else `DEFAULT_CHAT_AGENT`). Only takes effect when a new session is created — /// ignored for existing sessions. pub agent_id: Option, /// Named substitutions applied to the agent's system prompt. /// Each entry replaces the sentinel `__KEY__` in the loaded prompt text. /// Matches `` placeholders that `agents::resolve_includes` converts to sentinels. pub system_substitutions: HashMap, pub client_name: Option, /// Extra text prepended to the agent's system prompt for this turn only. /// STATIC: safe to cache — use for interface-specific formatting rules that /// never change turn-to-turn (e.g. Telegram HTML mode). pub extra_system_context: Option, /// Extra system message injected AFTER the conversation history for this turn only. /// DYNAMIC: not cached — use for per-turn context (e.g. notification framing). pub extra_system_dynamic: Option, /// Short reminder injected near the tail of the message list to prevent drift. pub tail_reminder: Option, pub interface_tools: Vec, /// True for system-generated messages injected as user turns (notification briefings). pub is_synthetic: bool, /// Opaque structured metadata persisted on the user turn (e.g. file attachments). /// ChatHub forwards it verbatim; the projection and the UI derive their own views. pub metadata: Option, } // ── ChatHubApi ──────────────────────────────────────────────────────────────── /// Abstraction over [`ChatHub`](crate) that plugins and external crates depend on. /// /// Implementing this trait for `ChatHub` in the main crate is the only coupling /// point needed: plugins can accept `Arc` and stay independent. #[async_trait] pub trait ChatHubApi: Send + Sync { /// Register a source. No-op for duplicate registrations. async fn register(&self, source_id: &str); /// Send a user message for a source, running a full LLM turn. /// Creates a session lazily if none exists yet. async fn send_message( &self, source_id: &str, prompt: &str, opts: SendMessageOptions, ) -> anyhow::Result<()>; /// Persist an uploaded file for `source_id` into the owner's /// `~/uploads/{session}/` and return its [`Attachment`] (home-relative agent /// path). Channel adapters (e.g. the Telegram plugin) call this instead of /// writing files themselves, so the core owns *where* uploads land and every /// surface produces a path the agent can actually reach. The recognized /// magic-byte MIME wins over the caller-claimed `client_mime`. async fn save_upload( &self, source_id: &str, file_name: &str, client_mime: Option, bytes: &[u8], ) -> anyhow::Result; /// Create a new session for the source, discarding the previous one. async fn clear(&self, source_id: &str) -> anyhow::Result; /// Subscribe to the global event bus. /// Filtering by source is the caller's responsibility. fn events(&self, source_id: &str) -> broadcast::Receiver; /// Set which source is the "home" for background agent notifications. async fn set_home(&self, source_id: &str) -> anyhow::Result<()>; /// Returns token usage `(input, output)` for the last message in the source's session. async fn context_info( &self, source_id: &str, ) -> anyhow::Result<(Option, Option)>; /// Total spend (USD) of the source's active session, including synchronous /// sub-agent frames and excluding asynchronous tasks (which run in their own /// session). `None` when no provider reported a cost. async fn cost_info(&self, source_id: &str) -> anyhow::Result>; /// Force compaction of the source's active session history. /// Returns `true` if compaction occurred. async fn force_compact(&self, source_id: &str) -> anyhow::Result; /// Resume any interrupted turn for a source's active session. async fn resume(&self, source_id: &str) -> anyhow::Result<()>; /// Approve a pending tool-call approval request. async fn approve(&self, request_id: i64); /// Reject a pending tool-call approval request. async fn reject(&self, request_id: i64, note: String); /// Resolve a pending `ask_user_clarification` question. /// Collapses `session_handler(source_id).resolve_question(...)` into a single /// hub-level call so callers never need to know about `ChatSessionHandler`. async fn resolve_question(&self, source_id: &str, request_id: i64, answer: String); /// Cancel the active LLM turn for a source, clearing any pending approvals /// and clarification questions. No-op if no session is active. async fn cancel(&self, source_id: &str); /// Revoke all session-scoped MCP grants for a source's active session. /// The next LLM turn will start with no MCP tools activated. async fn reset_mcp(&self, source_id: &str) -> anyhow::Result<()>; /// Returns `(models, default)` where `models` is the ordered list of usable /// LLM client names (first entry is always `"auto"`) and `default` is the /// configured default client name. Used by `/models` and the web selector. async fn list_clients(&self) -> (Vec, String); /// Returns the client name pinned for the source, or `None` when unset /// (the caller should fall back to AUTO resolution). async fn get_selected_client(&self, source_id: &str) -> Option; /// Pin a client name for the source and broadcast `ClientSelected` to every /// client of the source. `client` must be a `list_clients()` entry (e.g. /// `"auto"` or a model name); the caller is responsible for validation. async fn set_selected_client(&self, source_id: &str, client: String); /// Clear any pinned client for the source (revert to AUTO) and broadcast /// `ClientSelected { client: "auto" }`. async fn clear_selected_client(&self, source_id: &str); /// Snapshot of the model list with the per-source current selection marked. /// Returns `(index, name, is_current)` tuples — call sites format them as /// HTML or Markdown without re-querying the LLM manager. async fn list_clients_marked( &self, source_id: &str, ) -> Vec<(usize, String, bool)>; /// Apply a `/model {arg}` command: resolve the argument, mutate the /// per-source pinned client (broadcasting `ClientSelected`), return a /// structured outcome the caller can format for its medium (HTML/Markdown). async fn apply_model_command( &self, source_id: &str, arg: &str, ) -> ModelCommandOutcome; } // ── Model command helpers (shared business logic) ──────────────────────────── /// Outcome of [`ChatHubApi::apply_model_command`]. The caller formats each /// variant for its medium (Telegram HTML, web Markdown, …). #[derive(Debug, Clone)] pub enum ModelCommandOutcome { /// A model was pinned. The backend has already broadcast `ClientSelected`. Set(String), /// The pin was cleared (back to AUTO). The backend has already broadcast /// `ClientSelected { client: "auto" }`. Cleared, /// The argument was empty, out of range, or ambiguous. Carries a /// user-facing message (no formatting — the caller wraps it as needed). Error(String), } /// Resolve a `/model` (or analogous — e.g. a future `/reasoning`) argument /// against an ordered list. /// /// Returns: /// - `Ok(Some(client))` for a unique match (caller pins it) /// - `Ok(None)` for `auto` / index 0 (caller clears the pin) /// - `Err(user_facing_message)` when the input is empty / out of range / /// ambiguous /// /// Accepts (in order): /// 1. `auto` (case-insensitive) — or numeric `0` which is conventionally the /// "auto" slot in `client_names()` /// 2. Numeric index `N` → exact lookup /// 3. Exact case-insensitive name match /// 4. Substring match (case-insensitive) — must be unique pub fn resolve_list_arg(models: &[String], arg: &str) -> Result, String> { let arg = arg.trim(); if arg.is_empty() { return Err("Usage: /model N or /model name or /model auto".to_string()); } if arg.eq_ignore_ascii_case("auto") { return Ok(None); } if let Ok(n) = arg.parse::() { return match models.get(n) { Some(m) if m == "auto" => Ok(None), Some(m) => Ok(Some(m.clone())), None => Err(format!("Index {n} out of range. Use /models to see the list.")), }; } if let Some(m) = models.iter().find(|m| m.eq_ignore_ascii_case(arg)) { return Ok(if m == "auto" { None } else { Some(m.clone()) }); } let lower = arg.to_ascii_lowercase(); let hits: Vec<&String> = models.iter().filter(|m| m.to_ascii_lowercase().contains(&lower)).collect(); match hits.len() { 1 => Ok(if hits[0] == "auto" { None } else { Some(hits[0].clone()) }), 0 => Err(format!("No model matches '{arg}'. Use /models to see the list.")), _ => Err(format!( "Multiple models match '{arg}': {}. Be more specific.", hits.iter().map(|h| h.as_str()).collect::>().join(", ") )), } }