diff --git a/lib/core/utils/html_utils.dart b/lib/core/utils/html_utils.dart
new file mode 100644
index 0000000..6b4659e
--- /dev/null
+++ b/lib/core/utils/html_utils.dart
@@ -0,0 +1,18 @@
+/// HTML entity utilities for parsing content.
+///
+/// Reference: openwebui-src/src/lib/utils/index.ts (unescapeHtml)
+library;
+
+import 'package:html_unescape/html_unescape.dart';
+
+/// Utility class for HTML entity handling.
+class HtmlUtils {
+ /// HTML entity unescaper instance.
+ static final _unescape = HtmlUnescape();
+
+ /// Unescape HTML entities in a string.
+ ///
+ /// Handles all Named, Decimal, and Hexadecimal Character References.
+ static String unescapeHtml(String s) => _unescape.convert(s);
+}
+
diff --git a/lib/core/utils/reasoning_parser.dart b/lib/core/utils/reasoning_parser.dart
index b3172db..eee8efb 100644
--- a/lib/core/utils/reasoning_parser.dart
+++ b/lib/core/utils/reasoning_parser.dart
@@ -1,342 +1,73 @@
/// 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 = >[
- ['', ''],
- ['', ''],
- ];
+///
+/// This parser handles:
+/// - `` blocks (server-emitted, preferred)
+/// - Raw tag pairs like ``, ``, ``, etc.
+///
+/// Reference: openwebui-src/backend/open_webui/utils/middleware.py DEFAULT_REASONING_TAGS
+library;
- /// 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;
+import 'html_utils.dart';
- // 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();
+/// All reasoning tag pairs supported by Open WebUI.
+/// Reference: DEFAULT_REASONING_TAGS in middleware.py
+const List<(String, String)> defaultReasoningTagPairs = [
+ ('', ''),
+ ('', ''),
+ ('', ''),
+ ('', ''),
+ ('', ''),
+ ('', ''),
+ ('<|begin_of_thought|>', '<|end_of_thought|>'),
+ ('◁think▷', '◁/think▷'),
+];
- final mainContent = content.replaceAll(detailsRegex, '').trim();
+/// Lightweight reasoning block for segmented rendering.
+class ReasoningEntry {
+ final String reasoning;
+ final String summary;
+ final int duration;
+ final bool isDone;
- return ReasoningContent(
- reasoning: reasoning,
- summary: summary,
- duration: duration,
- isDone: isDone,
- mainContent: mainContent,
- originalContent: content,
- );
- }
+ const ReasoningEntry({
+ required this.reasoning,
+ required this.summary,
+ required this.duration,
+ required this.isDone,
+ });
- // 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();
+ String get formattedDuration => ReasoningParser.formatDuration(duration);
- 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)(.*?)($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;
- }
-
- /// Splits content into ordered segments of plain text and reasoning entries
- /// (in the order they appear). Supports multiple reasoning blocks.
- /// - Handles `` with optional summary/duration/done
- /// - Handles raw tag pairs like `...` and `...`
- /// - Handles incomplete/streaming cases by emitting a partial reasoning entry
- static List? segments(
- String content, {
- List? customTagPair,
- bool detectDefaultTags = true,
- }) {
- if (content.isEmpty) return null;
-
- // Build raw tag pairs to check
- final tagPairs = >[];
- if (customTagPair != null && customTagPair.length == 2) {
- tagPairs.add(customTagPair);
- }
- if (detectDefaultTags) {
- tagPairs.addAll(defaultReasoningTagPairs);
- }
-
- final segs = [];
- int index = 0;
-
- while (index < content.length) {
- final nextDetails = content.indexOf('? rawPair; // [start, end]
- for (final pair in tagPairs) {
- final s = content.indexOf(pair[0], index);
- if (s != -1 && (nextRawStart == -1 || s < nextRawStart)) {
- nextRawStart = s;
- rawPair = pair;
- }
- }
-
- // Determine which comes first: reasoning or raw tag
- int nextIdx;
- String kind; // 'details' or 'raw' or 'none'
- if (nextDetails == -1 && nextRawStart == -1) {
- nextIdx = -1;
- kind = 'none';
- } else if (nextDetails != -1 &&
- (nextRawStart == -1 || nextDetails < nextRawStart)) {
- nextIdx = nextDetails;
- kind = 'details';
- } else {
- nextIdx = nextRawStart;
- kind = 'raw';
- }
-
- if (kind == 'none') {
- if (index < content.length) {
- segs.add(ReasoningSegment.text(content.substring(index)));
- }
- break;
- }
-
- // Add text before the next block
- if (nextIdx > index) {
- segs.add(ReasoningSegment.text(content.substring(index, nextIdx)));
- }
-
- if (kind == 'details') {
- // Try to parse the opening
- final openEnd = content.indexOf('>', nextIdx);
- if (openEnd == -1) {
- // Malformed tag; treat rest as text and stop
- segs.add(ReasoningSegment.text(content.substring(nextIdx)));
- break;
- }
- final openTag = content.substring(nextIdx, openEnd + 1);
-
- // Parse attributes
- final attrs = {};
- final attrRegex = RegExp(r'(\w+)="(.*?)"');
- for (final m in attrRegex.allMatches(openTag)) {
- attrs[m.group(1)!] = m.group(2) ?? '';
- }
- final isReasoning = (attrs['type'] ?? '') == 'reasoning';
-
- // Find matching closing tag with nesting awareness
- int depth = 1;
- int i = openEnd + 1;
- while (i < content.length && depth > 0) {
- final nextOpen = content.indexOf('', i);
- if (nextClose == -1 && nextOpen == -1) break;
- if (nextOpen != -1 && (nextClose == -1 || nextOpen < nextClose)) {
- depth++;
- i = nextOpen + 8; // '([^<]*)<\/summary>',
- ).firstMatch(after);
- final summary = (summaryMatch?.group(1) ?? '').trim();
- final reasoning = after
- .replaceAll(RegExp(r'^\s*[\s\S]*?<\/summary>'), '')
- .trim();
- segs.add(
- ReasoningSegment.entry(
- ReasoningEntry(
- reasoning: reasoning,
- summary: summary,
- duration: duration,
- isDone: false,
- ),
- ),
- );
- // No more content after partial block
- break;
- } else {
- // Closed block: extract inner content
- final inner = content.substring(
- openEnd + 1,
- i - 10,
- ); // without
- final sumMatch = RegExp(
- r'([^<]*)<\/summary>',
- ).firstMatch(inner);
- final summary = (sumMatch?.group(1) ?? '').trim();
- final reasoning = inner
- .replaceAll(RegExp(r'[\s\S]*?<\/summary>'), '')
- .trim();
- segs.add(
- ReasoningSegment.entry(
- ReasoningEntry(
- reasoning: reasoning,
- summary: summary,
- duration: duration,
- isDone: done,
- ),
- ),
- );
- index = i;
- continue;
- }
- } else if (kind == 'raw' && rawPair != null) {
- final startTag = rawPair[0];
- final endTag = rawPair[1];
- final start = nextIdx;
- final end = content.indexOf(endTag, start + startTag.length);
- if (end == -1) {
- // Unclosed raw tag => streaming partial
- final inner = content.substring(start + startTag.length);
- segs.add(
- ReasoningSegment.entry(
- ReasoningEntry(
- reasoning: inner.trim(),
- summary: '',
- duration: 0,
- isDone: false,
- ),
- ),
- );
- break;
- } else {
- final inner = content.substring(start + startTag.length, end);
- segs.add(
- ReasoningSegment.entry(
- ReasoningEntry(
- reasoning: inner.trim(),
- summary: '',
- duration: 0,
- isDone: true,
- ),
- ),
- );
- index = end + endTag.length;
- continue;
- }
- }
- }
-
- return segs.isEmpty ? null : segs;
- }
-
- /// Checks if a message contains reasoning content
- static bool hasReasoningContent(String content) {
- if (content.contains('' from blockquote format).
+ String get cleanedReasoning {
+ return reasoning
+ .split('\n')
+ .map((line) {
+ // Remove leading '>' and optional space (blockquote format from server)
+ if (line.startsWith('> ')) return line.substring(2);
+ if (line.startsWith('>')) return line.substring(1);
+ return line;
+ })
+ .join('\n')
+ .trim();
}
}
-/// Model class for reasoning content
+/// Ordered segment that is either plain text or a reasoning entry.
+class ReasoningSegment {
+ final String? text;
+ final ReasoningEntry? entry;
+
+ const ReasoningSegment._({this.text, this.entry});
+
+ factory ReasoningSegment.text(String text) => ReasoningSegment._(text: text);
+ factory ReasoningSegment.entry(ReasoningEntry entry) =>
+ ReasoningSegment._(entry: entry);
+
+ bool get isReasoning => entry != null;
+}
+
+/// Model class for reasoning content (legacy, kept for compatibility).
class ReasoningContent {
final String reasoning;
final String summary;
@@ -377,51 +108,349 @@ class ReasoningContent {
String get formattedDuration => ReasoningParser.formatDuration(duration);
- /// Gets the cleaned reasoning text (removes leading '>')
+ /// 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)
+ .map((line) {
+ if (line.startsWith('> ')) return line.substring(2);
+ if (line.startsWith('>')) return line.substring(1);
+ return line;
+ })
.join('\n')
.trim();
}
}
-/// Lightweight reasoning block for segmented rendering
-class ReasoningEntry {
- final String reasoning;
- final String summary;
- final int duration;
- final bool isDone;
+/// Utility class for parsing and extracting reasoning/thinking content.
+class ReasoningParser {
+ /// Splits content into ordered segments of plain text and reasoning entries.
+ ///
+ /// Handles:
+ /// - `` blocks with optional summary/duration/done
+ /// - Raw tag pairs like ``, ``, ``, etc.
+ /// - Incomplete/streaming cases by emitting a partial reasoning entry
+ static List? segments(
+ String content, {
+ List<(String, String)>? customTagPairs,
+ bool detectDefaultTags = true,
+ }) {
+ if (content.isEmpty) return null;
- const ReasoningEntry({
- required this.reasoning,
- required this.summary,
- required this.duration,
- required this.isDone,
+ // Build the list of raw tag pairs to detect
+ final tagPairs = <(String, String)>[];
+ if (customTagPairs != null) {
+ tagPairs.addAll(customTagPairs);
+ }
+ if (detectDefaultTags) {
+ tagPairs.addAll(defaultReasoningTagPairs);
+ }
+
+ final segments = [];
+ int index = 0;
+
+ while (index < content.length) {
+ // Find the earliest match: either ]*type="reasoning"',
+ ).firstMatch(content.substring(index));
+ if (detailsMatch != null) {
+ nextDetailsIdx = index + detailsMatch.start;
+ }
+
+ // Check for raw tag pairs
+ for (final pair in tagPairs) {
+ final startTag = pair.$1;
+ final idx = content.indexOf(startTag, index);
+ if (idx != -1 && (nextRawIdx == -1 || idx < nextRawIdx)) {
+ nextRawIdx = idx;
+ matchedRawPair = pair;
+ }
+ }
+
+ // Determine which comes first
+ final int nextIdx;
+ final String kind;
+ if (nextDetailsIdx == -1 && nextRawIdx == -1) {
+ // No more reasoning blocks
+ if (index < content.length) {
+ final remaining = content.substring(index);
+ if (remaining.trim().isNotEmpty) {
+ segments.add(ReasoningSegment.text(remaining));
+ }
+ }
+ break;
+ } else if (nextDetailsIdx != -1 &&
+ (nextRawIdx == -1 || nextDetailsIdx <= nextRawIdx)) {
+ nextIdx = nextDetailsIdx;
+ kind = 'details';
+ } else {
+ nextIdx = nextRawIdx;
+ kind = 'raw';
+ }
+
+ // Add text before this block
+ if (nextIdx > index) {
+ final textBefore = content.substring(index, nextIdx);
+ if (textBefore.trim().isNotEmpty) {
+ segments.add(ReasoningSegment.text(textBefore));
+ }
+ }
+
+ if (kind == 'details') {
+ // Parse block and extract ReasoningEntry
+ final result = _parseDetailsReasoning(content, nextIdx);
+ segments.add(ReasoningSegment.entry(result.entry));
+
+ if (!result.isComplete) {
+ // Incomplete block, stop here
+ break;
+ }
+ index = result.endIndex;
+ } else if (kind == 'raw' && matchedRawPair != null) {
+ // Parse raw tag pair
+ final result = _parseRawReasoning(
+ content,
+ nextIdx,
+ matchedRawPair.$1,
+ matchedRawPair.$2,
+ );
+ segments.add(ReasoningSegment.entry(result.entry));
+
+ if (!result.isComplete) {
+ // Incomplete block, stop here
+ break;
+ }
+ index = result.endIndex;
+ }
+ }
+
+ return segments.isEmpty ? null : segments;
+ }
+
+ /// Parse a `` block starting at the given index.
+ static _ReasoningResult _parseDetailsReasoning(String content, int startIdx) {
+ // Find the opening tag end
+ final openTagEnd = content.indexOf('>', startIdx);
+ if (openTagEnd == -1) {
+ // Incomplete opening tag
+ return _ReasoningResult(
+ entry: ReasoningEntry(
+ reasoning: '',
+ summary: '',
+ duration: 0,
+ isDone: false,
+ ),
+ endIndex: content.length,
+ isComplete: false,
+ );
+ }
+
+ final openTag = content.substring(startIdx, openTagEnd + 1);
+
+ // Parse attributes
+ final attrs = {};
+ final attrRegex = RegExp(r'(\w+)="([^"]*)"');
+ for (final m in attrRegex.allMatches(openTag)) {
+ attrs[m.group(1)!] = m.group(2) ?? '';
+ }
+
+ final isDone = (attrs['done'] ?? 'true') == 'true';
+ final duration = int.tryParse(attrs['duration'] ?? '0') ?? 0;
+
+ // Find matching closing tag with nesting support
+ int depth = 1;
+ int i = openTagEnd + 1;
+ while (i < content.length && depth > 0) {
+ final nextOpen = content.indexOf('', i);
+ if (nextClose == -1) break;
+ if (nextOpen != -1 && nextOpen < nextClose) {
+ depth++;
+ i = nextOpen + ''.length;
+ }
+ }
+
+ if (depth != 0) {
+ // Incomplete block (streaming)
+ final innerContent = content.substring(openTagEnd + 1);
+ final summaryResult = _extractSummary(innerContent);
+
+ return _ReasoningResult(
+ entry: ReasoningEntry(
+ reasoning: HtmlUtils.unescapeHtml(summaryResult.remaining),
+ summary: HtmlUtils.unescapeHtml(summaryResult.summary),
+ duration: duration,
+ isDone: false,
+ ),
+ endIndex: content.length,
+ isComplete: false,
+ );
+ }
+
+ // Complete block
+ final closeIdx = i - ' '.length;
+ final innerContent = content.substring(openTagEnd + 1, closeIdx);
+ final summaryResult = _extractSummary(innerContent);
+
+ return _ReasoningResult(
+ entry: ReasoningEntry(
+ reasoning: HtmlUtils.unescapeHtml(summaryResult.remaining),
+ summary: HtmlUtils.unescapeHtml(summaryResult.summary),
+ duration: duration,
+ isDone: isDone,
+ ),
+ endIndex: i,
+ isComplete: true,
+ );
+ }
+
+ /// Parse a raw reasoning tag pair (e.g., `...`).
+ static _ReasoningResult _parseRawReasoning(
+ String content,
+ int startIdx,
+ String startTag,
+ String endTag,
+ ) {
+ final endIdx = content.indexOf(endTag, startIdx + startTag.length);
+
+ if (endIdx == -1) {
+ // Incomplete block (streaming)
+ final innerContent = content.substring(startIdx + startTag.length);
+ return _ReasoningResult(
+ entry: ReasoningEntry(
+ reasoning: HtmlUtils.unescapeHtml(innerContent.trim()),
+ summary: '',
+ duration: 0,
+ isDone: false,
+ ),
+ endIndex: content.length,
+ isComplete: false,
+ );
+ }
+
+ // Complete block
+ final innerContent = content.substring(startIdx + startTag.length, endIdx);
+ return _ReasoningResult(
+ entry: ReasoningEntry(
+ reasoning: HtmlUtils.unescapeHtml(innerContent.trim()),
+ summary: '',
+ duration: 0,
+ isDone: true,
+ ),
+ endIndex: endIdx + endTag.length,
+ isComplete: true,
+ );
+ }
+
+ /// Extract `...
` from content.
+ static _SummaryResult _extractSummary(String content) {
+ final summaryRegex = RegExp(
+ r'^\s*(.*?)
\s*',
+ dotAll: true,
+ );
+ final match = summaryRegex.firstMatch(content);
+
+ if (match != null) {
+ return _SummaryResult(
+ summary: (match.group(1) ?? '').trim(),
+ remaining: content.substring(match.end).trim(),
+ );
+ }
+
+ return _SummaryResult(summary: '', remaining: content.trim());
+ }
+
+ /// Parses a message and extracts the first reasoning content block.
+ /// Returns null if no reasoning content is found.
+ static ReasoningContent? parseReasoningContent(
+ String content, {
+ List<(String, String)>? customTagPairs,
+ bool detectDefaultTags = true,
+ }) {
+ final segs = segments(
+ content,
+ customTagPairs: customTagPairs,
+ detectDefaultTags: detectDefaultTags,
+ );
+ if (segs == null || segs.isEmpty) return null;
+
+ // Find the first reasoning entry
+ ReasoningEntry? firstEntry;
+ final textParts = [];
+
+ for (final seg in segs) {
+ if (seg.isReasoning && firstEntry == null) {
+ firstEntry = seg.entry;
+ } else if (seg.text != null) {
+ textParts.add(seg.text!);
+ }
+ }
+
+ if (firstEntry == null) return null;
+
+ return ReasoningContent(
+ reasoning: firstEntry.reasoning,
+ summary: firstEntry.summary,
+ duration: firstEntry.duration,
+ isDone: firstEntry.isDone,
+ mainContent: textParts.join().trim(),
+ originalContent: content,
+ );
+ }
+
+ /// Checks if a message contains reasoning content.
+ static bool hasReasoningContent(String content) {
+ // Check for ReasoningParser.formatDuration(duration);
-
- String get cleanedReasoning {
- return reasoning
- .split('\n')
- .map((line) => line.startsWith('>') ? line.substring(1).trim() : line)
- .join('\n')
- .trim();
- }
}
-/// Ordered segment that is either plain text or a reasoning entry
-class ReasoningSegment {
- final String? text;
- final ReasoningEntry? entry;
+class _SummaryResult {
+ final String summary;
+ final String remaining;
- const ReasoningSegment._({this.text, this.entry});
- factory ReasoningSegment.text(String text) => ReasoningSegment._(text: text);
- factory ReasoningSegment.entry(ReasoningEntry entry) =>
- ReasoningSegment._(entry: entry);
-
- bool get isReasoning => entry != null;
+ const _SummaryResult({required this.summary, required this.remaining});
}
diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart
index 2144448..564ae7b 100644
--- a/lib/features/chat/widgets/assistant_message_widget.dart
+++ b/lib/features/chat/widgets/assistant_message_widget.dart
@@ -30,146 +30,6 @@ import '../../../core/services/worker_manager.dart';
final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*');
final _fileIdPattern = RegExp(r'/api/v1/files/([^/]+)/content');
-// Pre-compiled regex patterns for content sanitization (performance optimization)
-final _detailsOpenPattern = RegExp(r']*>');
-final _detailsClosePattern = RegExp(r' ');
-final _inlineDetailsPattern = RegExp(
- r']*)>((?:(?! ).)*) ',
- dotAll: true,
-);
-// Patterns for balancing and tags (similar to )
-final _thinkOpenPattern = RegExp(r'');
-final _thinkClosePattern = RegExp(r'');
-final _reasoningOpenPattern = RegExp(r'');
-final _reasoningClosePattern = RegExp(r'');
-
-/// Sanitizes content to handle malformed HTML-like tags that might cause
-/// parsing issues, particularly with Pipe Functions (e.g., Gemini).
-///
-/// This function:
-/// - Ensures all ``, ``, and `` tags are properly
-/// closed
-/// - Converts inline `... ` to multi-line format for proper
-/// block-level parsing
-/// - Removes orphan closing tags (those without matching opening tags)
-/// - Adds missing closing tags for unclosed opening tags
-/// - Prevents infinite loops in parsers caused by malformed content
-String sanitizeContentForParsing(String content) {
- if (content.isEmpty) return content;
-
- String result = content;
-
- // Check which tag types are present and need balancing
- final hasDetails =
- content.contains('');
- final hasThink = content.contains('') || content.contains('');
- final hasReasoning =
- content.contains('') || content.contains('');
-
- // Quick check: skip if no relevant tags present
- if (!hasDetails && !hasThink && !hasReasoning) {
- return content;
- }
-
- // Step 1: Convert inline ... to multi-line format
- // This ensures the markdown block parser can properly detect them
- if (hasDetails) {
- result = result.replaceAllMapped(_inlineDetailsPattern, (match) {
- final attrs = match.group(1) ?? '';
- final inner = match.group(2) ?? '';
- // Only convert if the inner content doesn't already span multiple lines
- if (!inner.contains('\n')) {
- return '\n$inner\n ';
- }
- return match.group(0)!;
- });
- }
-
- // Step 2: Balance tags by removing orphan closing tags and adding
- // missing closing tags using depth tracking
- if (hasDetails) {
- result = _balanceTags(
- result,
- _detailsOpenPattern,
- _detailsClosePattern,
- ' ',
- );
- }
- if (hasThink) {
- result = _balanceTags(
- result,
- _thinkOpenPattern,
- _thinkClosePattern,
- '',
- );
- }
- if (hasReasoning) {
- result = _balanceTags(
- result,
- _reasoningOpenPattern,
- _reasoningClosePattern,
- ' ',
- );
- }
-
- return result;
-}
-
-/// Balances tags by removing orphan closing tags and adding missing closing
-/// tags. Uses depth tracking to properly handle nested tags and identify
-/// orphans anywhere in the content.
-String _balanceTags(
- String content,
- RegExp openPattern,
- RegExp closePattern,
- String closeTag,
-) {
- final openMatches = openPattern.allMatches(content).toList();
- final closeMatches = closePattern.allMatches(content).toList();
-
- if (openMatches.isEmpty && closeMatches.isEmpty) return content;
-
- // Build sorted list of all tags: (start, end, isOpen)
- final tags = <({int start, int end, bool isOpen})>[];
- for (final m in openMatches) {
- tags.add((start: m.start, end: m.end, isOpen: true));
- }
- for (final m in closeMatches) {
- tags.add((start: m.start, end: m.end, isOpen: false));
- }
- tags.sort((a, b) => a.start.compareTo(b.start));
-
- // Find orphan closing tags using depth tracking
- // An orphan is a closing tag encountered when depth is already 0
- final orphanRanges = <(int, int)>[];
- int depth = 0;
- for (final tag in tags) {
- if (tag.isOpen) {
- depth++;
- } else {
- if (depth > 0) {
- depth--;
- } else {
- // Orphan closing tag - no matching opening tag
- orphanRanges.add((tag.start, tag.end));
- }
- }
- }
-
- // Remove orphan closing tags from end to start to preserve indices
- var result = content;
- for (final range in orphanRanges.reversed) {
- result = result.substring(0, range.$1) + result.substring(range.$2);
- }
-
- // Add missing closing tags for unclosed opening tags
- if (depth > 0) {
- result += '\n$closeTag' * depth;
- }
-
- return result;
-}
-
class AssistantMessageWidget extends ConsumerStatefulWidget {
final dynamic message;
final bool isStreaming;
@@ -303,13 +163,6 @@ class _AssistantMessageWidgetState extends ConsumerState
raw = raw.substring(searchBanner.length);
}
- // Sanitize content to handle malformed HTML-like tags from Pipe Functions
- // (e.g., Gemini) that might cause parsing issues or infinite loops.
- // Only sanitize when NOT streaming to avoid interfering with partial content.
- if (!widget.isStreaming) {
- raw = sanitizeContentForParsing(raw);
- }
-
// Do not truncate content during streaming; segmented parser skips
// incomplete details blocks and tiles will render once complete.
final rSegs = ReasoningParser.segments(raw);
@@ -1011,39 +864,13 @@ class _AssistantMessageWidgetState extends ConsumerState
return const SizedBox.shrink();
}
- // Note: The markdown parser now handles tags (including type="reasoning"
- // and type="tool_calls") via a custom block syntax, so they won't be rendered as
- // plain text during streaming. This prevents character flashing.
-
- // Quick check: only run cleanup if raw tags might exist (rare case)
- String cleaned = content;
- if (content.contains('') || content.contains('')) {
- // Clean raw reasoning tags as a fallback for raw mode or direct API responses.
- // The server normally converts these to format.
- cleaned = content
- .replaceAll(
- RegExp(r'[\s\S]*?<\/think>', multiLine: true, dotAll: true),
- '',
- )
- .replaceAll(
- RegExp(
- r'[\s\S]*?<\/reasoning>',
- multiLine: true,
- dotAll: true,
- ),
- '',
- );
- }
-
- // Sanitize content for markdown rendering to prevent parser issues with
- // malformed blocks from Pipe Functions (e.g., Gemini).
- // Only sanitize when NOT streaming to avoid interfering with partial content.
- if (!widget.isStreaming) {
- cleaned = sanitizeContentForParsing(cleaned);
- }
+ // Note: The reasoning/tool-calls parsers now handle all tag formats including
+ // raw tags like , , , etc. They are extracted
+ // and rendered as collapsible tiles, so we don't need to strip them here.
+ // The markdown widget will receive only the text segments.
// Process images in the remaining text
- final processedContent = _processContentForImages(cleaned);
+ final processedContent = _processContentForImages(content);
Widget buildDefault(BuildContext context) => StreamingMarkdownWidget(
content: processedContent,
diff --git a/pubspec.lock b/pubspec.lock
index 66a9119..d2c2ea6 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -757,6 +757,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.15.6"
+ html_unescape:
+ dependency: "direct main"
+ description:
+ name: html_unescape
+ sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.0"
http:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 2a61f58..9dedf53 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -77,6 +77,7 @@ dependencies:
flutter_app_intents: ^0.7.0
quick_actions: 1.1.0
flutter_svg: ^2.2.3
+ html_unescape: ^2.0.0
# Clipboard functionality is available through flutter/services (part of Flutter SDK)