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