Add support for file attachments (m.file) in conversations
Files sent as m.file Matrix messages are now downloaded, MIME-detected, and forwarded to LLM providers alongside the conversation context, similar to how m.image is already handled. - OpenAI provider: sends files inline as base64 data URLs - Anthropic provider: skips files with a warning (library limitation) - OpenAI-compat provider: skips files with a warning (library limitation) Controller routing respects the existing prefix requirement setting. MIME detection expanded to cover PDF, text, code, and document formats. Docs updated to reflect file support and known limitations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,13 @@ pub(super) fn create_anthropic_message_request(llm_messages: Vec<LLMMessage>) ->
|
||||
},
|
||||
}]
|
||||
}
|
||||
LLMMessageContent::File(file_details) => {
|
||||
tracing::warn!(
|
||||
"The Anthropic provider's library does not support file/document content. This file message ({}) will be skipped.",
|
||||
file_details.filename(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let message = Message { role, content };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use async_openai::types::responses::{
|
||||
EasyInputContent, EasyInputMessage, ImageDetail, InputContent, InputImageContent, InputItem,
|
||||
InputParam, MessageType, Role,
|
||||
EasyInputContent, EasyInputMessage, ImageDetail, InputContent, InputFileArgs,
|
||||
InputImageContent, InputItem, InputParam, MessageType, Role,
|
||||
};
|
||||
|
||||
use crate::conversation::llm::{
|
||||
@@ -35,6 +35,21 @@ pub fn convert_llm_messages_to_openai_response_input(
|
||||
file_id: None,
|
||||
})])
|
||||
}
|
||||
LLMMessageContent::File(file_details) => {
|
||||
let file_data = format!(
|
||||
"data:{};base64,{}",
|
||||
file_details.mime,
|
||||
base64_encode(&file_details.data)
|
||||
);
|
||||
|
||||
let file_content = InputFileArgs::default()
|
||||
.file_data(file_data)
|
||||
.filename(file_details.filename())
|
||||
.build()
|
||||
.expect("Failed to build InputFileContent");
|
||||
|
||||
EasyInputContent::ContentList(vec![InputContent::InputFile(file_content)])
|
||||
}
|
||||
};
|
||||
|
||||
items.push(InputItem::EasyMessage(EasyInputMessage {
|
||||
|
||||
@@ -40,6 +40,12 @@ fn convert_llm_message_to_openai_message(llm_message: LLMMessage) -> Option<Mess
|
||||
);
|
||||
None
|
||||
}
|
||||
LLMMessageContent::File(_file_details) => {
|
||||
tracing::warn!(
|
||||
"The OpenAI-compat provider's library does not support file content. This file message will be skipped."
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ pub enum ChatCompletionControllerType {
|
||||
|
||||
Image,
|
||||
|
||||
File,
|
||||
|
||||
ThreadMention,
|
||||
ReplyMention,
|
||||
}
|
||||
@@ -419,7 +421,8 @@ async fn handle_stage_text_generation(
|
||||
| ChatCompletionControllerType::TextMention
|
||||
| ChatCompletionControllerType::TextDirect
|
||||
| ChatCompletionControllerType::Audio
|
||||
| ChatCompletionControllerType::Image => {
|
||||
| ChatCompletionControllerType::Image
|
||||
| ChatCompletionControllerType::File => {
|
||||
Some(message_context.combined_admin_and_user_regexes())
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,18 @@ pub fn determine_controller(
|
||||
)
|
||||
}
|
||||
}
|
||||
MessagePayload::File(_file_message_content) => {
|
||||
let prefix_requirement_type = message_context
|
||||
.room_config_context()
|
||||
.text_generation_prefix_requirement_type();
|
||||
|
||||
match prefix_requirement_type {
|
||||
TextGenerationPrefixRequirementType::CommandPrefix => ControllerType::Ignore,
|
||||
TextGenerationPrefixRequirementType::No => {
|
||||
ControllerType::ChatCompletion(ChatCompletionControllerType::File)
|
||||
}
|
||||
}
|
||||
}
|
||||
MessagePayload::Audio(_) => {
|
||||
ControllerType::ChatCompletion(ChatCompletionControllerType::Audio)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::ImageMessageEventContent;
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::{
|
||||
FileMessageEventContent, ImageMessageEventContent,
|
||||
};
|
||||
use mxlink::mime::Mime;
|
||||
|
||||
use crate::agent::provider::ImageSource;
|
||||
@@ -48,10 +50,35 @@ impl From<ImageDetails> for ImageSource {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDetails {
|
||||
pub event_content: FileMessageEventContent,
|
||||
pub mime: Mime,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl FileDetails {
|
||||
pub fn new(event_content: FileMessageEventContent, mime: Mime, data: Vec<u8>) -> Self {
|
||||
Self {
|
||||
event_content,
|
||||
mime,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> String {
|
||||
self.event_content
|
||||
.filename
|
||||
.clone()
|
||||
.unwrap_or(self.event_content.body.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MessageContent {
|
||||
Text(String),
|
||||
Image(ImageDetails),
|
||||
File(FileDetails),
|
||||
}
|
||||
|
||||
impl PartialEq for MessageContent {
|
||||
@@ -62,6 +89,7 @@ impl PartialEq for MessageContent {
|
||||
// We can probably do better than this by inspecting `.event_conten1t.source`, but for now this is good enough.
|
||||
a.filename() == b.filename()
|
||||
}
|
||||
(MessageContent::File(a), MessageContent::File(b)) => a.filename() == b.filename(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ fn calculate_token_size_for_message(bpe: &CoreBPE, model: &str, message: &Messag
|
||||
let text_length = match &message.content {
|
||||
MessageContent::Text(text) => bpe.encode_with_special_tokens(text).len() as i32,
|
||||
MessageContent::Image(..) => 0,
|
||||
MessageContent::File(..) => 0,
|
||||
};
|
||||
|
||||
(text_length + role_length + tokens_per_message + tokens_per_name) as u32
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use mxlink::matrix_sdk::ruma::OwnedUserId;
|
||||
|
||||
use super::entity::{Author, ImageDetails, Message, MessageContent};
|
||||
use super::entity::{Author, FileDetails, ImageDetails, Message, MessageContent};
|
||||
use crate::conversation::matrix::{MatrixMessage, MatrixMessageContent};
|
||||
use crate::utils::text_to_speech as text_to_speech_utils;
|
||||
|
||||
@@ -32,6 +32,15 @@ fn convert_bot_message(matrix_message: &MatrixMessage) -> Option<Message> {
|
||||
)),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
MatrixMessageContent::File(file_content, mime_type, media_bytes) => Some(Message {
|
||||
author: Author::Assistant,
|
||||
content: MessageContent::File(FileDetails::new(
|
||||
file_content.clone(),
|
||||
mime_type.clone(),
|
||||
media_bytes.clone(),
|
||||
)),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,5 +99,14 @@ fn convert_user_message(matrix_message: &MatrixMessage) -> Option<Message> {
|
||||
)),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
MatrixMessageContent::File(file_content, mime_type, media_bytes) => Some(Message {
|
||||
author: Author::User,
|
||||
content: MessageContent::File(FileDetails::new(
|
||||
file_content.clone(),
|
||||
mime_type.clone(),
|
||||
media_bytes.clone(),
|
||||
)),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ use chrono::{DateTime, Utc};
|
||||
use regex::Regex;
|
||||
|
||||
use mxlink::matrix_sdk::ruma::OwnedUserId;
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::ImageMessageEventContent;
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::{
|
||||
FileMessageEventContent, ImageMessageEventContent,
|
||||
};
|
||||
use mxlink::mime::Mime;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -18,6 +20,7 @@ pub enum MatrixMessageContent {
|
||||
Text(String),
|
||||
Notice(String),
|
||||
Image(ImageMessageEventContent, Mime, Vec<u8>),
|
||||
File(FileMessageEventContent, Mime, Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -234,6 +234,7 @@ pub async fn convert_matrix_native_event_to_matrix_message(
|
||||
MessageType::Text(text_content) => (text_content.body.clone(), false),
|
||||
MessageType::Notice(notice_content) => (notice_content.body.clone(), true),
|
||||
MessageType::Image(image_content) => (image_content.body.clone(), false),
|
||||
MessageType::File(file_content) => (file_content.body.clone(), false),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
@@ -291,6 +292,49 @@ pub async fn convert_matrix_native_event_to_matrix_message(
|
||||
}));
|
||||
}
|
||||
|
||||
if let MessageType::File(file_content) = &room_message.msgtype {
|
||||
let media_request = mxlink::matrix_sdk::media::MediaRequestParameters {
|
||||
source: file_content.source.to_owned(),
|
||||
format: mxlink::matrix_sdk::media::MediaFormat::File,
|
||||
};
|
||||
|
||||
let file_name = file_content
|
||||
.filename
|
||||
.clone()
|
||||
.unwrap_or(file_content.body.clone());
|
||||
|
||||
let mime_type = file_content
|
||||
.info
|
||||
.as_ref()
|
||||
.and_then(|info| info.mimetype.clone())
|
||||
.and_then(|mimetype| mimetype.parse::<mxlink::mime::Mime>().ok())
|
||||
.unwrap_or_else(|| get_mime_type_from_file_name(&file_name));
|
||||
|
||||
tracing::debug!("Determined mime type {} for file {}", mime_type, file_name);
|
||||
|
||||
let span = tracing::debug_span!("get_media_content", file_name = %file_name, mime_type = %mime_type);
|
||||
|
||||
let media_bytes = matrix_link
|
||||
.client()
|
||||
.media()
|
||||
.get_media_content(&media_request, true)
|
||||
.instrument(span)
|
||||
.await?;
|
||||
|
||||
tracing::debug!(
|
||||
"Downloaded {} bytes for file {}",
|
||||
media_bytes.len(),
|
||||
file_name
|
||||
);
|
||||
|
||||
return Ok(Some(MatrixMessage {
|
||||
sender_id: matrix_native_event.sender().to_owned(),
|
||||
content: MatrixMessageContent::File(file_content.clone(), mime_type, media_bytes),
|
||||
mentioned_users,
|
||||
timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Some(MatrixMessage {
|
||||
sender_id: matrix_native_event.sender().to_owned(),
|
||||
content: if is_notice {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::{
|
||||
AudioMessageEventContent, ImageMessageEventContent, MessageType, TextMessageEventContent,
|
||||
AudioMessageEventContent, FileMessageEventContent, ImageMessageEventContent, MessageType,
|
||||
TextMessageEventContent,
|
||||
};
|
||||
use mxlink::matrix_sdk::ruma::{OwnedEventId, OwnedUserId};
|
||||
|
||||
@@ -30,6 +31,7 @@ pub enum MessagePayload {
|
||||
Text(TextMessageEventContent),
|
||||
Audio(AudioMessageEventContent),
|
||||
Image(ImageMessageEventContent),
|
||||
File(FileMessageEventContent),
|
||||
|
||||
Reaction {
|
||||
key: String,
|
||||
@@ -57,6 +59,7 @@ impl TryInto<MessagePayload> for MessageType {
|
||||
MessagePayload::Audio(audio_content)
|
||||
}
|
||||
MessageType::Image(image_content) => MessagePayload::Image(image_content),
|
||||
MessageType::File(file_content) => MessagePayload::File(file_content),
|
||||
other => {
|
||||
return Err(format!("Unsupported message type: {:?}", other));
|
||||
}
|
||||
|
||||
@@ -29,6 +29,18 @@ pub fn get_mime_type_from_file_name(file_name: &str) -> mime::Mime {
|
||||
"bmp" => "image/bmp".parse().unwrap(),
|
||||
"heic" | "heif" => "image/heic".parse().unwrap(),
|
||||
"avif" => "image/avif".parse().unwrap(),
|
||||
"pdf" => "application/pdf".parse().unwrap(),
|
||||
"txt" | "log" => mime::TEXT_PLAIN,
|
||||
"csv" => mime::TEXT_CSV,
|
||||
"json" => mime::APPLICATION_JSON,
|
||||
"xml" => "application/xml".parse().unwrap(),
|
||||
"html" | "htm" => mime::TEXT_HTML,
|
||||
"md" | "markdown" => "text/markdown".parse().unwrap(),
|
||||
"yaml" | "yml" => "application/yaml".parse().unwrap(),
|
||||
"toml" => "application/toml".parse().unwrap(),
|
||||
"rs" | "py" | "js" | "ts" | "go" | "c" | "cpp" | "h" | "java" | "rb" | "sh" | "css" => {
|
||||
mime::TEXT_PLAIN
|
||||
}
|
||||
_ => mime::APPLICATION_OCTET_STREAM,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user