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            pending: VecDeque::new(),
190            delay_pending: false,
191            done: false,
192        };
193        Box::pin(stream::unfold(state, |mut state| async move {
194            loop {
195                if let Some((event, delay_after)) = state.pending.pop_front() {
196                    if state.delay_pending
197                        && let Some(delay) = state.delay
198                    {
199                        tokio::time::sleep(delay).await;
200                    }
201                    state.delay_pending = delay_after;
202                    return Some((event, state));
203                }
204                if state.done {
205                    return None;
206                }
207                match state.input.next().await {
208                    Some(event) => state.handle(event),
209                    None => {
210                        state.done = true;
211                        state.flush(None);
212                    }
213                }
214            }
215        }))
216    }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
220enum Kind {
221    Text,
222    Reasoning,
223}
224
225struct SmoothState {
226    input: EventStream,
227    delay: Option<Duration>,
228    chunking: Chunking,
229    buffer: String,
230    current: Option<(Kind, PartId)>,
231    pending: VecDeque<(StreamEvent, bool)>,
232    delay_pending: bool,
233    done: bool,
234}
235
236impl SmoothState {
237    fn handle(&mut self, event: StreamEvent) {
238        match event {
239            StreamEvent::TextDelta {
240                id,
241                text,
242                provider_metadata,
243            } => self.smooth(Kind::Text, id, text, provider_metadata),
244            StreamEvent::ReasoningDelta {
245                id,
246                text,
247                provider_metadata,
248            } => self.smooth(Kind::Reasoning, id, text, provider_metadata),
249            other => {
250                self.flush(None);
251                self.pending.push_back((other, false));
252            }
253        }
254    }
255
256    fn smooth(
257        &mut self,
258        kind: Kind,
259        id: PartId,
260        text: String,
261        provider_metadata: Option<ProviderMetadata>,
262    ) {
263        let same_part = self
264            .current
265            .as_ref()
266            .is_some_and(|(current_kind, current_id)| *current_kind == kind && *current_id == id);
267        if !self.buffer.is_empty() && (!same_part || provider_metadata.is_some()) {
268            self.flush(provider_metadata);
269        }
270        self.buffer.push_str(&text);
271        self.current = Some((kind, id));
272        while let Some(end) = self.chunking.detect(&self.buffer) {
273            let chunk: String = self.buffer.drain(..end).collect();
274            if let Some(event) = self.delta(chunk, None) {
275                self.pending.push_back((event, true));
276            }
277        }
278    }
279
280    fn flush(&mut self, provider_metadata: Option<ProviderMetadata>) {
281        if self.buffer.is_empty() {
282            return;
283        }
284        let text = std::mem::take(&mut self.buffer);
285        if let Some(event) = self.delta(text, provider_metadata) {
286            self.pending.push_back((event, false));
287        }
288    }
289
290    fn delta(
291        &self,
292        text: String,
293        provider_metadata: Option<ProviderMetadata>,
294    ) -> Option<StreamEvent> {
295        let (kind, id) = self.current.clone()?;
296        Some(match kind {
297            Kind::Text => StreamEvent::TextDelta {
298                id,
299                text,
300                provider_metadata,
301            },
302            Kind::Reasoning => StreamEvent::ReasoningDelta {
303                id,
304                text,
305                provider_metadata,
306            },
307        })
308    }
309}