Skip to main content

ferrin_core/stream_text/transforms/
smooth.rs

1//! `smooth_stream`: buffers text and reasoning deltas and re-emits them in
2//! word- or line-sized chunks with a small delay, producing an even typing
3//! rhythm regardless of provider chunking.
4//!
5//! Derived from the Vercel AI SDK (Apache-2.0, Copyright 2023 Vercel, Inc.),
6//! translated from TypeScript to Rust and modified; see `NOTICE`.
7
8use std::collections::VecDeque;
9use std::fmt;
10use std::sync::Arc;
11use std::time::Duration;
12
13use ferrin_spec::PartId;
14use ferrin_spec::ProviderMetadata;
15use futures_util::StreamExt;
16use futures_util::stream;
17use regex::Regex;
18use unicode_segmentation::UnicodeSegmentation;
19
20use super::StreamTransform;
21use super::TransformContext;
22use crate::error::Error;
23use crate::stream_text::EventStream;
24use crate::stream_text::StreamErrorInfo;
25use crate::stream_text::StreamEvent;
26
27/// A function returning the byte length of the next chunk in a buffer, or
28/// `None` when the buffer holds no complete chunk yet.
29pub type ChunkDetector = Arc<dyn Fn(&str) -> Option<usize> + Send + Sync>;
30
31/// How buffered text is split into chunks.
32#[derive(Clone, Default)]
33#[non_exhaustive]
34pub enum Chunking {
35    /// A run of non-whitespace followed by whitespace (`\S+\s+`).
36    #[default]
37    Word,
38    /// Up to and including a run of newlines (`\n+`).
39    Line,
40    /// The first match of a regular expression; the chunk spans from the
41    /// buffer start to the end of the match. Empty matches fail the stream.
42    Regex(Regex),
43    /// The first Unicode word-boundary segment (UAX #29), emitted immediately.
44    /// Whitespace and punctuation are separate segments; locale dictionaries
45    /// and ICU tailoring require an application-supplied detector.
46    UnicodeWords,
47    /// A custom detector. Lengths of zero, beyond the buffer or inside a
48    /// character fail the smoothing stream.
49    Detector(ChunkDetector),
50}
51
52impl fmt::Debug for Chunking {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::Word => f.write_str("Word"),
56            Self::Line => f.write_str("Line"),
57            Self::Regex(regex) => f.debug_tuple("Regex").field(&regex.as_str()).finish(),
58            Self::UnicodeWords => f.write_str("UnicodeWords"),
59            Self::Detector(_) => f.write_str("Detector(..)"),
60        }
61    }
62}
63
64impl Chunking {
65    /// Custom detector.
66    #[must_use]
67    pub fn detector(f: impl Fn(&str) -> Option<usize> + Send + Sync + 'static) -> Self {
68        Self::Detector(Arc::new(f))
69    }
70
71    /// Byte length of the next chunk in `buffer`, if complete.
72    #[must_use]
73    pub fn detect(&self, buffer: &str) -> Option<usize> {
74        self.detect_checked(buffer).ok().flatten()
75    }
76
77    fn detect_checked(&self, buffer: &str) -> Result<Option<usize>, Error> {
78        let end = match self {
79            Self::Word => word_chunk(buffer),
80            Self::Line => line_chunk(buffer),
81            Self::Regex(regex) => match regex.find(buffer) {
82                Some(found) if found.is_empty() => {
83                    return Err(Error::invalid_argument(
84                        "chunking",
85                        "chunking regex must not match an empty string",
86                    ));
87                }
88                found => found.map(|found| found.end()),
89            },
90            Self::UnicodeWords => unicode_word_chunk(buffer),
91            Self::Detector(detector) => detector(buffer),
92        };
93        if let Some(end) = end
94            && (end == 0 || end > buffer.len() || !buffer.is_char_boundary(end))
95        {
96            return Err(Error::invalid_argument(
97                "chunking",
98                "chunk detector must return a nonempty UTF-8 prefix length",
99            ));
100        }
101        Ok(end)
102    }
103}
104
105/// End of the first `\S+\s+` match, measured from the buffer start.
106fn word_chunk(buffer: &str) -> Option<usize> {
107    let mut seen_word = false;
108    let mut in_trailing_space = false;
109    for (index, ch) in buffer.char_indices() {
110        if !seen_word {
111            if !ch.is_whitespace() {
112                seen_word = true;
113            }
114        } else if ch.is_whitespace() {
115            in_trailing_space = true;
116        } else if in_trailing_space {
117            return Some(index);
118        }
119    }
120    in_trailing_space.then_some(buffer.len())
121}
122
123/// End of the first `\n+` match, measured from the buffer start.
124fn line_chunk(buffer: &str) -> Option<usize> {
125    let start = buffer.find('\n')?;
126    let rest = &buffer[start..];
127    let run = rest
128        .char_indices()
129        .find(|(_, ch)| *ch != '\n')
130        .map_or(rest.len(), |(index, _)| index);
131    Some(start + run)
132}
133
134/// The first segment, matching the reference Intl.Segmenter adapter's
135/// emission timing without assuming locale-specific dictionary support.
136fn unicode_word_chunk(buffer: &str) -> Option<usize> {
137    buffer.split_word_bounds().next().map(str::len)
138}
139
140/// Configuration of [`smooth_stream`].
141#[derive(Debug, Clone)]
142pub struct SmoothStreamConfig {
143    /// Pause after each emitted chunk (default 10 ms; `None` for no pause).
144    pub delay: Option<Duration>,
145    /// Chunking strategy (default [`Chunking::Word`]).
146    pub chunking: Chunking,
147}
148
149impl Default for SmoothStreamConfig {
150    fn default() -> Self {
151        Self {
152            delay: Some(Duration::from_millis(10)),
153            chunking: Chunking::Word,
154        }
155    }
156}
157
158impl SmoothStreamConfig {
159    /// The default configuration.
160    #[must_use]
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Sets the pause after each chunk.
166    #[must_use]
167    pub fn delay(mut self, delay: Option<Duration>) -> Self {
168        self.delay = delay;
169        self
170    }
171
172    /// Sets the chunking strategy.
173    #[must_use]
174    pub fn chunking(mut self, chunking: Chunking) -> Self {
175        self.chunking = chunking;
176        self
177    }
178}
179
180/// Creates the smoothing transform.
181#[must_use]
182pub fn smooth_stream(config: SmoothStreamConfig) -> SmoothStream {
183    SmoothStream { config }
184}
185
186/// The smoothing transform; see [`smooth_stream`].
187#[derive(Debug, Clone)]
188pub struct SmoothStream {
189    config: SmoothStreamConfig,
190}
191
192impl StreamTransform for SmoothStream {
193    fn apply(&self, input: EventStream, ctx: TransformContext) -> EventStream {
194        let state = SmoothState {
195            input,
196            delay: self.config.delay,
197            chunking: self.config.chunking.clone(),
198            buffer: String::new(),
199            current: None,
200            provider_metadata: None,
201            pending: VecDeque::new(),
202            delay_pending: false,
203            done: false,
204            context: ctx,
205        };
206        Box::pin(stream::unfold(state, |mut state| async move {
207            loop {
208                if let Some((event, delay_after)) = state.pending.pop_front() {
209                    if state.delay_pending
210                        && let Some(delay) = state.delay
211                    {
212                        let cancelled = tokio::select! {
213                            biased;
214                            () = state.context.cancellation().cancelled() => true,
215                            () = async {
216                                match tokio::time::Instant::now().checked_add(delay) {
217                                    Some(deadline) => tokio::time::sleep_until(deadline).await,
218                                    None => std::future::pending().await,
219                                }
220                            } => false,
221                        };
222                        if cancelled {
223                            state.pending.clear();
224                            state.buffer.clear();
225                            state.delay_pending = false;
226                            continue;
227                        }
228                    }
229                    state.delay_pending = delay_after;
230                    return Some((event, state));
231                }
232                if state.done {
233                    return None;
234                }
235                match state.input.next().await {
236                    Some(event) => {
237                        if let Err(error) = state.handle(event) {
238                            let info = StreamErrorInfo::from_error(&error);
239                            state.context.fail(error);
240                            state.pending.clear();
241                            state.buffer.clear();
242                            state.delay_pending = false;
243                            state.done = true;
244                            state
245                                .pending
246                                .push_back((StreamEvent::Error { error: info }, false));
247                        }
248                    }
249                    None => {
250                        state.done = true;
251                        state.flush();
252                    }
253                }
254            }
255        }))
256    }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
260enum Kind {
261    Text,
262    Reasoning,
263}
264
265struct SmoothState {
266    input: EventStream,
267    delay: Option<Duration>,
268    chunking: Chunking,
269    buffer: String,
270    current: Option<(Kind, PartId)>,
271    provider_metadata: Option<ProviderMetadata>,
272    pending: VecDeque<(StreamEvent, bool)>,
273    delay_pending: bool,
274    done: bool,
275    context: TransformContext,
276}
277
278impl SmoothState {
279    fn handle(&mut self, event: StreamEvent) -> Result<(), Error> {
280        match event {
281            StreamEvent::TextDelta {
282                id,
283                text,
284                provider_metadata,
285            } => self.smooth(Kind::Text, id, text, provider_metadata),
286            StreamEvent::ReasoningDelta {
287                id,
288                text,
289                provider_metadata,
290            } => self.smooth(Kind::Reasoning, id, text, provider_metadata),
291            other => {
292                self.flush();
293                self.pending.push_back((other, false));
294                Ok(())
295            }
296        }
297    }
298
299    fn smooth(
300        &mut self,
301        kind: Kind,
302        id: PartId,
303        text: String,
304        provider_metadata: Option<ProviderMetadata>,
305    ) -> Result<(), Error> {
306        let same_part = self
307            .current
308            .as_ref()
309            .is_some_and(|(current_kind, current_id)| *current_kind == kind && *current_id == id);
310        if !same_part {
311            self.flush();
312        }
313        self.buffer.push_str(&text);
314        self.current = Some((kind, id));
315        if provider_metadata.is_some() {
316            self.provider_metadata = provider_metadata;
317        }
318        while let Some(end) = self.chunking.detect_checked(&self.buffer)? {
319            let chunk: String = self.buffer.drain(..end).collect();
320            if let Some(event) = self.delta(chunk, None) {
321                self.pending.push_back((event, true));
322            }
323        }
324        Ok(())
325    }
326
327    fn flush(&mut self) {
328        if self.buffer.is_empty() && self.provider_metadata.is_none() {
329            return;
330        }
331        let text = std::mem::take(&mut self.buffer);
332        let metadata = self.provider_metadata.take();
333        if let Some(event) = self.delta(text, metadata) {
334            self.pending.push_back((event, false));
335        }
336    }
337
338    fn delta(
339        &self,
340        text: String,
341        provider_metadata: Option<ProviderMetadata>,
342    ) -> Option<StreamEvent> {
343        let (kind, id) = self.current.clone()?;
344        Some(match kind {
345            Kind::Text => StreamEvent::TextDelta {
346                id,
347                text,
348                provider_metadata,
349            },
350            Kind::Reasoning => StreamEvent::ReasoningDelta {
351                id,
352                text,
353                provider_metadata,
354            },
355        })
356    }
357}