use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::providers::ToolCall; // ============================================================================ // ContentBlock - Multimodal content representation (OpenAI-style) // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "image_url")] ImageUrl { image_url: ImageUrlBlock }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageUrlBlock { pub url: String, } impl ContentBlock { pub fn text(content: impl Into) -> Self { Self::Text { text: content.into() } } pub fn image_url(url: impl Into) -> Self { Self::ImageUrl { image_url: ImageUrlBlock { url: url.into() }, } } } // ============================================================================ // MediaItem - Media metadata for messages // ============================================================================ #[derive(Debug, Clone)] pub struct MediaItem { pub path: String, // Local file path pub media_type: String, // "image", "audio", "file", "video" pub mime_type: Option, pub original_key: Option, // Feishu file_key for download } impl MediaItem { pub fn new(path: impl Into, media_type: impl Into) -> Self { Self { path: path.into(), media_type: media_type.into(), mime_type: None, original_key: None, } } } // ============================================================================ // ChatMessage - Used by AgentLoop for LLM conversation history // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatMessage { pub id: String, pub role: String, pub content: String, pub media_refs: Vec, // Paths to media files for context pub timestamp: i64, #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tool_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, } impl ChatMessage { pub fn user(content: impl Into) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "user".to_string(), content: content.into(), media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, } } pub fn user_with_media(content: impl Into, media_refs: Vec) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "user".to_string(), content: content.into(), media_refs, timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, } } pub fn assistant(content: impl Into) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "assistant".to_string(), content: content.into(), media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, } } pub fn assistant_with_tool_calls(content: impl Into, tool_calls: Vec) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "assistant".to_string(), content: content.into(), media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: Some(tool_calls), } } pub fn system(content: impl Into) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "system".to_string(), content: content.into(), media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: None, tool_name: None, tool_calls: None, } } pub fn tool(tool_call_id: impl Into, tool_name: impl Into, content: impl Into) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), role: "tool".to_string(), content: content.into(), media_refs: Vec::new(), timestamp: current_timestamp(), tool_call_id: Some(tool_call_id.into()), tool_name: Some(tool_name.into()), tool_calls: None, } } } // ============================================================================ // InboundMessage - Message from Channel to Bus (user input) // ============================================================================ #[derive(Debug, Clone)] pub struct InboundMessage { pub channel: String, pub sender_id: String, pub chat_id: String, pub content: String, pub timestamp: i64, pub media: Vec, /// Channel-specific data used internally by the channel (not forwarded). pub metadata: HashMap, /// Data forwarded from inbound to outbound (copied to OutboundMessage.metadata by gateway). pub forwarded_metadata: HashMap, } impl InboundMessage { pub fn session_key(&self) -> String { format!("{}:{}", self.channel, self.chat_id) } } // ============================================================================ // OutboundMessage - Message from Agent to Channel (bot response) // ============================================================================ #[derive(Debug, Clone)] pub struct OutboundMessage { pub channel: String, pub chat_id: String, pub content: String, pub reply_to: Option, pub media: Vec, pub metadata: HashMap, pub event_kind: OutboundEventKind, pub role: String, pub tool_call_id: Option, pub tool_name: Option, pub tool_arguments: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum OutboundEventKind { AssistantResponse, ToolCall, ToolResult, } impl OutboundMessage { pub fn is_stream_delta(&self) -> bool { self.metadata.get("_stream_delta").is_some() } pub fn assistant( channel: impl Into, chat_id: impl Into, content: impl Into, reply_to: Option, metadata: HashMap, ) -> Self { Self { channel: channel.into(), chat_id: chat_id.into(), content: content.into(), reply_to, media: Vec::new(), metadata, event_kind: OutboundEventKind::AssistantResponse, role: "assistant".to_string(), tool_call_id: None, tool_name: None, tool_arguments: None, } } pub fn tool_call( channel: impl Into, chat_id: impl Into, message_id: impl Into, tool_name: impl Into, tool_arguments: serde_json::Value, reply_to: Option, metadata: HashMap, ) -> Self { let tool_name = tool_name.into(); let content = format_tool_call_content(&tool_name, &tool_arguments); Self { channel: channel.into(), chat_id: chat_id.into(), content, reply_to, media: Vec::new(), metadata, event_kind: OutboundEventKind::ToolCall, role: "assistant".to_string(), tool_call_id: Some(message_id.into()), tool_name: Some(tool_name), tool_arguments: Some(tool_arguments), } } pub fn tool_result( channel: impl Into, chat_id: impl Into, tool_call_id: impl Into, tool_name: impl Into, content: impl Into, reply_to: Option, metadata: HashMap, ) -> Self { let tool_name = tool_name.into(); let raw_content = content.into(); let content = format_tool_result_content(&tool_name, &raw_content); Self { channel: channel.into(), chat_id: chat_id.into(), content, reply_to, media: Vec::new(), metadata, event_kind: OutboundEventKind::ToolResult, role: "tool".to_string(), tool_call_id: Some(tool_call_id.into()), tool_name: Some(tool_name), tool_arguments: None, } } pub fn from_chat_message( channel: &str, chat_id: &str, reply_to: Option, metadata: &HashMap, message: &ChatMessage, ) -> Vec { match message.role.as_str() { "assistant" => { if let Some(tool_calls) = &message.tool_calls { tool_calls .iter() .map(|tool_call| { Self::tool_call( channel.to_string(), chat_id.to_string(), tool_call.id.clone(), tool_call.name.clone(), tool_call.arguments.clone(), reply_to.clone(), metadata.clone(), ) }) .collect() } else { vec![Self::assistant( channel.to_string(), chat_id.to_string(), message.content.clone(), reply_to, metadata.clone(), )] } } "tool" => Vec::new(), _ => Vec::new(), } } } fn format_tool_call_content(tool_name: &str, tool_arguments: &serde_json::Value) -> String { format!( "调用工具: {}\n\n输入参数:\n{}", tool_name, format_json_value(tool_arguments), ) } fn format_tool_result_content(tool_name: &str, content: &str) -> String { format!("工具结果: {}\n\n{}", tool_name, content) } fn format_json_value(value: &serde_json::Value) -> String { match value { serde_json::Value::Object(map) if map.is_empty() => "{}".to_string(), other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()), } } // ============================================================================ // Helpers // ============================================================================ fn current_timestamp() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as i64 } #[cfg(test)] mod tests { use super::{ChatMessage, OutboundEventKind, OutboundMessage}; use crate::providers::ToolCall; use serde_json::json; use std::collections::HashMap; #[test] fn test_from_chat_message_expands_tool_calls() { let message = ChatMessage::assistant_with_tool_calls( "", vec![ ToolCall { id: "call-1".to_string(), name: "calculator".to_string(), arguments: json!({"expression": "1 + 1"}), }, ToolCall { id: "call-2".to_string(), name: "file_read".to_string(), arguments: json!({"path": "README.md"}), }, ], ); let outbound = OutboundMessage::from_chat_message( "feishu", "chat-1", None, &HashMap::new(), &message, ); assert_eq!(outbound.len(), 2); assert_eq!(outbound[0].event_kind, OutboundEventKind::ToolCall); assert_eq!(outbound[0].tool_name.as_deref(), Some("calculator")); assert_eq!(outbound[0].tool_arguments.as_ref().unwrap()["expression"], "1 + 1"); assert_eq!(outbound[1].tool_name.as_deref(), Some("file_read")); } #[test] fn test_from_chat_message_omits_tool_result() { let message = ChatMessage::tool("call-9", "calculator", "2"); let outbound = OutboundMessage::from_chat_message( "feishu", "chat-1", None, &HashMap::new(), &message, ); assert!(outbound.is_empty()); } }