diff --git a/lib/core/utils/message_segments.dart b/lib/core/utils/message_segments.dart new file mode 100644 index 0000000..8891530 --- /dev/null +++ b/lib/core/utils/message_segments.dart @@ -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; +} diff --git a/lib/core/utils/reasoning_parser.dart b/lib/core/utils/reasoning_parser.dart index 17fa311..4871111 100644 --- a/lib/core/utils/reasoning_parser.dart +++ b/lib/core/utils/reasoning_parser.dart @@ -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 `
` 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('
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; +} diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 3dd2cc0..f9d93e9 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -8,6 +8,7 @@ import 'dart:io' show Platform; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; import '../../../core/utils/reasoning_parser.dart'; +import '../../../core/utils/message_segments.dart'; import '../../../core/utils/tool_calls_parser.dart'; import 'enhanced_image_attachment.dart'; import 'package:conduit/l10n/app_localizations.dart'; @@ -41,14 +42,13 @@ class AssistantMessageWidget extends ConsumerStatefulWidget { class _AssistantMessageWidgetState extends ConsumerState with TickerProviderStateMixin { - bool _showReasoning = false; late AnimationController _fadeController; late AnimationController _slideController; - ReasoningContent? _reasoningContent; - List _toolSegments = const []; + // Unified content segments (text, tool-calls, reasoning) + List _segments = const []; final Set _expandedToolIds = {}; + final Set _expandedReasoning = {}; Widget? _cachedAvatar; - String _contentSansDetails = ''; bool _allowTypingIndicator = false; Timer? _typingGateTimer; // press state handled by shared ChatActionButton @@ -105,69 +105,53 @@ class _AssistantMessageWidgetState extends ConsumerState if (raw.startsWith(searchBanner)) { 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. - final rc = ReasoningParser.parseReasoningContent(raw); - String base = rc?.mainContent ?? raw; + final rSegs = ReasoningParser.segments(raw); - final tools = ToolCallsParser.parse(base); - List? segments = ToolCallsParser.segments(base); - - // Fallback: if parser failed but content has tool_calls details, synthesize segments - if ((segments == null || segments.isEmpty) && base.contains('[]; - final detailsRegex = RegExp(r']*>([\s\S]*?)<\/details>', multiLine: true, dotAll: true); - final attrRegex = RegExp(r'(\w+)="([^"]*)"'); - final matches = detailsRegex.allMatches(base).toList(); - String textRemainder = base; - for (final m in matches) { - final full = m.group(0) ?? ''; - final openTag = RegExp(r']*>').firstMatch(full)?.group(0) ?? ''; - if (!openTag.contains('type="tool_calls"')) continue; - final attrs = {}; - 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 out = []; + final textBuf = StringBuffer(); + if (rSegs == null || rSegs.isEmpty) { + final tSegs = ToolCallsParser.segments(raw); + if (tSegs == null || tSegs.isEmpty) { + out.add(MessageSegment.text(raw)); + textBuf.write(raw); + } 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); } } - - 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) { - final remainder = textRemainder.trim(); - if (remainder.isNotEmpty) { - fallbackSegs.add(ToolCallsSegment.text(remainder)); + } else { + for (final rs in rSegs) { + if (rs.isReasoning && rs.entry != null) { + 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(() { - _reasoningContent = rc; - _contentSansDetails = tools?.mainContent ?? base; - _toolSegments = segments ?? [ToolCallsSegment.text(_contentSansDetails)]; + _segments = out.isEmpty ? [MessageSegment.text(raw)] : out; }); _updateTypingIndicatorGate(); } @@ -345,19 +329,21 @@ class _AssistantMessageWidgetState extends ConsumerState Widget _buildSegmentedContent() { final children = []; bool firstToolSpacerAdded = false; - for (final seg in _toolSegments) { - if (seg.isToolCall && seg.entry != null) { + int idx = 0; + for (final seg in _segments) { + if (seg.isTool && seg.toolCall != null) { // Add top spacing before the first tool block for clarity if (!firstToolSpacerAdded) { children.add(const SizedBox(height: Spacing.sm)); 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) { - children.add( - _buildEnhancedMarkdownContent(seg.text!), - ); + children.add(_buildEnhancedMarkdownContent(seg.text!)); } + idx++; } if (children.isEmpty) return const SizedBox.shrink(); @@ -379,6 +365,15 @@ class _AssistantMessageWidgetState extends ConsumerState ), '', ); + // Hide reasoning blocks as well in text check + cleaned = cleaned.replaceAll( + RegExp( + r']*>[\s\S]*?<\/details>', + multiLine: true, + dotAll: true, + ), + '', + ); // If last
is unclosed, drop tail to avoid rendering raw tag final lastOpen = cleaned.lastIndexOf('= 0) { @@ -390,8 +385,9 @@ class _AssistantMessageWidgetState extends ConsumerState return cleaned.trim().isNotEmpty; } - for (final seg in _toolSegments) { - if (seg.isToolCall && seg.entry != null) return true; + for (final seg in _segments) { + if (seg.isTool && seg.toolCall != null) return true; + if (seg.isReasoning && seg.reasoning != null) return true; final text = seg.text ?? ''; if (_textRenderable(text)) return true; } @@ -454,108 +450,7 @@ class _AssistantMessageWidgetState extends ConsumerState // Cached AI Name and Avatar to prevent flashing _cachedAvatar ?? const SizedBox.shrink(), - // Reasoning Section (if present) - 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), - ], + // Reasoning blocks are now rendered inline where they appear // Documentation-style content without heavy bubble; premium markdown SizedBox( @@ -603,12 +498,9 @@ class _AssistantMessageWidgetState extends ConsumerState ), ); }, - child: (!_hasRenderableSegments && - _allowTypingIndicator && - widget.isStreaming && - (widget.message.content.trim().isEmpty || - widget.message.content == - '[TYPING_INDICATOR]')) + child: (widget.isStreaming && + !_hasRenderableSegments && + _allowTypingIndicator) ? KeyedSubtree( key: const ValueKey('typing'), child: _buildTypingIndicator(), @@ -654,6 +546,19 @@ class _AssistantMessageWidgetState extends ConsumerState ), '', ); + // Also hide reasoning details blocks if any slipped into text + cleaned = cleaned.replaceAll( + RegExp( + r']*>[\s\S]*?<\/details>', + multiLine: true, + dotAll: true, + ), + '', + ); + // Remove raw ... or ... tags in text + cleaned = cleaned + .replaceAll(RegExp(r'[\s\S]*?<\/think>', multiLine: true, dotAll: true), '') + .replaceAll(RegExp(r'[\s\S]*?<\/reasoning>', multiLine: true, dotAll: true), ''); // If there's an unclosed
, drop the tail to avoid raw tags. final lastOpen = cleaned.lastIndexOf(' }) { 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), + ), + ], + ), + ), + ), + ); + } }