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