chore: initial release
This commit is contained in:
158
lib/core/utils/reasoning_parser.dart
Normal file
158
lib/core/utils/reasoning_parser.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Utility class for parsing and extracting reasoning/thinking content from messages
|
||||
class ReasoningParser {
|
||||
/// Parses a message and extracts reasoning content
|
||||
static ReasoningContent? parseReasoningContent(String content) {
|
||||
if (content.isEmpty) return null;
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'DEBUG: Parsing content: ${content.substring(0, content.length > 200 ? 200 : content.length)}...',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if content contains reasoning
|
||||
if (!content.contains('<details type="reasoning"')) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: No reasoning content found in text');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: Found reasoning tags in content');
|
||||
}
|
||||
|
||||
// Match the <details> tag with type="reasoning"
|
||||
final reasoningRegex = RegExp(
|
||||
r'<details\s+type="reasoning"\s+done="(true|false)"\s+duration="(\d+)"[^>]*>\s*<summary>([^<]*)</summary>\s*(.*?)\s*</details>',
|
||||
multiLine: true,
|
||||
dotAll: true,
|
||||
);
|
||||
|
||||
final match = reasoningRegex.firstMatch(content);
|
||||
if (match == null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: Regex did not match - checking pattern');
|
||||
}
|
||||
// Try a more flexible regex to debug
|
||||
final flexRegex = RegExp(
|
||||
r'<details[^>]*type="reasoning"[^>]*>.*?</details>',
|
||||
multiLine: true,
|
||||
dotAll: true,
|
||||
);
|
||||
final flexMatch = flexRegex.firstMatch(content);
|
||||
if (flexMatch != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: Found flexible match: ${flexMatch.group(0)}');
|
||||
}
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: No flexible match found either');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: Regex matched successfully');
|
||||
}
|
||||
|
||||
final isDone = match.group(1) == 'true';
|
||||
final duration = int.tryParse(match.group(2) ?? '0') ?? 0;
|
||||
final summary = match.group(3)?.trim() ?? '';
|
||||
final reasoning = match.group(4)?.trim() ?? '';
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'DEBUG: Parsed values - isDone: $isDone, duration: $duration, summary: $summary',
|
||||
);
|
||||
debugPrint('DEBUG: Reasoning content length: ${reasoning.length}');
|
||||
}
|
||||
|
||||
// Remove the reasoning section from the main content
|
||||
final mainContent = content.replaceAll(reasoningRegex, '').trim();
|
||||
|
||||
return ReasoningContent(
|
||||
reasoning: reasoning,
|
||||
summary: summary,
|
||||
duration: duration,
|
||||
isDone: isDone,
|
||||
mainContent: mainContent,
|
||||
originalContent: content,
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks if a message contains reasoning content
|
||||
static bool hasReasoningContent(String content) {
|
||||
return content.contains('<details type="reasoning"');
|
||||
}
|
||||
|
||||
/// Formats the duration for display
|
||||
static String formatDuration(int seconds) {
|
||||
if (seconds == 0) return 'instant';
|
||||
if (seconds < 60) return '$seconds second${seconds == 1 ? '' : 's'}';
|
||||
|
||||
final minutes = seconds ~/ 60;
|
||||
final remainingSeconds = seconds % 60;
|
||||
|
||||
if (remainingSeconds == 0) {
|
||||
return '$minutes minute${minutes == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
return '$minutes min ${remainingSeconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
/// Model class for reasoning content
|
||||
class ReasoningContent {
|
||||
final String reasoning;
|
||||
final String summary;
|
||||
final int duration;
|
||||
final bool isDone;
|
||||
final String mainContent;
|
||||
final String originalContent;
|
||||
|
||||
const ReasoningContent({
|
||||
required this.reasoning,
|
||||
required this.summary,
|
||||
required this.duration,
|
||||
required this.isDone,
|
||||
required this.mainContent,
|
||||
required this.originalContent,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ReasoningContent &&
|
||||
runtimeType == other.runtimeType &&
|
||||
reasoning == other.reasoning &&
|
||||
summary == other.summary &&
|
||||
duration == other.duration &&
|
||||
isDone == other.isDone &&
|
||||
mainContent == other.mainContent &&
|
||||
originalContent == other.originalContent;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
reasoning.hashCode ^
|
||||
summary.hashCode ^
|
||||
duration.hashCode ^
|
||||
isDone.hashCode ^
|
||||
mainContent.hashCode ^
|
||||
originalContent.hashCode;
|
||||
|
||||
String get formattedDuration => ReasoningParser.formatDuration(duration);
|
||||
|
||||
/// Gets the cleaned reasoning text (removes leading '>')
|
||||
String get cleanedReasoning {
|
||||
// Split by lines and clean each line
|
||||
return reasoning
|
||||
.split('\n')
|
||||
.map((line) => line.startsWith('>') ? line.substring(1).trim() : line)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
105
lib/core/utils/stream_chunker.dart
Normal file
105
lib/core/utils/stream_chunker.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
/// Utility class to chunk large text streams into smaller pieces for smoother UI updates
|
||||
class StreamChunker {
|
||||
/// Splits large text chunks into smaller pieces for more fluid streaming
|
||||
/// Similar to OpenWebUI's approach for better UX
|
||||
static Stream<String> chunkStream(
|
||||
Stream<String> inputStream, {
|
||||
bool enableChunking = true,
|
||||
int minChunkSize = 16, // increase to reduce UI thrash
|
||||
int maxChunkLength = 12, // larger chunks improve performance
|
||||
Duration delayBetweenChunks = const Duration(milliseconds: 8),
|
||||
}) async* {
|
||||
final random = Random();
|
||||
|
||||
await for (final chunk in inputStream) {
|
||||
if (!enableChunking || chunk.length < minChunkSize) {
|
||||
// Small chunks pass through as-is
|
||||
yield chunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split large chunks into smaller pieces
|
||||
String remaining = chunk;
|
||||
while (remaining.isNotEmpty) {
|
||||
// Random chunk size between 4 and maxChunkLength characters
|
||||
// But prefer to break at word boundaries when possible
|
||||
int chunkSize = min(
|
||||
max(4, random.nextInt(maxChunkLength) + 1),
|
||||
remaining.length,
|
||||
);
|
||||
|
||||
// Try to find a word boundary (space) within the chunk size
|
||||
if (chunkSize < remaining.length) {
|
||||
final nextSpace = remaining.indexOf(' ', chunkSize);
|
||||
if (nextSpace != -1 && nextSpace <= chunkSize + 2) {
|
||||
// Include the space in the chunk for natural word breaks
|
||||
chunkSize = nextSpace + 1;
|
||||
}
|
||||
}
|
||||
|
||||
final pieceToYield = remaining.substring(0, chunkSize);
|
||||
yield pieceToYield;
|
||||
remaining = remaining.substring(chunkSize);
|
||||
|
||||
// Add small delay between chunks for fluid animation
|
||||
// Skip delay for last piece to avoid unnecessary wait
|
||||
if (remaining.isNotEmpty && delayBetweenChunks.inMicroseconds > 0) {
|
||||
await Future.delayed(delayBetweenChunks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alternative method that chunks by words instead of characters
|
||||
static Stream<String> chunkByWords(
|
||||
Stream<String> inputStream, {
|
||||
bool enableChunking = true,
|
||||
int wordsPerChunk = 1,
|
||||
Duration delayBetweenWords = const Duration(milliseconds: 50),
|
||||
}) async* {
|
||||
if (!enableChunking) {
|
||||
yield* inputStream;
|
||||
return;
|
||||
}
|
||||
|
||||
String buffer = '';
|
||||
|
||||
await for (final chunk in inputStream) {
|
||||
buffer += chunk;
|
||||
|
||||
// Split by spaces and yield word by word
|
||||
final words = buffer.split(' ');
|
||||
|
||||
// Keep the last "word" in buffer as it might be incomplete
|
||||
if (words.length > 1) {
|
||||
buffer = words.last;
|
||||
final completeWords = words.sublist(0, words.length - 1);
|
||||
|
||||
for (int i = 0; i < completeWords.length; i++) {
|
||||
final word = completeWords[i];
|
||||
// Add space back except for the first word if buffer was empty
|
||||
final wordWithSpace =
|
||||
(i < completeWords.length - 1 || buffer.isNotEmpty)
|
||||
? '$word '
|
||||
: word;
|
||||
|
||||
yield wordWithSpace;
|
||||
|
||||
// Add delay between words for smooth streaming effect
|
||||
if (i < completeWords.length - 1 &&
|
||||
delayBetweenWords.inMicroseconds > 0) {
|
||||
await Future.delayed(delayBetweenWords);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Yield any remaining buffer content
|
||||
if (buffer.isNotEmpty) {
|
||||
yield buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user