use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct ProviderMetadata {
pub model_id: Option<String>,
pub request_id: Option<String>,
pub timestamp: Option<String>,
#[serde(flatten)]
pub extra: Value,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum PartState {
Streaming,
#[default]
Done,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TextPart {
pub text: String,
#[serde(default)]
pub state: Option<PartState>,
#[serde(default)]
pub provider_metadata: Option<ProviderMetadata>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReasoningPart {
pub text: String,
#[serde(default)]
pub state: Option<PartState>,
#[serde(default)]
pub provider_metadata: Option<ProviderMetadata>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FilePart {
pub media_type: String,
pub url: String,
#[serde(default)]
pub filename: Option<String>,
#[serde(default)]
pub provider_metadata: Option<ProviderMetadata>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCallPart {
pub tool_call_id: String,
pub tool_name: String,
pub args: Value,
#[serde(default)]
pub provider_metadata: Option<ProviderMetadata>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolResultPart {
pub tool_call_id: String,
#[serde(default)]
pub tool_name: Option<String>,
pub result: Value,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolPart {
pub tool_name: String,
pub tool_call_id: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub provider_executed: bool,
pub state: DynamicToolState,
#[serde(default)]
pub call_provider_metadata: Option<ProviderMetadata>,
#[serde(default)]
pub preliminary: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceUrlPart {
pub source_id: String,
pub url: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub provider_metadata: Option<ProviderMetadata>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceDocumentPart {
pub source_id: String,
pub media_type: String,
pub title: String,
#[serde(default)]
pub filename: Option<String>,
#[serde(default)]
pub provider_metadata: Option<ProviderMetadata>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DataPart {
pub data_type: String,
#[serde(default)]
pub id: Option<String>,
pub data: Value,
}
#[derive(Debug, Clone, Serialize)]
pub enum UIMessagePart {
Text(TextPart),
Reasoning(ReasoningPart),
File(FilePart),
ToolCall(ToolCallPart),
ToolResult(ToolResultPart),
DynamicTool(DynamicToolPart),
SourceUrl(SourceUrlPart),
SourceDocument(SourceDocumentPart),
StepStart,
Data(DataPart),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum UIMessagePartTagged {
Text(TextPart),
Reasoning(ReasoningPart),
File(FilePart),
ToolCall(ToolCallPart),
ToolResult(ToolResultPart),
DynamicTool(DynamicToolPart),
SourceUrl(SourceUrlPart),
SourceDocument(SourceDocumentPart),
StepStart,
}
impl<'de> Deserialize<'de> for UIMessagePart {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = serde_json::Value::deserialize(deserializer)?;
if let Some(t) = raw.get("type").and_then(|v| v.as_str()) {
if t.starts_with("data-") {
let data_part = DataPart {
data_type: t.strip_prefix("data-").unwrap_or(t).to_string(),
id: raw.get("id").and_then(|v| v.as_str()).map(String::from),
data: raw.get("data").cloned().unwrap_or(Value::Null),
};
return Ok(UIMessagePart::Data(data_part));
}
}
let tagged: Result<UIMessagePartTagged, _> =
serde_json::from_value(raw.clone()).map_err(serde::de::Error::custom);
match tagged {
Ok(tagged) => Ok(match tagged {
UIMessagePartTagged::Text(v) => UIMessagePart::Text(v),
UIMessagePartTagged::Reasoning(v) => UIMessagePart::Reasoning(v),
UIMessagePartTagged::File(v) => UIMessagePart::File(v),
UIMessagePartTagged::ToolCall(v) => UIMessagePart::ToolCall(v),
UIMessagePartTagged::ToolResult(v) => UIMessagePart::ToolResult(v),
UIMessagePartTagged::DynamicTool(v) => UIMessagePart::DynamicTool(v),
UIMessagePartTagged::SourceUrl(v) => UIMessagePart::SourceUrl(v),
UIMessagePartTagged::SourceDocument(v) => UIMessagePart::SourceDocument(v),
UIMessagePartTagged::StepStart => UIMessagePart::StepStart,
}),
Err(e) => Err(e),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum DynamicToolState {
InputStreaming {
#[serde(default)]
input: Option<Value>,
},
InputAvailable { input: Value },
OutputAvailable { input: Value, output: Value },
OutputError {
#[serde(default)]
input: Option<Value>,
#[serde(rename = "errorText")]
error_text: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaType {
Image,
Audio,
Video,
Document,
Other,
}
impl UIMessagePart {
pub fn as_text(&self) -> Option<&str> {
match self {
UIMessagePart::Text(p) => Some(&p.text),
_ => None,
}
}
pub fn is_text(&self) -> bool {
matches!(self, UIMessagePart::Text(_))
}
pub fn is_reasoning(&self) -> bool {
matches!(self, UIMessagePart::Reasoning(_))
}
pub fn is_tool_call(&self) -> bool {
matches!(
self,
UIMessagePart::ToolCall(_) | UIMessagePart::DynamicTool(_)
)
}
pub fn is_tool_result(&self) -> bool {
matches!(self, UIMessagePart::ToolResult(_))
}
pub fn is_data(&self) -> bool {
matches!(self, UIMessagePart::Data(_))
}
pub fn as_data(&self) -> Option<&DataPart> {
match self {
UIMessagePart::Data(p) => Some(p),
_ => None,
}
}
pub fn as_file(&self) -> Option<(&str, &str, Option<&String>)> {
match self {
UIMessagePart::File(p) => Some((&p.media_type, &p.url, p.filename.as_ref())),
_ => None,
}
}
pub fn state(&self) -> Option<PartState> {
match self {
UIMessagePart::Text(p) => p.state,
UIMessagePart::Reasoning(p) => p.state,
_ => None,
}
}
pub fn media_type_kind(&self) -> Option<MediaType> {
match self {
UIMessagePart::File(p) => {
if p.media_type.starts_with("image/") {
Some(MediaType::Image)
} else if p.media_type.starts_with("audio/") {
Some(MediaType::Audio)
} else if p.media_type.starts_with("video/") {
Some(MediaType::Video)
} else if matches!(
p.media_type.as_str(),
"application/pdf"
| "application/msword"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "text/plain"
| "text/csv"
| "application/json"
) {
Some(MediaType::Document)
} else {
Some(MediaType::Other)
}
}
UIMessagePart::SourceDocument(p) => {
if p.media_type.starts_with("image/") {
Some(MediaType::Image)
} else if p.media_type.starts_with("audio/") {
Some(MediaType::Audio)
} else if p.media_type.starts_with("video/") {
Some(MediaType::Video)
} else if matches!(
p.media_type.as_str(),
"application/pdf"
| "application/msword"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "text/plain"
| "text/csv"
| "application/json"
) {
Some(MediaType::Document)
} else {
Some(MediaType::Other)
}
}
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UIMessage {
pub id: String,
pub role: String,
#[serde(default)]
pub metadata: Option<Value>,
pub parts: Vec<UIMessagePart>,
}
impl UIMessage {
pub fn text(&self) -> String {
self.parts
.iter()
.filter_map(|p| p.as_text())
.collect::<Vec<_>>()
.join("")
}
pub fn is_user(&self) -> bool {
self.role == "user"
}
pub fn is_assistant(&self) -> bool {
self.role == "assistant"
}
pub fn is_system(&self) -> bool {
self.role == "system"
}
pub fn get_parts_by_type<F>(&self, predicate: F) -> Vec<&UIMessagePart>
where
F: Fn(&UIMessagePart) -> bool,
{
self.parts.iter().filter(|p| predicate(p)).collect()
}
pub fn has_streaming_content(&self) -> bool {
self.parts
.iter()
.any(|p| p.state() == Some(PartState::Streaming))
}
pub fn has_tool_calls(&self) -> bool {
self.parts.iter().any(|p| p.is_tool_call())
}
pub fn has_tool_results(&self) -> bool {
self.parts.iter().any(|p| p.is_tool_result())
}
pub fn has_files(&self) -> bool {
self.parts.iter().any(|p| p.as_file().is_some())
}
}