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
5use std::collections::VecDeque;
6use std::fmt;
7use std::sync::Arc;
8use std::time::Duration;
9
10use ferrin_spec::PartId;
11use ferrin_spec::ProviderMetadata;
12use futures_util::StreamExt;
13use futures_util::stream;
14use regex::Regex;
15use unicode_segmentation::UnicodeSegmentation;
16
17use super::StreamTransform;
18use super::TransformContext;
19use crate::stream_text::EventStream;
20use crate::stream_text::StreamEvent;
21
22/// A function returning the byte length of the next chunk in a buffer, or
23/// `None` when the buffer holds no complete chunk yet.
24pub type ChunkDetector = Arc<dyn Fn(&str) -> Option<usize> + Send + Sync>;
25
26/// How buffered text is split into chunks.
27#[derive(Clone, Default)]
28#[non_exhaustive]
29pub enum Chunking {
30    /// A run of non-whitespace followed by whitespace (`\S+\s+`).
31    #[default]
32    Word,
33    /// Up to and including a run of newlines (`\n+`).
34    Line,
35    /// The first match of a regular expression; the chunk spans from the
36    /// buffer start to the end of the match. Empty matches are ignored.
37    Regex(Regex),
38    /// Unicode word boundaries (UAX #29): one word plus the whitespace that
39    /// follows it. Splits scripts written without spaces character by
40    /// character.
41    UnicodeWords,
42    /// A custom detector. Lengths of zero, beyond the buffer or inside a
43    /// character are ignored.
44    Detector(ChunkDetector),
45}
46
47impl fmt::Debug for Chunking {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::Word => f.write_str("Word"),
51            Self::Line => f.write_str("Line"),
52            Self::Regex(regex) => f.debug_tuple("Regex").field(&regex.as_str()).finish(),
53            Self::UnicodeWords => f.write_str("UnicodeWords"),
54            Self::Detector(_) => f.write_str("Detector(..)"),
55        }
56    }
57}
58
59impl Chunking {
60    /// Custom detector.
61    #[must_use]
62    pub fn detector(f: impl Fn(&str) -> Option<usize> + Send + Sync + 'static) -> Self {
63        Self::Detector(Arc::new(f))
64    }
65
66    /// Byte length of the next chunk in `buffer`, if complete.
67    #[must_use]
68    pub fn detect(&self, buffer: &str) -> Option<usize> {
69        let end = match self {
70            Self::Word => word_chunk(buffer),
71            Self::Line => line_chunk(buffer),
72            Self::Regex(regex) => regex.find(buffer).map(|found| found.end()),
73            Self::UnicodeWords => unicode_word_chunk(buffer),
74            Self::Detector(detector) => detector(buffer),
75        }?;
76        (end > 0 && end <= buffer.len() && buffer.is_char_boundary(end)).then_some(end)
77    }
78}
79
80/// End of the first `\S+\s+` match, measured from the buffer start.
81fn word_chunk(buffer: &str) -> Option<usize> {
82    let mut seen_word = false;
83    let mut in_trailing_space = false;
84    for (index, ch) in buffer.char_indices() {
85        if !seen_word {
86            if !ch.is_whitespace() {
87                seen_word = true;
88            }
89        } else if ch.is_whitespace() {
90            in_trailing_space = true;
91        } else if in_trailing_space {
92            return Some(index);
93        }
94    }
95    in_trailing_space.then_some(buffer.len())
96}
97
98/// End of the first `\n+` match, measured from the buffer start.
99fn line_chunk(buffer: &str) -> Option<usize> {
100    let start = buffer.find('\n')?;
101    let rest = &buffer[start..];
102    let run = rest
103        .char_indices()
104        .find(|(_, ch)| *ch != '\n')
105        .map_or(rest.len(), |(index, _)| index);
106    Some(start + run)
107}
108
109/// First word (after leading whitespace) plus the whitespace following it,
110/// once the next word has started or the buffer ends in whitespace.
111fn unicode_word_chunk(buffer: &str) -> Option<usize> {
112    let mut seen_word = false;
113    let mut in_trailing_space = false;
114    for (index, segment) in buffer.split_word_bound_indices() {
115        let is_space = segment.chars().all(char::is_whitespace);
116        if !seen_word {
117            if !is_space {
118                seen_word = true;
119            }
120        } else if is_space {
121            in_trailing_space = true;
122        } else {
123            return Some(index);
124        }
125    }
126    in_trailing_space.then_some(buffer.len())
127}
128
129/// Configuration of [`smooth_stream`].
130#[derive(Debug, Clone)]
131pub struct SmoothStreamConfig {
132    /// Pause after each emitted chunk (default 10 ms; `None` for no pause).
133    pub delay: Option<Duration>,
134    /// Chunking strategy (default [`Chunking::Word`]).
135    pub chunking: Chunking,
136}
137
138impl Default for SmoothStreamConfig {
139    fn default() -> Self {
140        Self {
141            delay: Some(Duration::from_millis(10)),
142            chunking: Chunking::Word,
143        }
144    }
145}
146
147impl SmoothStreamConfig {
148    /// The default configuration.
149    #[must_use]
150    pub fn new() -> Self {
151        Self::default()
152    }
153
154    /// Sets the pause after each chunk.
155    #[must_use]
156    pub fn delay(mut self, delay: Option<Duration>) -> Self {
157        self.delay = delay;
158        self
159    }
160
161    /// Sets the chunking strategy.
162    #[must_use]
163    pub fn chunking(mut self, chunking: Chunking) -> Self {
164        self.chunking = chunking;
165        self
166    }
167}
168
169/// Creates the smoothing transform.
170#[must_use]
171pub fn smooth_stream(config: SmoothStreamConfig) -> SmoothStream {
172    SmoothStream { config }
173}
174
175/// The smoothing transform; see [`smooth_stream`].
176#[derive(Debug, Clone)]
177pub struct SmoothStream {
178    config: SmoothStreamConfig,
179}
180
181impl StreamTransform for SmoothStream {
182    fn apply(&self, input: EventStream, _ctx: TransformContext) -> EventStream {
183        let state = SmoothState {
184            input,
185            delay: self.config.delay,
186            chunking: self.config.chunking.clone(),
187            buffer: String::new(),
188            current: None,
189            provider_metadata: None,
190            pending: VecDeque::new(),
191            delay_pending: false,
192            done: false,
193        };
194        Box::pin(stream::unfold(state, |mut state| async move {
195            loop {
196                if let Some((event, delay_after)) = state.pending.pop_front() {
197                    if state.delay_pending
198                        && let Some(delay) = state.delay
199                    {
200                        tokio::time::sleep(delay).await;
201                    }
202                    state.delay_pending = delay_after;
203                    return Some((event, state));
204                }
205                if state.done {
206                    return None;
207                }
208                match state.input.next().await {
209                    Some(event) => state.handle(event),
210                    None => {
211                        state.done = true;
212                        state.flush();
213                    }
214                }
215            }
216        }))
217    }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
221enum Kind {
222    Text,
223    Reasoning,
224}
225
226struct SmoothState {
227    input: EventStream,
228    delay: Option<Duration>,
229    chunking: Chunking,
230    buffer: String,
231    current: Option<(Kind, PartId)>,
232    provider_metadata: Option<ProviderMetadata>,
233    pending: VecDeque<(StreamEvent, bool)>,
234    delay_pending: bool,
235    done: bool,
236}
237
238impl SmoothState {
239    fn handle(&mut self, event: StreamEvent) {
240        match event {
241            StreamEvent::TextDelta {
242                id,
243                text,
244                provider_metadata,
245            } => self.smooth(Kind::Text, id, text, provider_metadata),
246            StreamEvent::ReasoningDelta {
247                id,
248                text,
249                provider_metadata,
250            } => self.smooth(Kind::Reasoning, id, text, provider_metadata),
251            other => {
252                self.flush();
253                self.pending.push_back((other, false));
254            }
255        }
256    }
257
258    fn smooth(
259        &mut self,
260        kind: Kind,
261        id: PartId,
262        text: String,
263        provider_metadata: Option<ProviderMetadata>,
264    ) {
265        let same_part = self
266            .current
267            .as_ref()
268            .is_some_and(|(current_kind, current_id)| *current_kind == kind && *current_id == id);
269        if !same_part || provider_metadata.is_some() {
270            self.flush();
271            self.provider_metadata = provider_metadata;
272        }
273        self.buffer.push_str(&text);
274        self.current = Some((kind, id));
275        if text.is_empty() && self.provider_metadata.is_some() {
276            self.flush();
277        }
278        while let Some(end) = self.chunking.detect(&self.buffer) {
279            let chunk: String = self.buffer.drain(..end).collect();
280            let metadata = self.provider_metadata.take();
281            if let Some(event) = self.delta(chunk, metadata) {
282                self.pending.push_back((event, true));
283            }
284        }
285    }
286
287    fn flush(&mut self) {
288        if self.buffer.is_empty() && self.provider_metadata.is_none() {
289            return;
290        }
291        let text = std::mem::take(&mut self.buffer);
292        let metadata = self.provider_metadata.take();
293        if let Some(event) = self.delta(text, metadata) {
294            self.pending.push_back((event, false));
295        }
296    }
297
298    fn delta(
299        &self,
300        text: String,
301        provider_metadata: Option<ProviderMetadata>,
302    ) -> Option<StreamEvent> {
303        let (kind, id) = self.current.clone()?;
304        Some(match kind {
305            Kind::Text => StreamEvent::TextDelta {
306                id,
307                text,
308                provider_metadata,
309            },
310            Kind::Reasoning => StreamEvent::ReasoningDelta {
311                id,
312                text,
313                provider_metadata,
314            },
315        })
316    }
317}