Skip to main content

systemprompt_agent/services/a2a_server/processing/
conversation_service.rs

1use anyhow::{Result, anyhow};
2use base64::Engine;
3use systemprompt_database::DbPool;
4use systemprompt_identifiers::ContextId;
5use systemprompt_models::{
6    AiContentPart, AiMessage, MessageRole, is_supported_audio, is_supported_image,
7    is_supported_text, is_supported_video,
8};
9
10use crate::models::a2a::{FilePart, MessageRole as A2aMessageRole, Part};
11use crate::models::{Artifact, Message};
12use crate::repository::task::TaskRepository;
13
14#[derive(Debug)]
15pub struct ConversationService {
16    db_pool: DbPool,
17}
18
19impl ConversationService {
20    pub const fn new(db_pool: DbPool) -> Self {
21        Self { db_pool }
22    }
23
24    pub async fn load_conversation_history(
25        &self,
26        context_id: &ContextId,
27    ) -> Result<Vec<AiMessage>> {
28        let task_repo = TaskRepository::new(&self.db_pool)?;
29        let tasks = task_repo
30            .list_tasks_by_context(context_id)
31            .await
32            .map_err(|e| anyhow!("Failed to load conversation history: {}", e))?;
33
34        let mut history_messages = Vec::new();
35
36        for task in tasks {
37            if let Some(task_history) = task.history {
38                for msg in task_history {
39                    let (text, parts) = Self::extract_message_content(&msg);
40                    if text.is_empty() && parts.is_empty() {
41                        continue;
42                    }
43
44                    let role = match msg.role {
45                        A2aMessageRole::User => MessageRole::User,
46                        A2aMessageRole::Agent => MessageRole::Assistant,
47                    };
48
49                    history_messages.push(AiMessage {
50                        role,
51                        content: text,
52                        parts,
53                    });
54                }
55            }
56
57            if let Some(artifacts) = task.artifacts {
58                for artifact in artifacts {
59                    let artifact_content = Self::serialize_artifact_for_context(&artifact);
60                    history_messages.push(AiMessage {
61                        role: MessageRole::Assistant,
62                        content: artifact_content,
63                        parts: Vec::new(),
64                    });
65                }
66            }
67        }
68
69        Ok(history_messages)
70    }
71
72    fn extract_message_content(message: &Message) -> (String, Vec<AiContentPart>) {
73        let mut text_content = String::new();
74        let mut content_parts = Vec::new();
75
76        for part in &message.parts {
77            match part {
78                Part::Text(text_part) => {
79                    if text_content.is_empty() {
80                        text_content.clone_from(&text_part.text);
81                    }
82                    content_parts.push(AiContentPart::text(&text_part.text));
83                },
84                Part::File(file_part) => {
85                    if let Some(content_part) = Self::file_to_content_part(file_part) {
86                        content_parts.push(content_part);
87                    }
88                },
89                Part::Data(_) => {},
90            }
91        }
92
93        (text_content, content_parts)
94    }
95
96    fn file_to_content_part(file_part: &FilePart) -> Option<AiContentPart> {
97        let mime_type = file_part.file.mime_type.as_deref()?;
98        let file_name = file_part.file.name.as_deref().unwrap_or("unnamed");
99
100        let bytes = file_part.file.bytes.as_deref()?;
101
102        if is_supported_image(mime_type) {
103            return Some(AiContentPart::image(mime_type, bytes));
104        }
105
106        if is_supported_audio(mime_type) {
107            return Some(AiContentPart::audio(mime_type, bytes));
108        }
109
110        if is_supported_video(mime_type) {
111            return Some(AiContentPart::video(mime_type, bytes));
112        }
113
114        if is_supported_text(mime_type) {
115            return Self::decode_text_file(bytes, file_name, mime_type);
116        }
117
118        tracing::warn!(
119            file_name = %file_name,
120            mime_type = %mime_type,
121            "Unsupported file type - file will not be sent to AI"
122        );
123        None
124    }
125
126    fn decode_text_file(bytes: &str, file_name: &str, mime_type: &str) -> Option<AiContentPart> {
127        let decoded = base64::engine::general_purpose::STANDARD
128            .decode(bytes)
129            .map_err(|e| {
130                tracing::warn!(
131                    file_name = %file_name,
132                    mime_type = %mime_type,
133                    error = %e,
134                    "Failed to decode base64 text file"
135                );
136                e
137            })
138            .ok()?;
139
140        let text_content = String::from_utf8(decoded)
141            .map_err(|e| {
142                tracing::warn!(
143                    file_name = %file_name,
144                    mime_type = %mime_type,
145                    error = %e,
146                    "Failed to decode text file as UTF-8"
147                );
148                e
149            })
150            .ok()?;
151
152        let formatted = format!("[File: {file_name} ({mime_type})]\n{text_content}");
153        Some(AiContentPart::text(formatted))
154    }
155
156    fn serialize_artifact_for_context(artifact: &Artifact) -> String {
157        let artifact_name = artifact
158            .title
159            .clone()
160            .unwrap_or_else(|| "unnamed".to_string());
161
162        let mut content = format!(
163            "[Artifact: {} (type: {})]\n",
164            artifact_name, artifact.metadata.artifact_type
165        );
166
167        for part in &artifact.parts {
168            match part {
169                Part::Text(text_part) => {
170                    content.push_str(&text_part.text);
171                    content.push('\n');
172                },
173                Part::Data(data_part) => {
174                    let json_str = serde_json::to_string_pretty(&data_part.data)
175                        .unwrap_or_else(|_| "{}".to_string());
176                    content.push_str(&json_str);
177                    content.push('\n');
178                },
179                Part::File(file_part) => {
180                    if let Some(name) = &file_part.file.name {
181                        content.push_str(&format!("[File: {}]\n", name));
182                    }
183                },
184            }
185        }
186
187        content
188    }
189}