use serde::{Deserialize, Serialize};
use starweaver_model::ContentPart;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct PromptAttachment {
pub data: Vec<u8>,
pub media_type: String,
pub size_bytes: usize,
pub placeholder: String,
}
impl PromptAttachment {
#[must_use]
#[allow(dead_code)]
pub fn image(index: usize, data: Vec<u8>, media_type: impl Into<String>) -> Self {
let media_type = media_type.into();
let size_bytes = data.len();
let placeholder = attachment_placeholder(index, &media_type, size_bytes);
Self {
data,
media_type,
size_bytes,
placeholder,
}
}
#[must_use]
pub fn description(&self) -> String {
format!("{} {}", self.media_type, format_size_bytes(self.size_bytes))
}
#[must_use]
pub fn into_content_part(self) -> ContentPart {
ContentPart::Binary {
data: self.data,
media_type: self.media_type,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct PromptInput {
pub text: String,
#[serde(default)]
pub attachments: Vec<PromptAttachment>,
#[serde(default)]
pub extra_text_parts: Vec<String>,
#[serde(default)]
pub guidance_text_parts: Vec<String>,
}
impl PromptInput {
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
attachments: Vec::new(),
extra_text_parts: Vec::new(),
guidance_text_parts: Vec::new(),
}
}
#[must_use]
pub const fn has_content_parts(&self) -> bool {
!self.attachments.is_empty() || !self.extra_text_parts.is_empty()
}
pub fn push_guidance_text_part(&mut self, text: impl Into<String>) {
let text = text.into();
if !text.trim().is_empty() {
self.guidance_text_parts.push(text);
}
}
#[must_use]
pub fn into_content_parts(self) -> Vec<ContentPart> {
let mut text = self.text;
for attachment in &self.attachments {
if !attachment.placeholder.is_empty() {
text = text.replace(&attachment.placeholder, "");
}
}
let text = text.trim().to_string();
let mut parts = Vec::new();
if !text.is_empty() {
parts.push(ContentPart::Text { text });
}
parts.extend(
self.attachments
.into_iter()
.map(PromptAttachment::into_content_part),
);
parts.extend(
self.extra_text_parts
.into_iter()
.filter(|text| !text.trim().is_empty())
.map(|text| ContentPart::Text { text }),
);
parts
}
#[must_use]
pub fn display_text(&self) -> String {
if !self.text.trim().is_empty() {
return self.text.trim().to_string();
}
self.attachments
.iter()
.map(|attachment| attachment.placeholder.clone())
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub fn history_text(&self) -> Option<String> {
let mut text = self.text.clone();
for attachment in &self.attachments {
if !attachment.placeholder.is_empty() {
text = text.replace(&attachment.placeholder, "");
}
}
let text = text.trim().to_string();
(!text.is_empty()).then_some(text)
}
}
#[must_use]
#[allow(dead_code)]
pub fn attachment_placeholder(index: usize, media_type: &str, size_bytes: usize) -> String {
format!(
"[Attached image {index}: {media_type} {}]",
format_size_bytes(size_bytes)
)
}
#[must_use]
pub fn format_size_bytes(size_bytes: usize) -> String {
if size_bytes < 1024 {
return format!("{size_bytes}B");
}
if size_bytes < 1024 * 1024 {
return format!("{}KB", size_bytes.saturating_add(512) / 1024);
}
let tenths = size_bytes.saturating_mul(10).saturating_add(512 * 1024) / (1024 * 1024);
format!("{}.{:01}MB", tenths / 10, tenths % 10)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_input_strips_placeholders_and_appends_binary_parts() {
let attachment = PromptAttachment::image(1, b"image-bytes".to_vec(), "image/png");
let placeholder = attachment.placeholder.clone();
let input = PromptInput {
text: format!("look at this {placeholder} please"),
attachments: vec![attachment],
extra_text_parts: Vec::new(),
guidance_text_parts: Vec::new(),
};
let parts = input.into_content_parts();
assert_eq!(
parts,
vec![
ContentPart::Text {
text: "look at this please".to_string(),
},
ContentPart::Binary {
data: b"image-bytes".to_vec(),
media_type: "image/png".to_string(),
},
]
);
}
#[test]
fn prompt_input_appends_extra_text_parts_after_attachments() {
let mut input = PromptInput::text("inspect");
input.extra_text_parts.push(
"<user-rules location=/tmp/RULES.md>\nPrefer concise output.\n</user-rules>"
.to_string(),
);
let parts = input.into_content_parts();
assert_eq!(
parts,
vec![
ContentPart::Text {
text: "inspect".to_string(),
},
ContentPart::Text {
text:
"<user-rules location=/tmp/RULES.md>\nPrefer concise output.\n</user-rules>"
.to_string(),
},
]
);
}
#[test]
fn history_text_excludes_generated_attachment_placeholders() {
let attachment = PromptAttachment::image(1, vec![1, 2, 3], "image/png");
let placeholder = attachment.placeholder.clone();
let with_text = PromptInput {
text: format!("inspect {placeholder}"),
attachments: vec![attachment.clone()],
extra_text_parts: Vec::new(),
guidance_text_parts: Vec::new(),
};
assert_eq!(with_text.history_text().as_deref(), Some("inspect"));
let attachment_only = PromptInput {
text: placeholder,
attachments: vec![attachment],
extra_text_parts: Vec::new(),
guidance_text_parts: Vec::new(),
};
assert_eq!(attachment_only.history_text(), None);
}
#[test]
fn display_text_falls_back_to_attachment_placeholder() {
let input = PromptInput {
text: " ".to_string(),
attachments: vec![PromptAttachment::image(1, vec![1, 2, 3], "image/png")],
extra_text_parts: Vec::new(),
guidance_text_parts: Vec::new(),
};
assert_eq!(input.display_text(), "[Attached image 1: image/png 3B]");
assert_eq!(format_size_bytes(1024), "1KB");
assert_eq!(format_size_bytes(1024 * 1024), "1.0MB");
}
}