aisdk/integrations/vercel_aisdk_ui.rs
1//! Integration with Vercel's AI SDK UI.
2
3#[cfg(feature = "language-model-request")]
4use futures::Stream;
5#[cfg(feature = "language-model-request")]
6use futures::StreamExt;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9#[cfg(feature = "language-model-request")]
10use uuid;
11
12#[cfg(feature = "language-model-request")]
13use crate::core::LanguageModelStreamChunkType;
14
15/// Vercel's ai-sdk UI message chunk types.
16/// These represent the JSON chunks sent over SSE to the frontend.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "type", rename_all = "kebab-case")]
19pub enum VercelUIStream {
20 /// Start of text message
21 #[serde(rename = "text-start")]
22 TextStart {
23 /// Message ID
24 id: String,
25 /// Optional provider metadata
26 #[serde(skip_serializing_if = "Option::is_none")]
27 provider_metadata: Option<Value>,
28 },
29 /// Delta of text message
30 #[serde(rename = "text-delta")]
31 TextDelta {
32 /// Message ID
33 id: String,
34 /// Text delta
35 delta: String,
36 /// Optional provider metadata
37 #[serde(skip_serializing_if = "Option::is_none")]
38 provider_metadata: Option<Value>,
39 },
40 /// End of text message
41 #[serde(rename = "text-end")]
42 TextEnd {
43 /// Message ID
44 id: String,
45 /// Optional provider metadata
46 #[serde(skip_serializing_if = "Option::is_none")]
47 provider_metadata: Option<Value>,
48 },
49 /// Start of reasoning message
50 #[serde(rename = "reasoning-start")]
51 ReasoningStart {
52 /// Message ID
53 id: String,
54 /// Optional provider metadata
55 #[serde(skip_serializing_if = "Option::is_none")]
56 provider_metadata: Option<Value>,
57 },
58 /// Delta of reasoning message
59 #[serde(rename = "reasoning-delta")]
60 ReasoningDelta {
61 /// Message ID
62 id: String,
63 /// Reasoning delta
64 delta: String,
65 /// Optional provider metadata
66 #[serde(skip_serializing_if = "Option::is_none")]
67 provider_metadata: Option<Value>,
68 },
69 /// End of reasoning message
70 #[serde(rename = "reasoning-end")]
71 ReasoningEnd {
72 /// Message ID
73 id: String,
74 /// Optional provider metadata
75 #[serde(skip_serializing_if = "Option::is_none")]
76 provider_metadata: Option<Value>,
77 },
78 /// Start of tool call
79 #[serde(rename = "tool-call-start")]
80 ToolCallStart {
81 /// Message ID
82 id: String,
83 /// Tool call ID
84 tool_call_id: String,
85 /// Tool name
86 tool_name: String,
87 /// Optional provider metadata
88 #[serde(skip_serializing_if = "Option::is_none")]
89 provider_metadata: Option<Value>,
90 },
91 /// Delta of tool call
92 #[serde(rename = "tool-call-delta")]
93 ToolCallDelta {
94 /// Message ID
95 id: String,
96 /// Tool call ID
97 tool_call_id: String,
98 /// Delta
99 delta: String,
100 /// Optional provider metadata
101 #[serde(skip_serializing_if = "Option::is_none")]
102 provider_metadata: Option<Value>,
103 },
104 /// End of tool call
105 #[serde(rename = "tool-call-end")]
106 ToolCallEnd {
107 /// Message ID
108 id: String,
109 /// Tool call ID
110 tool_call_id: String,
111 /// Result
112 result: Value,
113 /// Optional provider metadata
114 #[serde(skip_serializing_if = "Option::is_none")]
115 provider_metadata: Option<Value>,
116 },
117 /// Error chunk
118 #[serde(rename = "error")]
119 Error {
120 /// Error text
121 error_text: String,
122 },
123 /// Not supported chunk by aisdk.rs
124 #[serde(rename = "not-supported")]
125 NotSupported {
126 /// Error text
127 error_text: String,
128 },
129 // TODO: init - Add additional vercel UI chunks for data parts, sources, etc.
130 // as needed for full compatibility
131}
132
133#[derive(Default)]
134/// Configuration for vercel UI message stream.
135pub struct VercelUIStreamOptions {
136 /// Whether to send reasoning chunks
137 pub send_reasoning: bool,
138 /// Whether to send sources (TODO: uncomment when sources are supported)
139 //pub send_sources: bool,
140 /// Whether to send start chunks
141 pub send_start: bool,
142 /// Whether to send finish chunks
143 pub send_finish: bool,
144 /// Custom message ID generator
145 pub generate_message_id: Option<Box<VercelUIStreamIdGenerator>>,
146}
147
148/// Type alias for custom message ID generator functions.
149pub type VercelUIStreamIdGenerator = dyn Fn() -> String + Send + Sync;
150
151/// Builder for vercel UI message stream with fluent API, context, and build closure.
152pub struct VercelUIStreamBuilder<C, T> {
153 /// Context for the builder. eg. StreamTextResponse
154 pub context: C,
155
156 /// Configuration for the Vercel UI message stream.
157 pub options: VercelUIStreamOptions,
158
159 /// Build function that creates the final stream response. (implemented by the framework e.g. axum, actix)
160 /// where T is the type of the stream response.
161 build_fn: Box<dyn Fn(C, VercelUIStreamOptions) -> T + Send + Sync>,
162}
163
164impl<C, T> VercelUIStreamBuilder<C, T> {
165 /// Creates a new `VercelUIStreamBuilder` with the provided context and build function.
166 ///
167 /// Initializes the builder with default options, allowing further configuration via fluent methods
168 /// before building the final response.
169 ///
170 /// # Parameters
171 /// - `context`: The context object (e.g., `StreamTextResponse`) to be used in the build process.
172 /// - `build_fn`: A closure that takes the context and options to produce the final output. implemented by the framework e.g. axum, actix)
173 ///
174 /// # Returns
175 /// A new `VercelUIStreamBuilder` instance ready for configuration.
176 pub fn new<B>(context: C, build_fn: B) -> Self
177 where
178 B: Fn(C, VercelUIStreamOptions) -> T + Send + Sync + 'static,
179 {
180 Self {
181 context,
182 options: VercelUIStreamOptions::default(),
183 build_fn: Box::new(build_fn),
184 }
185 }
186
187 /// Enable sending reasoning chunks.
188 pub fn send_reasoning(mut self) -> Self {
189 self.options.send_reasoning = true;
190 self
191 }
192
193 /// Enable sending start chunks.
194 pub fn send_start(mut self) -> Self {
195 self.options.send_start = true;
196 self
197 }
198
199 /// Enable sending finish chunks.
200 pub fn send_finish(mut self) -> Self {
201 self.options.send_finish = true;
202 self
203 }
204
205 /// Set a custom message ID generator.
206 pub fn with_id_generator<G>(mut self, generator: G) -> Self
207 where
208 G: Fn() -> String + Send + Sync + 'static,
209 {
210 self.options.generate_message_id = Some(Box::new(generator));
211 self
212 }
213
214 /// Build the final response using the configured options.
215 pub fn build(self) -> T {
216 (self.build_fn)(self.context, self.options)
217 }
218}
219
220#[cfg(feature = "language-model-request")]
221impl crate::core::StreamTextResponse {
222 /// Converts this `StreamTextResponse` into a stream of `VercelUIStream` chunks.
223 ///
224 /// Transforms the underlying language model stream into Vercel-compatible UI chunks (e.g., text deltas,
225 /// reasoning deltas), enabling streaming of the language model output to a frontend using Vercel's ai-sdk-ui.
226 ///
227 /// # Parameters
228 /// - `options`: Configuration options controlling streaming behavior (e.g., enabling reasoning chunks).
229 ///
230 /// # Returns
231 /// A stream yielding `VercelUIStream` items or errors.
232 pub fn into_vercel_ui_stream(
233 self,
234 options: VercelUIStreamOptions,
235 ) -> impl Stream<Item = crate::Result<VercelUIStream>> {
236 let message_id = options
237 .generate_message_id
238 .as_ref()
239 .map(|f| f())
240 .unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple()));
241
242 self.stream.filter_map(move |chunk| {
243 let ui_chunk = match chunk {
244 LanguageModelStreamChunkType::Start if options.send_start => {
245 Some(VercelUIStream::TextStart {
246 id: message_id.clone(),
247 provider_metadata: None,
248 })
249 }
250
251 LanguageModelStreamChunkType::Text(delta) => Some(VercelUIStream::TextDelta {
252 id: message_id.clone(),
253 delta,
254 provider_metadata: None,
255 }),
256
257 LanguageModelStreamChunkType::Reasoning(delta) if options.send_reasoning => {
258 Some(VercelUIStream::ReasoningDelta {
259 id: message_id.clone(),
260 delta,
261 provider_metadata: None,
262 })
263 }
264
265 LanguageModelStreamChunkType::ToolCall(_json_str) => {
266 //TODO: handle tool call streams when they are supported
267 Some(VercelUIStream::ToolCallStart {
268 id: message_id.clone(),
269 tool_call_id: "unknown".to_string(),
270 tool_name: "unknown".to_string(),
271 provider_metadata: None,
272 })
273 }
274
275 LanguageModelStreamChunkType::End(_) if options.send_finish => {
276 Some(VercelUIStream::TextEnd {
277 id: message_id.clone(),
278 provider_metadata: None,
279 })
280 }
281
282 LanguageModelStreamChunkType::Failed(error)
283 | LanguageModelStreamChunkType::Incomplete(error) => {
284 Some(VercelUIStream::Error { error_text: error })
285 }
286
287 // Skip and continue
288 LanguageModelStreamChunkType::NotSupported(_) => None,
289
290 //TODO: handle other vercel chunk types
291 // Skip and continue
292 _ => None,
293 };
294
295 futures::future::ready(ui_chunk.map(Ok))
296 })
297 }
298}
299
300/// Represents a part of a UI message from Vercel's useChat hook.
301#[derive(Deserialize, Debug)]
302pub struct VercelUIMessagePart {
303 /// The text content of the part.
304 pub text: String,
305 /// The type of the part (e.g., "text").
306 #[serde(rename = "type")]
307 pub part_type: String,
308}
309
310/// Represents a UI message from Vercel's useChat hook.
311#[derive(Deserialize, Debug)]
312pub struct VercelUIMessage {
313 /// Unique identifier for the message.
314 pub id: String,
315 /// Role of the message sender ("user", "assistant", "system").
316 pub role: String,
317 /// Array of message parts (e.g., text content).
318 pub parts: Vec<VercelUIMessagePart>,
319}
320
321/// Represents a request body from Vercel's useChat hook.
322#[derive(Deserialize, Debug)]
323pub struct VercelUIRequest {
324 /// Unique identifier for the chat session.
325 pub id: String,
326 /// Array of UI messages from the frontend.
327 pub messages: Vec<VercelUIMessage>,
328 /// Trigger indicating the action (e.g., "submit-message").
329 pub trigger: String,
330}
331
332impl crate::core::Message {
333 /// Converts a slice of Vercel UI messages to the `aisdk::core::Message` format.
334 ///
335 /// This function extracts text content from UI message parts and maps roles to the
336 /// corresponding `Message` variants. Currently only "text" parts are supported; other part types
337 /// (e.g., files, tools) are ignored.
338 ///
339 /// # Parameters
340 /// - `ui_messages`: A slice of `VercelUIMessage` to convert.
341 ///
342 /// # Returns
343 /// A vector of `Message` instances.
344 ///
345 /// # Notes
346 /// - Joins multiple text parts into a single string.
347 /// - TODO: Add support for file parts (e.g., map to URLs in content).
348 /// - TODO: Add support for tool parts (e.g., map to `Tool` messages).
349 pub fn from_vercel_ui_message(
350 ui_messages: &[VercelUIMessage],
351 ) -> crate::core::messages::Messages {
352 ui_messages
353 .iter()
354 .filter_map(|msg| {
355 let content = msg
356 .parts
357 .iter()
358 .filter(|part| part.part_type == "text")
359 .map(|part| part.text.clone())
360 .collect::<Vec<_>>()
361 .join("");
362
363 match msg.role.as_str() {
364 "system" => Some(crate::core::messages::Message::System(content.into())),
365 "user" => Some(crate::core::messages::Message::User(content.into())),
366 "assistant" => Some(crate::core::messages::Message::Assistant(content.into())),
367 _ => None,
368 }
369 })
370 .collect()
371 }
372}
373
374/// Converts a VercelUIRequest into native aisdk::core::messages::Message
375impl From<VercelUIRequest> for Vec<crate::core::messages::Message> {
376 fn from(request: VercelUIRequest) -> Self {
377 crate::core::messages::Message::from_vercel_ui_message(&request.messages)
378 }
379}