refactor: reasoning parser
This commit is contained in:
24
lib/core/utils/message_segments.dart
Normal file
24
lib/core/utils/message_segments.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'tool_calls_parser.dart';
|
||||
import 'reasoning_parser.dart';
|
||||
|
||||
/// Unified segment representing ordered pieces of a message:
|
||||
/// - `text`: plain text/markdown to render
|
||||
/// - `toolCall`: a parsed tool call entry to render as a tile
|
||||
/// - `reasoning`: a parsed reasoning entry to render as a tile
|
||||
class MessageSegment {
|
||||
final String? text;
|
||||
final ToolCallEntry? toolCall;
|
||||
final ReasoningEntry? reasoning;
|
||||
|
||||
const MessageSegment._({this.text, this.toolCall, this.reasoning});
|
||||
|
||||
factory MessageSegment.text(String text) => MessageSegment._(text: text);
|
||||
factory MessageSegment.tool(ToolCallEntry tool) =>
|
||||
MessageSegment._(toolCall: tool);
|
||||
factory MessageSegment.reason(ReasoningEntry entry) =>
|
||||
MessageSegment._(reasoning: entry);
|
||||
|
||||
bool get isText => text != null;
|
||||
bool get isTool => toolCall != null;
|
||||
bool get isReasoning => reasoning != null;
|
||||
}
|
||||
@@ -100,6 +100,203 @@ class ReasoningParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Splits content into ordered segments of plain text and reasoning entries
|
||||
/// (in the order they appear). Supports multiple reasoning blocks.
|
||||
/// - Handles `<details type="reasoning" ...>` with optional summary/duration/done
|
||||
/// - Handles raw tag pairs like `<think>...</think>` and `<reasoning>...</reasoning>`
|
||||
/// - Handles incomplete/streaming cases by emitting a partial reasoning entry
|
||||
static List<ReasoningSegment>? segments(
|
||||
String content, {
|
||||
List<String>? customTagPair,
|
||||
bool detectDefaultTags = true,
|
||||
}) {
|
||||
if (content.isEmpty) return null;
|
||||
|
||||
// Build raw tag pairs to check
|
||||
final tagPairs = <List<String>>[];
|
||||
if (customTagPair != null && customTagPair.length == 2) {
|
||||
tagPairs.add(customTagPair);
|
||||
}
|
||||
if (detectDefaultTags) {
|
||||
tagPairs.addAll(defaultReasoningTagPairs);
|
||||
}
|
||||
|
||||
final segs = <ReasoningSegment>[];
|
||||
int index = 0;
|
||||
|
||||
while (index < content.length) {
|
||||
final nextDetails = content.indexOf('<details', index);
|
||||
|
||||
// Find earliest raw tag start among known pairs
|
||||
int nextRawStart = -1;
|
||||
List<String>? 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 <details> 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 <details ...>
|
||||
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 = <String, String>{};
|
||||
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('<details', i);
|
||||
final nextClose = content.indexOf('</details>', i);
|
||||
if (nextClose == -1 && nextOpen == -1) break;
|
||||
if (nextOpen != -1 && (nextClose == -1 || nextOpen < nextClose)) {
|
||||
depth++;
|
||||
i = nextOpen + 8; // '<details'
|
||||
} else {
|
||||
depth--;
|
||||
i = (nextClose != -1) ? nextClose + 10 : content.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReasoning) {
|
||||
// Not a reasoning block; keep entire block as text if closed,
|
||||
// else append remainder and stop (streaming/malformed)
|
||||
if (depth != 0) {
|
||||
segs.add(ReasoningSegment.text(content.substring(nextIdx)));
|
||||
break;
|
||||
} else {
|
||||
final full = content.substring(nextIdx, i);
|
||||
segs.add(ReasoningSegment.text(full));
|
||||
index = i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning block
|
||||
final done = (attrs['done'] ?? 'true') == 'true';
|
||||
final duration = int.tryParse(attrs['duration'] ?? '0') ?? 0;
|
||||
|
||||
if (depth != 0) {
|
||||
// Unclosed; treat as streaming partial
|
||||
final after = content.substring(openEnd + 1);
|
||||
final summaryMatch = RegExp(r'<summary>([^<]*)<\/summary>').firstMatch(after);
|
||||
final summary = (summaryMatch?.group(1) ?? '').trim();
|
||||
final reasoning = after
|
||||
.replaceAll(RegExp(r'^\s*<summary>[\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 </details>
|
||||
final sumMatch = RegExp(r'<summary>([^<]*)<\/summary>').firstMatch(inner);
|
||||
final summary = (sumMatch?.group(1) ?? '').trim();
|
||||
final reasoning = inner
|
||||
.replaceAll(RegExp(r'<summary>[\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('<details type="reasoning"')) return true;
|
||||
@@ -176,3 +373,41 @@ class ReasoningContent {
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight reasoning block for segmented rendering
|
||||
class ReasoningEntry {
|
||||
final String reasoning;
|
||||
final String summary;
|
||||
final int duration;
|
||||
final bool isDone;
|
||||
|
||||
const ReasoningEntry({
|
||||
required this.reasoning,
|
||||
required this.summary,
|
||||
required this.duration,
|
||||
required this.isDone,
|
||||
});
|
||||
|
||||
String get formattedDuration => 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user