ferrin_core/stream_text/transforms/
smooth.rs1use 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
22pub type ChunkDetector = Arc<dyn Fn(&str) -> Option<usize> + Send + Sync>;
25
26#[derive(Clone, Default)]
28#[non_exhaustive]
29pub enum Chunking {
30 #[default]
32 Word,
33 Line,
35 Regex(Regex),
38 UnicodeWords,
42 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(®ex.as_str()).finish(),
53 Self::UnicodeWords => f.write_str("UnicodeWords"),
54 Self::Detector(_) => f.write_str("Detector(..)"),
55 }
56 }
57}
58
59impl Chunking {
60 #[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 #[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
80fn 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
98fn 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
109fn 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#[derive(Debug, Clone)]
131pub struct SmoothStreamConfig {
132 pub delay: Option<Duration>,
134 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 #[must_use]
150 pub fn new() -> Self {
151 Self::default()
152 }
153
154 #[must_use]
156 pub fn delay(mut self, delay: Option<Duration>) -> Self {
157 self.delay = delay;
158 self
159 }
160
161 #[must_use]
163 pub fn chunking(mut self, chunking: Chunking) -> Self {
164 self.chunking = chunking;
165 self
166 }
167}
168
169#[must_use]
171pub fn smooth_stream(config: SmoothStreamConfig) -> SmoothStream {
172 SmoothStream { config }
173}
174
175#[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}