/// Utility class for parsing and extracting reasoning/thinking content from messages class ReasoningParser { /// Default tag pairs to detect raw reasoning blocks when providers don't emit
/// This mirrors Open WebUI defaults: ..., ... static const List> defaultReasoningTagPairs = >[ ['', ''], ['', ''], ]; /// Parses a message and extracts reasoning content /// Supports: /// -
blocks (server-emitted) /// - Raw tag pairs like ... or ... /// - Optional custom tag pair override static ReasoningContent? parseReasoningContent( String content, { List? customTagPair, bool detectDefaultTags = true, }) { if (content.isEmpty) return null; // 1) Prefer server-emitted
blocks final detailsRegex = RegExp( r']*>\s*([^<]*)<\/summary>\s*([\s\S]*?)<\/details>', multiLine: true, dotAll: true, ); final detailsMatch = detailsRegex.firstMatch(content); if (detailsMatch != null) { final isDone = (detailsMatch.group(1) ?? 'true') == 'true'; final duration = int.tryParse(detailsMatch.group(2) ?? '0') ?? 0; final summary = (detailsMatch.group(3) ?? '').trim(); final reasoning = (detailsMatch.group(4) ?? '').trim(); final mainContent = content.replaceAll(detailsRegex, '').trim(); return ReasoningContent( reasoning: reasoning, summary: summary, duration: duration, isDone: isDone, mainContent: mainContent, originalContent: content, ); } // 2) Handle partially streamed
(opening present, no closing yet) final openingIdx = content.indexOf('
= 0 && !content.contains('
')) { final after = content.substring(openingIdx); // Try to extract optional summary final summaryMatch = RegExp(r'([^<]*)<\/summary>').firstMatch(after); final summary = (summaryMatch?.group(1) ?? '').trim(); final reasoning = after .replaceAll(RegExp(r'^]*>'), '') .replaceAll(RegExp(r'[\s\S]*?<\/summary>'), '') .trim(); final mainContent = content.substring(0, openingIdx).trim(); return ReasoningContent( reasoning: reasoning, summary: summary, duration: 0, isDone: false, mainContent: mainContent, originalContent: content, ); } // 3) Otherwise, look for raw tag pairs List> tagPairs = []; if (customTagPair != null && customTagPair.length == 2) { tagPairs.add(customTagPair); } if (detectDefaultTags) { tagPairs.addAll(defaultReasoningTagPairs); } for (final pair in tagPairs) { final start = RegExp.escape(pair[0]); final end = RegExp.escape(pair[1]); final tagRegex = RegExp('($start)([\n\r\s\S]*?)($end)', multiLine: true, dotAll: true); final match = tagRegex.firstMatch(content); if (match != null) { final reasoning = (match.group(2) ?? '').trim(); final mainContent = content.replaceAll(tagRegex, '').trim(); return ReasoningContent( reasoning: reasoning, summary: '', // no summary available for raw tags duration: 0, isDone: true, mainContent: mainContent, originalContent: content, ); } } return null; } /// Checks if a message contains reasoning content static bool hasReasoningContent(String content) { if (content.contains('
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(); } }