2025-09-02 00:04:21 +05:30
|
|
|
/// Utility class for parsing and extracting reasoning/thinking content from messages.
|
2025-12-04 15:05:20 +05:30
|
|
|
///
|
|
|
|
|
/// This parser handles:
|
|
|
|
|
/// - `<details type="reasoning">` blocks (server-emitted, preferred)
|
|
|
|
|
/// - Raw tag pairs like `<think>`, `<thinking>`, `<reasoning>`, etc.
|
|
|
|
|
///
|
|
|
|
|
/// Reference: openwebui-src/backend/open_webui/utils/middleware.py DEFAULT_REASONING_TAGS
|
|
|
|
|
library;
|
|
|
|
|
|
2025-12-22 14:07:04 +05:30
|
|
|
import 'package:html_unescape/html_unescape.dart';
|
|
|
|
|
|
|
|
|
|
final _htmlUnescape = HtmlUnescape();
|
|
|
|
|
|
|
|
|
|
/// Unescape HTML entities in reasoning content.
|
|
|
|
|
String _unescapeHtml(String s) => _htmlUnescape.convert(s);
|
2025-12-04 15:05:20 +05:30
|
|
|
|
|
|
|
|
/// All reasoning tag pairs supported by Open WebUI.
|
|
|
|
|
/// Reference: DEFAULT_REASONING_TAGS in middleware.py
|
|
|
|
|
const List<(String, String)> defaultReasoningTagPairs = [
|
|
|
|
|
('<think>', '</think>'),
|
|
|
|
|
('<thinking>', '</thinking>'),
|
|
|
|
|
('<reason>', '</reason>'),
|
|
|
|
|
('<reasoning>', '</reasoning>'),
|
|
|
|
|
('<thought>', '</thought>'),
|
|
|
|
|
('<Thought>', '</Thought>'),
|
|
|
|
|
('<|begin_of_thought|>', '<|end_of_thought|>'),
|
|
|
|
|
('◁think▷', '◁/think▷'),
|
|
|
|
|
];
|
|
|
|
|
|
2025-12-09 23:04:18 +05:30
|
|
|
/// Type of collapsible block (reasoning vs code_interpreter).
|
|
|
|
|
enum CollapsibleBlockType { reasoning, codeInterpreter }
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
/// Lightweight reasoning block for segmented rendering.
|
|
|
|
|
class ReasoningEntry {
|
|
|
|
|
final String reasoning;
|
|
|
|
|
final String summary;
|
|
|
|
|
final int duration;
|
|
|
|
|
final bool isDone;
|
2025-12-09 23:04:18 +05:30
|
|
|
final CollapsibleBlockType blockType;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
const ReasoningEntry({
|
|
|
|
|
required this.reasoning,
|
|
|
|
|
required this.summary,
|
|
|
|
|
required this.duration,
|
|
|
|
|
required this.isDone,
|
2025-12-09 23:04:18 +05:30
|
|
|
this.blockType = CollapsibleBlockType.reasoning,
|
2025-12-04 15:05:20 +05:30
|
|
|
});
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-12-09 23:04:18 +05:30
|
|
|
/// Whether this is a code interpreter block.
|
|
|
|
|
bool get isCodeInterpreter =>
|
|
|
|
|
blockType == CollapsibleBlockType.codeInterpreter;
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
String get formattedDuration => ReasoningParser.formatDuration(duration);
|
2025-08-29 00:20:34 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
/// Gets the cleaned reasoning text (removes leading '>' 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();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-29 00:20:34 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
/// 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;
|
|
|
|
|
final int duration;
|
|
|
|
|
final bool isDone;
|
|
|
|
|
final String mainContent;
|
|
|
|
|
final String originalContent;
|
2025-08-29 00:20:34 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
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 {
|
|
|
|
|
return reasoning
|
|
|
|
|
.split('\n')
|
|
|
|
|
.map((line) {
|
|
|
|
|
if (line.startsWith('> ')) return line.substring(2);
|
|
|
|
|
if (line.startsWith('>')) return line.substring(1);
|
|
|
|
|
return line;
|
|
|
|
|
})
|
|
|
|
|
.join('\n')
|
|
|
|
|
.trim();
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
2025-12-04 15:05:20 +05:30
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
/// Utility class for parsing and extracting reasoning/thinking content.
|
|
|
|
|
class ReasoningParser {
|
2025-12-07 22:35:16 +05:30
|
|
|
/// Patterns that indicate a details block is reasoning content.
|
|
|
|
|
/// Used when the `type` attribute is missing.
|
|
|
|
|
static final _reasoningSummaryPattern = RegExp(
|
|
|
|
|
r'Thought|Thinking|Reasoning',
|
|
|
|
|
caseSensitive: false,
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
/// Splits content into ordered segments of plain text and reasoning entries.
|
|
|
|
|
///
|
|
|
|
|
/// Handles:
|
|
|
|
|
/// - `<details type="reasoning">` blocks with optional summary/duration/done
|
2025-12-07 22:35:16 +05:30
|
|
|
/// - `<details>` blocks without type but with reasoning-like summary
|
2025-12-04 15:05:20 +05:30
|
|
|
/// - Raw tag pairs like `<think>`, `<thinking>`, `<reasoning>`, etc.
|
|
|
|
|
/// - Incomplete/streaming cases by emitting a partial reasoning entry
|
2025-09-05 22:44:04 +05:30
|
|
|
static List<ReasoningSegment>? segments(
|
|
|
|
|
String content, {
|
2025-12-04 15:05:20 +05:30
|
|
|
List<(String, String)>? customTagPairs,
|
2025-09-05 22:44:04 +05:30
|
|
|
bool detectDefaultTags = true,
|
|
|
|
|
}) {
|
|
|
|
|
if (content.isEmpty) return null;
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
// Build the list of raw tag pairs to detect
|
|
|
|
|
final tagPairs = <(String, String)>[];
|
|
|
|
|
if (customTagPairs != null) {
|
|
|
|
|
tagPairs.addAll(customTagPairs);
|
2025-09-05 22:44:04 +05:30
|
|
|
}
|
|
|
|
|
if (detectDefaultTags) {
|
|
|
|
|
tagPairs.addAll(defaultReasoningTagPairs);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
final segments = <ReasoningSegment>[];
|
2025-09-05 22:44:04 +05:30
|
|
|
int index = 0;
|
|
|
|
|
|
|
|
|
|
while (index < content.length) {
|
2025-12-07 22:35:16 +05:30
|
|
|
// Find the earliest match: either <details (any type) or a raw tag
|
2025-12-04 15:05:20 +05:30
|
|
|
int nextDetailsIdx = -1;
|
|
|
|
|
int nextRawIdx = -1;
|
|
|
|
|
(String, String)? matchedRawPair;
|
|
|
|
|
|
2025-12-07 22:35:16 +05:30
|
|
|
// Check for any <details tag (we'll determine if it's reasoning later)
|
2025-12-04 15:05:20 +05:30
|
|
|
final detailsMatch = RegExp(
|
2025-12-07 22:35:16 +05:30
|
|
|
r'<details(?:\s|>)',
|
2025-12-04 15:05:20 +05:30
|
|
|
).firstMatch(content.substring(index));
|
|
|
|
|
if (detailsMatch != null) {
|
|
|
|
|
nextDetailsIdx = index + detailsMatch.start;
|
|
|
|
|
}
|
2025-09-05 22:44:04 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
// Check for raw tag pairs
|
2025-12-22 14:07:04 +05:30
|
|
|
// Supports tags with optional attributes like <think foo="bar">
|
|
|
|
|
// Reference: openwebui-src/backend/open_webui/utils/middleware.py
|
2025-09-05 22:44:04 +05:30
|
|
|
for (final pair in tagPairs) {
|
2025-12-04 15:05:20 +05:30
|
|
|
final startTag = pair.$1;
|
2025-12-22 14:07:04 +05:30
|
|
|
int idx = -1;
|
|
|
|
|
|
|
|
|
|
// For XML-like tags (e.g., <think>), match with optional attributes
|
|
|
|
|
if (startTag.startsWith('<') && startTag.endsWith('>')) {
|
|
|
|
|
final tagName = startTag.substring(1, startTag.length - 1);
|
|
|
|
|
final pattern = RegExp('<${RegExp.escape(tagName)}(\\s[^>]*)?>');
|
|
|
|
|
final match = pattern.firstMatch(content.substring(index));
|
|
|
|
|
if (match != null) {
|
|
|
|
|
idx = index + match.start;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// For non-XML tags (e.g., ◁think▷), use exact matching
|
|
|
|
|
idx = content.indexOf(startTag, index);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
if (idx != -1 && (nextRawIdx == -1 || idx < nextRawIdx)) {
|
|
|
|
|
nextRawIdx = idx;
|
|
|
|
|
matchedRawPair = pair;
|
2025-09-05 22:44:04 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
// Determine which comes first
|
|
|
|
|
final int nextIdx;
|
|
|
|
|
final String kind;
|
|
|
|
|
if (nextDetailsIdx == -1 && nextRawIdx == -1) {
|
|
|
|
|
// No more reasoning blocks
|
2025-09-05 22:44:04 +05:30
|
|
|
if (index < content.length) {
|
2025-12-04 15:05:20 +05:30
|
|
|
final remaining = content.substring(index);
|
|
|
|
|
if (remaining.trim().isNotEmpty) {
|
|
|
|
|
segments.add(ReasoningSegment.text(remaining));
|
|
|
|
|
}
|
2025-09-05 22:44:04 +05:30
|
|
|
}
|
|
|
|
|
break;
|
2025-12-04 15:05:20 +05:30
|
|
|
} else if (nextDetailsIdx != -1 &&
|
|
|
|
|
(nextRawIdx == -1 || nextDetailsIdx <= nextRawIdx)) {
|
|
|
|
|
nextIdx = nextDetailsIdx;
|
|
|
|
|
kind = 'details';
|
|
|
|
|
} else {
|
|
|
|
|
nextIdx = nextRawIdx;
|
|
|
|
|
kind = 'raw';
|
2025-09-05 22:44:04 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
// Add text before this block
|
2025-09-05 22:44:04 +05:30
|
|
|
if (nextIdx > index) {
|
2025-12-04 15:05:20 +05:30
|
|
|
final textBefore = content.substring(index, nextIdx);
|
|
|
|
|
if (textBefore.trim().isNotEmpty) {
|
|
|
|
|
segments.add(ReasoningSegment.text(textBefore));
|
|
|
|
|
}
|
2025-09-05 22:44:04 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (kind == 'details') {
|
2025-12-07 22:35:16 +05:30
|
|
|
// Parse <details> block and check if it's reasoning content
|
|
|
|
|
final result = _parseDetailsBlock(content, nextIdx);
|
|
|
|
|
|
|
|
|
|
// Only add as reasoning if it's a reasoning type or looks like reasoning
|
|
|
|
|
if (result.isReasoning) {
|
|
|
|
|
segments.add(ReasoningSegment.entry(result.entry));
|
|
|
|
|
} else {
|
|
|
|
|
// Not a reasoning block, treat as text
|
|
|
|
|
final detailsText = content.substring(nextIdx, result.endIndex);
|
|
|
|
|
if (detailsText.trim().isNotEmpty) {
|
|
|
|
|
segments.add(ReasoningSegment.text(detailsText));
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-04 15:05:20 +05:30
|
|
|
|
|
|
|
|
if (!result.isComplete) {
|
|
|
|
|
// Incomplete block, stop here
|
2025-09-05 22:44:04 +05:30
|
|
|
break;
|
|
|
|
|
}
|
2025-12-04 15:05:20 +05:30
|
|
|
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));
|
2025-09-05 22:44:04 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
if (!result.isComplete) {
|
|
|
|
|
// Incomplete block, stop here
|
|
|
|
|
break;
|
2025-09-05 22:44:04 +05:30
|
|
|
}
|
2025-12-04 15:05:20 +05:30
|
|
|
index = result.endIndex;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-05 22:44:04 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
return segments.isEmpty ? null : segments;
|
|
|
|
|
}
|
2025-09-05 22:44:04 +05:30
|
|
|
|
2025-12-07 22:35:16 +05:30
|
|
|
/// Parse a `<details>` block starting at the given index.
|
|
|
|
|
/// Returns whether the block is reasoning content based on type or summary.
|
|
|
|
|
static _DetailsResult _parseDetailsBlock(String content, int startIdx) {
|
2025-12-04 15:05:20 +05:30
|
|
|
// Find the opening tag end
|
|
|
|
|
final openTagEnd = content.indexOf('>', startIdx);
|
|
|
|
|
if (openTagEnd == -1) {
|
2025-12-07 22:35:16 +05:30
|
|
|
// Incomplete opening tag - assume reasoning for streaming
|
|
|
|
|
return _DetailsResult(
|
2025-12-04 15:05:20 +05:30
|
|
|
entry: ReasoningEntry(
|
|
|
|
|
reasoning: '',
|
|
|
|
|
summary: '',
|
|
|
|
|
duration: 0,
|
|
|
|
|
isDone: false,
|
|
|
|
|
),
|
|
|
|
|
endIndex: content.length,
|
|
|
|
|
isComplete: false,
|
2025-12-07 22:35:16 +05:30
|
|
|
isReasoning: true,
|
2025-12-04 15:05:20 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final openTag = content.substring(startIdx, openTagEnd + 1);
|
|
|
|
|
|
2025-12-09 23:04:18 +05:30
|
|
|
// Parse attributes - use non-greedy match to handle attributes correctly
|
|
|
|
|
// Mirrors Open WebUI's parseAttributes: /(\w+)="(.*?)"/g
|
2025-12-04 15:05:20 +05:30
|
|
|
final attrs = <String, String>{};
|
2025-12-09 23:04:18 +05:30
|
|
|
final attrRegex = RegExp(r'(\w+)="(.*?)"');
|
2025-12-04 15:05:20 +05:30
|
|
|
for (final m in attrRegex.allMatches(openTag)) {
|
|
|
|
|
attrs[m.group(1)!] = m.group(2) ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-09 23:04:18 +05:30
|
|
|
final type = attrs['type']?.toLowerCase() ?? '';
|
|
|
|
|
// Open WebUI treats done as string comparison: done === 'true'
|
|
|
|
|
final isDone = attrs['done'] == 'true';
|
2025-12-04 15:05:20 +05:30
|
|
|
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('<details', i);
|
|
|
|
|
final nextClose = content.indexOf('</details>', i);
|
|
|
|
|
if (nextClose == -1) break;
|
|
|
|
|
if (nextOpen != -1 && nextOpen < nextClose) {
|
|
|
|
|
depth++;
|
|
|
|
|
i = nextOpen + '<details'.length;
|
|
|
|
|
} else {
|
|
|
|
|
depth--;
|
|
|
|
|
i = nextClose + '</details>'.length;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-09 23:04:18 +05:30
|
|
|
// Determine block type based on type attribute
|
|
|
|
|
final blockType = type == 'code_interpreter'
|
|
|
|
|
? CollapsibleBlockType.codeInterpreter
|
|
|
|
|
: CollapsibleBlockType.reasoning;
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
if (depth != 0) {
|
|
|
|
|
// Incomplete block (streaming)
|
|
|
|
|
final innerContent = content.substring(openTagEnd + 1);
|
|
|
|
|
final summaryResult = _extractSummary(innerContent);
|
|
|
|
|
|
2025-12-07 22:35:16 +05:30
|
|
|
// Determine if this is reasoning based on type or summary
|
2025-12-09 23:04:18 +05:30
|
|
|
// Also treat code_interpreter as reasoning-like (collapsible thinking)
|
2025-12-07 22:35:16 +05:30
|
|
|
final isReasoning =
|
|
|
|
|
type == 'reasoning' ||
|
2025-12-09 23:04:18 +05:30
|
|
|
type == 'code_interpreter' ||
|
2025-12-07 22:35:16 +05:30
|
|
|
(type.isEmpty &&
|
|
|
|
|
_reasoningSummaryPattern.hasMatch(summaryResult.summary));
|
|
|
|
|
|
|
|
|
|
// Extract duration from summary if not in attributes
|
|
|
|
|
final effectiveDuration = duration > 0
|
|
|
|
|
? duration
|
|
|
|
|
: _extractDurationFromSummary(summaryResult.summary);
|
|
|
|
|
|
|
|
|
|
return _DetailsResult(
|
2025-12-04 15:05:20 +05:30
|
|
|
entry: ReasoningEntry(
|
2025-12-22 14:07:04 +05:30
|
|
|
reasoning: _unescapeHtml(summaryResult.remaining),
|
|
|
|
|
summary: _unescapeHtml(summaryResult.summary),
|
2025-12-07 22:35:16 +05:30
|
|
|
duration: effectiveDuration,
|
2025-12-04 15:05:20 +05:30
|
|
|
isDone: false,
|
2025-12-09 23:04:18 +05:30
|
|
|
blockType: blockType,
|
2025-12-04 15:05:20 +05:30
|
|
|
),
|
|
|
|
|
endIndex: content.length,
|
|
|
|
|
isComplete: false,
|
2025-12-07 22:35:16 +05:30
|
|
|
isReasoning: isReasoning,
|
2025-12-04 15:05:20 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Complete block
|
|
|
|
|
final closeIdx = i - '</details>'.length;
|
|
|
|
|
final innerContent = content.substring(openTagEnd + 1, closeIdx);
|
|
|
|
|
final summaryResult = _extractSummary(innerContent);
|
|
|
|
|
|
2025-12-07 22:35:16 +05:30
|
|
|
// Determine if this is reasoning based on type or summary
|
2025-12-09 23:04:18 +05:30
|
|
|
// Also treat code_interpreter as reasoning-like (collapsible thinking)
|
2025-12-07 22:35:16 +05:30
|
|
|
final isReasoning =
|
|
|
|
|
type == 'reasoning' ||
|
2025-12-09 23:04:18 +05:30
|
|
|
type == 'code_interpreter' ||
|
2025-12-07 22:35:16 +05:30
|
|
|
(type.isEmpty &&
|
|
|
|
|
_reasoningSummaryPattern.hasMatch(summaryResult.summary));
|
|
|
|
|
|
|
|
|
|
// Extract duration from summary if not in attributes
|
|
|
|
|
final effectiveDuration = duration > 0
|
|
|
|
|
? duration
|
|
|
|
|
: _extractDurationFromSummary(summaryResult.summary);
|
|
|
|
|
|
|
|
|
|
return _DetailsResult(
|
2025-12-04 15:05:20 +05:30
|
|
|
entry: ReasoningEntry(
|
2025-12-22 14:07:04 +05:30
|
|
|
reasoning: _unescapeHtml(summaryResult.remaining),
|
|
|
|
|
summary: _unescapeHtml(summaryResult.summary),
|
2025-12-07 22:35:16 +05:30
|
|
|
duration: effectiveDuration,
|
2025-12-04 15:05:20 +05:30
|
|
|
isDone: isDone,
|
2025-12-09 23:04:18 +05:30
|
|
|
blockType: blockType,
|
2025-12-04 15:05:20 +05:30
|
|
|
),
|
|
|
|
|
endIndex: i,
|
|
|
|
|
isComplete: true,
|
2025-12-07 22:35:16 +05:30
|
|
|
isReasoning: isReasoning,
|
2025-12-04 15:05:20 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse a raw reasoning tag pair (e.g., `<think>...</think>`).
|
2025-12-22 14:07:04 +05:30
|
|
|
/// Supports tags with optional attributes like `<think foo="bar">`.
|
|
|
|
|
///
|
|
|
|
|
/// Reference: openwebui-src/backend/open_webui/utils/middleware.py
|
2025-12-04 15:05:20 +05:30
|
|
|
static _ReasoningResult _parseRawReasoning(
|
|
|
|
|
String content,
|
|
|
|
|
int startIdx,
|
|
|
|
|
String startTag,
|
|
|
|
|
String endTag,
|
|
|
|
|
) {
|
2025-12-22 14:07:04 +05:30
|
|
|
// Find the actual end of the opening tag (handles attributes)
|
|
|
|
|
int contentStartIdx;
|
|
|
|
|
if (startTag.startsWith('<') && startTag.endsWith('>')) {
|
|
|
|
|
// For XML-like tags, find the closing '>' to skip any attributes
|
|
|
|
|
final tagCloseIdx = content.indexOf('>', startIdx);
|
|
|
|
|
if (tagCloseIdx == -1) {
|
|
|
|
|
// Incomplete opening tag
|
|
|
|
|
return _ReasoningResult(
|
|
|
|
|
entry: ReasoningEntry(
|
|
|
|
|
reasoning: '',
|
|
|
|
|
summary: '',
|
|
|
|
|
duration: 0,
|
|
|
|
|
isDone: false,
|
|
|
|
|
),
|
|
|
|
|
endIndex: content.length,
|
|
|
|
|
isComplete: false,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
contentStartIdx = tagCloseIdx + 1;
|
|
|
|
|
} else {
|
|
|
|
|
// For non-XML tags, use exact tag length
|
|
|
|
|
contentStartIdx = startIdx + startTag.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final endIdx = content.indexOf(endTag, contentStartIdx);
|
2025-12-04 15:05:20 +05:30
|
|
|
|
|
|
|
|
if (endIdx == -1) {
|
|
|
|
|
// Incomplete block (streaming)
|
2025-12-22 14:07:04 +05:30
|
|
|
final innerContent = content.substring(contentStartIdx);
|
2025-12-04 15:05:20 +05:30
|
|
|
return _ReasoningResult(
|
|
|
|
|
entry: ReasoningEntry(
|
2025-12-22 14:07:04 +05:30
|
|
|
reasoning: _unescapeHtml(innerContent.trim()),
|
2025-12-04 15:05:20 +05:30
|
|
|
summary: '',
|
|
|
|
|
duration: 0,
|
|
|
|
|
isDone: false,
|
|
|
|
|
),
|
|
|
|
|
endIndex: content.length,
|
|
|
|
|
isComplete: false,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Complete block
|
2025-12-22 14:07:04 +05:30
|
|
|
final innerContent = content.substring(contentStartIdx, endIdx);
|
2025-12-04 15:05:20 +05:30
|
|
|
return _ReasoningResult(
|
|
|
|
|
entry: ReasoningEntry(
|
2025-12-22 14:07:04 +05:30
|
|
|
reasoning: _unescapeHtml(innerContent.trim()),
|
2025-12-04 15:05:20 +05:30
|
|
|
summary: '',
|
|
|
|
|
duration: 0,
|
|
|
|
|
isDone: true,
|
|
|
|
|
),
|
|
|
|
|
endIndex: endIdx + endTag.length,
|
|
|
|
|
isComplete: true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract `<summary>...</summary>` from content.
|
|
|
|
|
static _SummaryResult _extractSummary(String content) {
|
|
|
|
|
final summaryRegex = RegExp(
|
|
|
|
|
r'^\s*<summary>(.*?)</summary>\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());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 22:35:16 +05:30
|
|
|
/// Extract duration from summary text like "Thought (1s)" or "Thinking (2m 30s)".
|
|
|
|
|
static int _extractDurationFromSummary(String summary) {
|
|
|
|
|
// Match patterns like "(1s)", "(30s)", "(1m)", "(2m 30s)", "(1m30s)"
|
|
|
|
|
// Supports minutes-only "(1m)", seconds-only "(30s)", or both "(2m 30s)"
|
|
|
|
|
final durationRegex = RegExp(
|
|
|
|
|
r'\((\d+)m(?:\s*(\d+)s)?\)|\((\d+)s\)',
|
|
|
|
|
caseSensitive: false,
|
|
|
|
|
);
|
|
|
|
|
final match = durationRegex.firstMatch(summary);
|
|
|
|
|
if (match != null) {
|
|
|
|
|
// Check if it's a minutes pattern (groups 1 and 2) or seconds-only (group 3)
|
|
|
|
|
if (match.group(1) != null) {
|
|
|
|
|
// Minutes pattern: "(Xm)" or "(Xm Ys)"
|
|
|
|
|
final minutes = int.tryParse(match.group(1) ?? '0') ?? 0;
|
|
|
|
|
final seconds = int.tryParse(match.group(2) ?? '0') ?? 0;
|
|
|
|
|
return minutes * 60 + seconds;
|
|
|
|
|
} else if (match.group(3) != null) {
|
|
|
|
|
// Seconds-only pattern: "(Xs)"
|
|
|
|
|
return int.tryParse(match.group(3) ?? '0') ?? 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
/// 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 = <String>[];
|
|
|
|
|
|
|
|
|
|
for (final seg in segs) {
|
|
|
|
|
if (seg.isReasoning && firstEntry == null) {
|
|
|
|
|
firstEntry = seg.entry;
|
|
|
|
|
} else if (seg.text != null) {
|
|
|
|
|
textParts.add(seg.text!);
|
2025-09-05 22:44:04 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
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,
|
|
|
|
|
);
|
2025-09-05 22:44:04 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
/// Checks if a message contains reasoning content.
|
2025-08-10 01:20:45 +05:30
|
|
|
static bool hasReasoningContent(String content) {
|
2025-12-09 23:04:18 +05:30
|
|
|
// Check for <details type="reasoning" (case-insensitive)
|
|
|
|
|
if (RegExp(r'type="reasoning"', caseSensitive: false).hasMatch(content)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for <details type="code_interpreter" (case-insensitive)
|
|
|
|
|
if (RegExp(
|
|
|
|
|
r'type="code_interpreter"',
|
|
|
|
|
caseSensitive: false,
|
|
|
|
|
).hasMatch(content)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2025-12-04 15:05:20 +05:30
|
|
|
|
2025-12-07 22:35:16 +05:30
|
|
|
// Check for <details> with reasoning-like summary
|
|
|
|
|
if (content.contains('<details')) {
|
|
|
|
|
final summaryMatch = RegExp(
|
|
|
|
|
r'<summary>([^<]*)</summary>',
|
|
|
|
|
).firstMatch(content);
|
|
|
|
|
if (summaryMatch != null) {
|
|
|
|
|
final summary = summaryMatch.group(1) ?? '';
|
|
|
|
|
if (_reasoningSummaryPattern.hasMatch(summary)) return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
// Check for raw tag pairs
|
2025-08-29 00:20:34 +05:30
|
|
|
for (final pair in defaultReasoningTagPairs) {
|
2025-12-04 15:05:20 +05:30
|
|
|
if (content.contains(pair.$1)) return true;
|
2025-08-29 00:20:34 +05:30
|
|
|
}
|
2025-12-04 15:05:20 +05:30
|
|
|
|
2025-08-29 00:20:34 +05:30
|
|
|
return false;
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
/// Formats the duration for display.
|
2025-12-22 14:07:04 +05:30
|
|
|
/// Mirrors Open WebUI's dayjs.duration(seconds, 'seconds').humanize():
|
2025-12-09 23:04:18 +05:30
|
|
|
/// - < 1: "less than a second"
|
|
|
|
|
/// - < 60: "X seconds"
|
2025-12-22 14:07:04 +05:30
|
|
|
/// - >= 60: humanized (e.g., "a minute", "2 minutes", "about an hour")
|
|
|
|
|
///
|
|
|
|
|
/// Reference: openwebui-src/src/lib/components/common/Collapsible.svelte
|
2025-08-10 01:20:45 +05:30
|
|
|
static String formatDuration(int seconds) {
|
2025-12-09 23:04:18 +05:30
|
|
|
if (seconds < 1) return 'less than a second';
|
2025-08-10 01:20:45 +05:30
|
|
|
if (seconds < 60) return '$seconds second${seconds == 1 ? '' : 's'}';
|
|
|
|
|
|
2025-12-22 14:07:04 +05:30
|
|
|
// Match dayjs.duration().humanize() behavior
|
|
|
|
|
// Reference: https://day.js.org/docs/en/durations/humanize
|
|
|
|
|
if (seconds < 90) return 'a minute';
|
|
|
|
|
if (seconds < 2700) {
|
|
|
|
|
// 45 minutes
|
|
|
|
|
final minutes = (seconds / 60).round();
|
|
|
|
|
return '$minutes minutes';
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
2025-12-22 14:07:04 +05:30
|
|
|
if (seconds < 5400) return 'about an hour'; // 90 minutes
|
|
|
|
|
if (seconds < 79200) {
|
|
|
|
|
// 22 hours
|
|
|
|
|
final hours = (seconds / 3600).round();
|
|
|
|
|
return '$hours hours';
|
|
|
|
|
}
|
|
|
|
|
if (seconds < 129600) return 'a day'; // 36 hours
|
|
|
|
|
final days = (seconds / 86400).round();
|
|
|
|
|
return '$days days';
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
class _ReasoningResult {
|
|
|
|
|
final ReasoningEntry entry;
|
|
|
|
|
final int endIndex;
|
|
|
|
|
final bool isComplete;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
const _ReasoningResult({
|
|
|
|
|
required this.entry,
|
|
|
|
|
required this.endIndex,
|
|
|
|
|
required this.isComplete,
|
2025-08-10 01:20:45 +05:30
|
|
|
});
|
|
|
|
|
}
|
2025-09-05 22:44:04 +05:30
|
|
|
|
2025-12-07 22:35:16 +05:30
|
|
|
class _DetailsResult {
|
|
|
|
|
final ReasoningEntry entry;
|
|
|
|
|
final int endIndex;
|
|
|
|
|
final bool isComplete;
|
|
|
|
|
final bool isReasoning;
|
|
|
|
|
|
|
|
|
|
const _DetailsResult({
|
|
|
|
|
required this.entry,
|
|
|
|
|
required this.endIndex,
|
|
|
|
|
required this.isComplete,
|
|
|
|
|
required this.isReasoning,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
class _SummaryResult {
|
2025-09-05 22:44:04 +05:30
|
|
|
final String summary;
|
2025-12-04 15:05:20 +05:30
|
|
|
final String remaining;
|
2025-09-05 22:44:04 +05:30
|
|
|
|
2025-12-04 15:05:20 +05:30
|
|
|
const _SummaryResult({required this.summary, required this.remaining});
|
2025-09-05 22:44:04 +05:30
|
|
|
}
|