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;
|
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
|
/// Checks if a message contains reasoning content
|
||||||
static bool hasReasoningContent(String content) {
|
static bool hasReasoningContent(String content) {
|
||||||
if (content.contains('<details type="reasoning"')) return true;
|
if (content.contains('<details type="reasoning"')) return true;
|
||||||
@@ -176,3 +373,41 @@ class ReasoningContent {
|
|||||||
.trim();
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'dart:io' show Platform;
|
|||||||
import '../../../shared/theme/theme_extensions.dart';
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
import '../../../shared/widgets/markdown/streaming_markdown_widget.dart';
|
import '../../../shared/widgets/markdown/streaming_markdown_widget.dart';
|
||||||
import '../../../core/utils/reasoning_parser.dart';
|
import '../../../core/utils/reasoning_parser.dart';
|
||||||
|
import '../../../core/utils/message_segments.dart';
|
||||||
import '../../../core/utils/tool_calls_parser.dart';
|
import '../../../core/utils/tool_calls_parser.dart';
|
||||||
import 'enhanced_image_attachment.dart';
|
import 'enhanced_image_attachment.dart';
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
@@ -41,14 +42,13 @@ class AssistantMessageWidget extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
bool _showReasoning = false;
|
|
||||||
late AnimationController _fadeController;
|
late AnimationController _fadeController;
|
||||||
late AnimationController _slideController;
|
late AnimationController _slideController;
|
||||||
ReasoningContent? _reasoningContent;
|
// Unified content segments (text, tool-calls, reasoning)
|
||||||
List<ToolCallsSegment> _toolSegments = const [];
|
List<MessageSegment> _segments = const [];
|
||||||
final Set<String> _expandedToolIds = {};
|
final Set<String> _expandedToolIds = {};
|
||||||
|
final Set<int> _expandedReasoning = {};
|
||||||
Widget? _cachedAvatar;
|
Widget? _cachedAvatar;
|
||||||
String _contentSansDetails = '';
|
|
||||||
bool _allowTypingIndicator = false;
|
bool _allowTypingIndicator = false;
|
||||||
Timer? _typingGateTimer;
|
Timer? _typingGateTimer;
|
||||||
// press state handled by shared ChatActionButton
|
// press state handled by shared ChatActionButton
|
||||||
@@ -105,69 +105,53 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
if (raw.startsWith(searchBanner)) {
|
if (raw.startsWith(searchBanner)) {
|
||||||
raw = raw.substring(searchBanner.length);
|
raw = raw.substring(searchBanner.length);
|
||||||
}
|
}
|
||||||
// Do not truncate content during streaming; segmented parser will skip
|
// Do not truncate content during streaming; segmented parser skips
|
||||||
// incomplete details blocks and tiles will render once complete.
|
// incomplete details blocks and tiles will render once complete.
|
||||||
final rc = ReasoningParser.parseReasoningContent(raw);
|
final rSegs = ReasoningParser.segments(raw);
|
||||||
String base = rc?.mainContent ?? raw;
|
|
||||||
|
|
||||||
final tools = ToolCallsParser.parse(base);
|
final out = <MessageSegment>[];
|
||||||
List<ToolCallsSegment>? segments = ToolCallsParser.segments(base);
|
final textBuf = StringBuffer();
|
||||||
|
if (rSegs == null || rSegs.isEmpty) {
|
||||||
// Fallback: if parser failed but content has tool_calls details, synthesize segments
|
final tSegs = ToolCallsParser.segments(raw);
|
||||||
if ((segments == null || segments.isEmpty) && base.contains('<details') && base.contains('type="tool_calls"')) {
|
if (tSegs == null || tSegs.isEmpty) {
|
||||||
final fallbackSegs = <ToolCallsSegment>[];
|
out.add(MessageSegment.text(raw));
|
||||||
final detailsRegex = RegExp(r'<details[^>]*>([\s\S]*?)<\/details>', multiLine: true, dotAll: true);
|
textBuf.write(raw);
|
||||||
final attrRegex = RegExp(r'(\w+)="([^"]*)"');
|
} else {
|
||||||
final matches = detailsRegex.allMatches(base).toList();
|
for (final s in tSegs) {
|
||||||
String textRemainder = base;
|
if (s.isToolCall && s.entry != null) {
|
||||||
for (final m in matches) {
|
out.add(MessageSegment.tool(s.entry!));
|
||||||
final full = m.group(0) ?? '';
|
} else if ((s.text ?? '').isNotEmpty) {
|
||||||
final openTag = RegExp(r'<details[^>]*>').firstMatch(full)?.group(0) ?? '';
|
out.add(MessageSegment.text(s.text!));
|
||||||
if (!openTag.contains('type="tool_calls"')) continue;
|
textBuf.write(s.text);
|
||||||
final attrs = <String, String>{};
|
|
||||||
for (final am in attrRegex.allMatches(openTag)) {
|
|
||||||
attrs[am.group(1)!] = am.group(2) ?? '';
|
|
||||||
}
|
|
||||||
final id = attrs['id'] ?? '';
|
|
||||||
final name = attrs['name'] ?? 'tool';
|
|
||||||
final done = (attrs['done'] == 'true');
|
|
||||||
final args = attrs['arguments'];
|
|
||||||
final result = attrs['result'];
|
|
||||||
final files = attrs['files'];
|
|
||||||
|
|
||||||
dynamic decodeMaybe(String? s) {
|
|
||||||
if (s == null || s.isEmpty) return null;
|
|
||||||
try {
|
|
||||||
return json.decode(s);
|
|
||||||
} catch (_) {
|
|
||||||
return s;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final entry = ToolCallEntry(
|
|
||||||
id: id.isNotEmpty ? id : '${name}_${m.start}',
|
|
||||||
name: name,
|
|
||||||
done: done,
|
|
||||||
arguments: decodeMaybe(args),
|
|
||||||
result: decodeMaybe(result),
|
|
||||||
files: (decodeMaybe(files) is List) ? decodeMaybe(files) as List : null,
|
|
||||||
);
|
|
||||||
fallbackSegs.add(ToolCallsSegment.entry(entry));
|
|
||||||
textRemainder = textRemainder.replaceFirst(full, '');
|
|
||||||
}
|
}
|
||||||
if (fallbackSegs.isNotEmpty) {
|
} else {
|
||||||
final remainder = textRemainder.trim();
|
for (final rs in rSegs) {
|
||||||
if (remainder.isNotEmpty) {
|
if (rs.isReasoning && rs.entry != null) {
|
||||||
fallbackSegs.add(ToolCallsSegment.text(remainder));
|
out.add(MessageSegment.reason(rs.entry!));
|
||||||
|
} else if ((rs.text ?? '').isNotEmpty) {
|
||||||
|
final t = rs.text!;
|
||||||
|
final tSegs = ToolCallsParser.segments(t);
|
||||||
|
if (tSegs == null || tSegs.isEmpty) {
|
||||||
|
out.add(MessageSegment.text(t));
|
||||||
|
textBuf.write(t);
|
||||||
|
} else {
|
||||||
|
for (final s in tSegs) {
|
||||||
|
if (s.isToolCall && s.entry != null) {
|
||||||
|
out.add(MessageSegment.tool(s.entry!));
|
||||||
|
} else if ((s.text ?? '').isNotEmpty) {
|
||||||
|
out.add(MessageSegment.text(s.text!));
|
||||||
|
textBuf.write(s.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
segments = fallbackSegs;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_reasoningContent = rc;
|
_segments = out.isEmpty ? [MessageSegment.text(raw)] : out;
|
||||||
_contentSansDetails = tools?.mainContent ?? base;
|
|
||||||
_toolSegments = segments ?? [ToolCallsSegment.text(_contentSansDetails)];
|
|
||||||
});
|
});
|
||||||
_updateTypingIndicatorGate();
|
_updateTypingIndicatorGate();
|
||||||
}
|
}
|
||||||
@@ -345,19 +329,21 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
Widget _buildSegmentedContent() {
|
Widget _buildSegmentedContent() {
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
bool firstToolSpacerAdded = false;
|
bool firstToolSpacerAdded = false;
|
||||||
for (final seg in _toolSegments) {
|
int idx = 0;
|
||||||
if (seg.isToolCall && seg.entry != null) {
|
for (final seg in _segments) {
|
||||||
|
if (seg.isTool && seg.toolCall != null) {
|
||||||
// Add top spacing before the first tool block for clarity
|
// Add top spacing before the first tool block for clarity
|
||||||
if (!firstToolSpacerAdded) {
|
if (!firstToolSpacerAdded) {
|
||||||
children.add(const SizedBox(height: Spacing.sm));
|
children.add(const SizedBox(height: Spacing.sm));
|
||||||
firstToolSpacerAdded = true;
|
firstToolSpacerAdded = true;
|
||||||
}
|
}
|
||||||
children.add(_buildToolCallTile(seg.entry!));
|
children.add(_buildToolCallTile(seg.toolCall!));
|
||||||
|
} else if (seg.isReasoning && seg.reasoning != null) {
|
||||||
|
children.add(_buildReasoningTile(seg.reasoning!, idx));
|
||||||
} else if ((seg.text ?? '').trim().isNotEmpty) {
|
} else if ((seg.text ?? '').trim().isNotEmpty) {
|
||||||
children.add(
|
children.add(_buildEnhancedMarkdownContent(seg.text!));
|
||||||
_buildEnhancedMarkdownContent(seg.text!),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
idx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (children.isEmpty) return const SizedBox.shrink();
|
if (children.isEmpty) return const SizedBox.shrink();
|
||||||
@@ -379,6 +365,15 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
),
|
),
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
|
// Hide reasoning blocks as well in text check
|
||||||
|
cleaned = cleaned.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'<details\s+type="reasoning"[^>]*>[\s\S]*?<\/details>',
|
||||||
|
multiLine: true,
|
||||||
|
dotAll: true,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
// If last <details> is unclosed, drop tail to avoid rendering raw tag
|
// If last <details> is unclosed, drop tail to avoid rendering raw tag
|
||||||
final lastOpen = cleaned.lastIndexOf('<details');
|
final lastOpen = cleaned.lastIndexOf('<details');
|
||||||
if (lastOpen >= 0) {
|
if (lastOpen >= 0) {
|
||||||
@@ -390,8 +385,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
return cleaned.trim().isNotEmpty;
|
return cleaned.trim().isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final seg in _toolSegments) {
|
for (final seg in _segments) {
|
||||||
if (seg.isToolCall && seg.entry != null) return true;
|
if (seg.isTool && seg.toolCall != null) return true;
|
||||||
|
if (seg.isReasoning && seg.reasoning != null) return true;
|
||||||
final text = seg.text ?? '';
|
final text = seg.text ?? '';
|
||||||
if (_textRenderable(text)) return true;
|
if (_textRenderable(text)) return true;
|
||||||
}
|
}
|
||||||
@@ -454,108 +450,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
// Cached AI Name and Avatar to prevent flashing
|
// Cached AI Name and Avatar to prevent flashing
|
||||||
_cachedAvatar ?? const SizedBox.shrink(),
|
_cachedAvatar ?? const SizedBox.shrink(),
|
||||||
|
|
||||||
// Reasoning Section (if present)
|
// Reasoning blocks are now rendered inline where they appear
|
||||||
if (_reasoningContent != null) ...[
|
|
||||||
InkWell(
|
|
||||||
onTap: () => setState(() => _showReasoning = !_showReasoning),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: Spacing.sm,
|
|
||||||
vertical: Spacing.xs,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.surfaceContainer.withValues(
|
|
||||||
alpha: 0.5,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
||||||
border: Border.all(
|
|
||||||
color: context.conduitTheme.dividerColor,
|
|
||||||
width: BorderWidth.thin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_showReasoning
|
|
||||||
? Icons.expand_less_rounded
|
|
||||||
: Icons.expand_more_rounded,
|
|
||||||
size: 16,
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.xs),
|
|
||||||
Icon(
|
|
||||||
Icons.psychology_outlined,
|
|
||||||
size: 14,
|
|
||||||
color: context.conduitTheme.buttonPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
() {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final rc = _reasoningContent!;
|
|
||||||
final hasSummary = rc.summary.isNotEmpty;
|
|
||||||
final isThinkingSummary = rc.summary.trim().toLowerCase() == 'thinking…' || rc.summary.trim().toLowerCase() == 'thinking...';
|
|
||||||
if (widget.isStreaming) {
|
|
||||||
// During streaming, prefer showing Thinking…
|
|
||||||
return hasSummary ? rc.summary : l10n.thinking;
|
|
||||||
}
|
|
||||||
// After streaming ends:
|
|
||||||
if (rc.duration > 0) {
|
|
||||||
return l10n.thoughtForDuration(rc.formattedDuration);
|
|
||||||
}
|
|
||||||
// If summary was just the placeholder 'Thinking…', replace with a neutral title
|
|
||||||
if (!hasSummary || isThinkingSummary) {
|
|
||||||
return l10n.thoughts;
|
|
||||||
}
|
|
||||||
return rc.summary;
|
|
||||||
}(),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: AppTypography.bodySmall,
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Expandable reasoning content
|
|
||||||
AnimatedCrossFade(
|
|
||||||
firstChild: const SizedBox.shrink(),
|
|
||||||
secondChild: Container(
|
|
||||||
margin: const EdgeInsets.only(top: Spacing.sm),
|
|
||||||
padding: const EdgeInsets.all(Spacing.sm),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.surfaceContainer.withValues(
|
|
||||||
alpha: 0.3,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
||||||
border: Border.all(
|
|
||||||
color: context.conduitTheme.dividerColor,
|
|
||||||
width: BorderWidth.thin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SelectableText(
|
|
||||||
_reasoningContent!.cleanedReasoning,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: AppTypography.bodySmall,
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
crossFadeState: _showReasoning
|
|
||||||
? CrossFadeState.showSecond
|
|
||||||
: CrossFadeState.showFirst,
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 0),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Documentation-style content without heavy bubble; premium markdown
|
// Documentation-style content without heavy bubble; premium markdown
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -603,12 +498,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: (!_hasRenderableSegments &&
|
child: (widget.isStreaming &&
|
||||||
_allowTypingIndicator &&
|
!_hasRenderableSegments &&
|
||||||
widget.isStreaming &&
|
_allowTypingIndicator)
|
||||||
(widget.message.content.trim().isEmpty ||
|
|
||||||
widget.message.content ==
|
|
||||||
'[TYPING_INDICATOR]'))
|
|
||||||
? KeyedSubtree(
|
? KeyedSubtree(
|
||||||
key: const ValueKey('typing'),
|
key: const ValueKey('typing'),
|
||||||
child: _buildTypingIndicator(),
|
child: _buildTypingIndicator(),
|
||||||
@@ -654,6 +546,19 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
),
|
),
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
|
// Also hide reasoning details blocks if any slipped into text
|
||||||
|
cleaned = cleaned.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'<details\s+type="reasoning"[^>]*>[\s\S]*?<\/details>',
|
||||||
|
multiLine: true,
|
||||||
|
dotAll: true,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
// Remove raw <think>...</think> or <reasoning>...</reasoning> tags in text
|
||||||
|
cleaned = cleaned
|
||||||
|
.replaceAll(RegExp(r'<think>[\s\S]*?<\/think>', multiLine: true, dotAll: true), '')
|
||||||
|
.replaceAll(RegExp(r'<reasoning>[\s\S]*?<\/reasoning>', multiLine: true, dotAll: true), '');
|
||||||
|
|
||||||
// If there's an unclosed <details>, drop the tail to avoid raw tags.
|
// If there's an unclosed <details>, drop the tail to avoid raw tags.
|
||||||
final lastOpen = cleaned.lastIndexOf('<details');
|
final lastOpen = cleaned.lastIndexOf('<details');
|
||||||
@@ -907,4 +812,121 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
}) {
|
}) {
|
||||||
return ChatActionButton(icon: icon, label: label, onTap: onTap);
|
return ChatActionButton(icon: icon, label: label, onTap: onTap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reasoning tile rendered inline at the position it appears
|
||||||
|
Widget _buildReasoningTile(ReasoningEntry rc, int index) {
|
||||||
|
final isExpanded = _expandedReasoning.contains(index);
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
|
||||||
|
String headerText() {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final hasSummary = rc.summary.isNotEmpty;
|
||||||
|
final isThinkingSummary = rc.summary.trim().toLowerCase() == 'thinking…' ||
|
||||||
|
rc.summary.trim().toLowerCase() == 'thinking...';
|
||||||
|
if (widget.isStreaming) {
|
||||||
|
return hasSummary ? rc.summary : l10n.thinking;
|
||||||
|
}
|
||||||
|
if (rc.duration > 0) {
|
||||||
|
return l10n.thoughtForDuration(rc.formattedDuration);
|
||||||
|
}
|
||||||
|
if (!hasSummary || isThinkingSummary) {
|
||||||
|
return l10n.thoughts;
|
||||||
|
}
|
||||||
|
return rc.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: Spacing.xs),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
if (isExpanded) {
|
||||||
|
_expandedReasoning.remove(index);
|
||||||
|
} else {
|
||||||
|
_expandedReasoning.add(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.sm,
|
||||||
|
vertical: Spacing.xs,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.surfaceContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
width: BorderWidth.thin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isExpanded
|
||||||
|
? Icons.expand_less_rounded
|
||||||
|
: Icons.expand_more_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
Icon(
|
||||||
|
Icons.psychology_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: theme.buttonPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
headerText(),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppTypography.bodySmall,
|
||||||
|
color: theme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
AnimatedCrossFade(
|
||||||
|
firstChild: const SizedBox.shrink(),
|
||||||
|
secondChild: Container(
|
||||||
|
margin: const EdgeInsets.only(top: Spacing.sm),
|
||||||
|
padding: const EdgeInsets.all(Spacing.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.surfaceContainer.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
width: BorderWidth.thin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
rc.cleanedReasoning,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppTypography.bodySmall,
|
||||||
|
color: theme.textSecondary,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
crossFadeState:
|
||||||
|
isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user