ferrin_core/stream_text/transforms/
smooth.rs1use 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
27pub type ChunkDetector = Arc<dyn Fn(&str) -> Option<usize> + Send + Sync>;
30
31#[derive(Clone, Default)]
33#[non_exhaustive]
34pub enum Chunking {
35 #[default]
37 Word,
38 Line,
40 Regex(Regex),
43 UnicodeWords,
47 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(®ex.as_str()).finish(),
58 Self::UnicodeWords => f.write_str("UnicodeWords"),
59 Self::Detector(_) => f.write_str("Detector(..)"),
60 }
61 }
62}
63
64impl Chunking {
65 #[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 #[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
105fn 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
123fn 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
134fn unicode_word_chunk(buffer: &str) -> Option<usize> {
137 buffer.split_word_bounds().next().map(str::len)
138}
139
140#[derive(Debug, Clone)]
142pub struct SmoothStreamConfig {
143 pub delay: Option<Duration>,
145 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 #[must_use]
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 #[must_use]
167 pub fn delay(mut self, delay: Option<Duration>) -> Self {
168 self.delay = delay;
169 self
170 }
171
172 #[must_use]
174 pub fn chunking(mut self, chunking: Chunking) -> Self {
175 self.chunking = chunking;
176 self
177 }
178}
179
180#[must_use]
182pub fn smooth_stream(config: SmoothStreamConfig) -> SmoothStream {
183 SmoothStream { config }
184}
185
186#[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}