PicoBot/src/bus/message.rs

413 lines
13 KiB
Rust

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<String>) -> Self {
Self::Text { text: content.into() }
}
pub fn image_url(url: impl Into<String>) -> 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<String>,
pub original_key: Option<String>, // Feishu file_key for download
}
impl MediaItem {
pub fn new(path: impl Into<String>, media_type: impl Into<String>) -> 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<String>, // Paths to media files for context
pub timestamp: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
impl ChatMessage {
pub fn user(content: impl Into<String>) -> 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<String>, media_refs: Vec<String>) -> 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<String>) -> 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<String>, tool_calls: Vec<ToolCall>) -> 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<String>) -> 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<String>, tool_name: impl Into<String>, content: impl Into<String>) -> 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<MediaItem>,
/// Channel-specific data used internally by the channel (not forwarded).
pub metadata: HashMap<String, String>,
/// Data forwarded from inbound to outbound (copied to OutboundMessage.metadata by gateway).
pub forwarded_metadata: HashMap<String, String>,
}
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<String>,
pub media: Vec<MediaItem>,
pub metadata: HashMap<String, String>,
pub event_kind: OutboundEventKind,
pub role: String,
pub tool_call_id: Option<String>,
pub tool_name: Option<String>,
pub tool_arguments: Option<serde_json::Value>,
}
#[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<String>,
chat_id: impl Into<String>,
content: impl Into<String>,
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> 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<String>,
chat_id: impl Into<String>,
message_id: impl Into<String>,
tool_name: impl Into<String>,
tool_arguments: serde_json::Value,
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> 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<String>,
chat_id: impl Into<String>,
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
content: impl Into<String>,
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> 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<String>,
metadata: &HashMap<String, String>,
message: &ChatMessage,
) -> Vec<Self> {
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());
}
}