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