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 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}