Skip to main content

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}