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