diff --git a/lib/core/utils/tool_calls_parser.dart b/lib/core/utils/tool_calls_parser.dart index de99794..cab5655 100644 --- a/lib/core/utils/tool_calls_parser.dart +++ b/lib/core/utils/tool_calls_parser.dart @@ -39,112 +39,125 @@ class ToolCallsParser { static List? segments(String content) { if (content.isEmpty || !content.contains(']*)>\s*[^<]*<\/summary>\s*<\/details>', - multiLine: true, - dotAll: true, - ); - - final matches = detailsRegex.allMatches(content).toList(); - if (matches.isEmpty) return null; - final segs = []; - int lastEnd = 0; + int index = 0; - for (final m in matches) { - // Text before this block - if (m.start > lastEnd) { - segs.add(ToolCallsSegment.text(content.substring(lastEnd, m.start))); + while (index < content.length) { + final start = content.indexOf(' index) { + segs.add(ToolCallsSegment.text(content.substring(index, start))); + } - if (attrs.contains('type="tool_calls"')) { - String? _attr(String name) { - final r = RegExp('$name="([^"]*)"'); - final mm = r.firstMatch(attrs); - return mm != null ? _unescapeHtml(mm.group(1) ?? '') : null; + // Find end of opening tag + final openEnd = content.indexOf('>', start); + if (openEnd == -1) { + // Malformed; append rest as text + segs.add(ToolCallsSegment.text(content.substring(start))); + break; + } + final openTag = content.substring(start, openEnd + 1); + + // Find matching closing tag with nesting support + 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; // '' + } + } + + if (depth != 0) { + // Unclosed details; append the rest as text + segs.add(ToolCallsSegment.text(content.substring(start))); + break; + } + + final fullMatch = content.substring(start, i); + + // Parse attributes from opening tag + final attrs = {}; + final attrRegex = RegExp(r'(\w+)="(.*?)"'); + for (final m in attrRegex.allMatches(openTag)) { + attrs[m.group(1)!] = m.group(2) ?? ''; + } + + if ((attrs['type'] ?? '') == 'tool_calls') { + dynamic _decode(String? s) { + if (s == null || s.isEmpty) return null; + try { + return json.decode(s); + } catch (_) { + return s; + } } - final id = _attr('id') ?? ''; - final name = _attr('name') ?? 'tool'; - final done = (_attr('done') == 'true'); - final args = _tryDecodeJson(_attr('arguments')); - final result = _tryDecodeJson(_attr('result')); - final files = _tryDecodeJson(_attr('files')); + final id = (attrs['id'] ?? ''); + final name = (attrs['name'] ?? 'tool'); + final done = (attrs['done'] == 'true'); + final args = _decode(attrs['arguments']); + final result = _decode(attrs['result']); + final files = _decode(attrs['files']); - final entry = ToolCallEntry( - id: id.isNotEmpty ? id : '${name}_${m.start}', - name: name, - done: done, - arguments: args, - result: result, - files: (files is List) ? files : null, + segs.add( + ToolCallsSegment.entry( + ToolCallEntry( + id: id.isNotEmpty ? id : '${name}_$start', + name: name, + done: done, + arguments: args, + result: result, + files: (files is List) ? files as List : null, + ), + ), ); - segs.add(ToolCallsSegment.entry(entry)); } else { - // Not a tool_calls block: keep it as text segs.add(ToolCallsSegment.text(fullMatch)); } - lastEnd = m.end; + index = i; } - // Tail text - if (lastEnd < content.length) { - segs.add(ToolCallsSegment.text(content.substring(lastEnd))); - } - - return segs; + return segs.isEmpty ? null : segs; } + /// Extracts tool call blocks and returns the remaining content with those blocks removed. static ToolCallsContent? parse(String content) { if (content.isEmpty || !content.contains(']*)>\s*[^<]*<\/summary>\s*<\/details>', - multiLine: true, - dotAll: true, - ); - - final matches = detailsRegex.allMatches(content).toList(); - if (matches.isEmpty) return null; + final segs = segments(content); + if (segs == null) return null; final calls = []; - for (final m in matches) { - final attrs = m.group(1) ?? ''; - if (!attrs.contains('type="tool_calls"')) continue; - - String? _attr(String name) { - final r = RegExp('$name="([^"]*)"'); - final mm = r.firstMatch(attrs); - return mm != null ? _unescapeHtml(mm.group(1) ?? '') : null; + final buf = StringBuffer(); + for (final seg in segs) { + if (seg.isToolCall && seg.entry != null) { + calls.add(seg.entry!); + } else if (seg.text != null && seg.text!.isNotEmpty) { + buf.write(seg.text); } - - final id = _attr('id') ?? ''; - final name = _attr('name') ?? 'tool'; - final done = (_attr('done') == 'true'); - final args = _tryDecodeJson(_attr('arguments')); - final result = _tryDecodeJson(_attr('result')); - final files = _tryDecodeJson(_attr('files')); - - calls.add( - ToolCallEntry( - id: id.isNotEmpty ? id : '${name}_${m.start}', - name: name, - done: done, - arguments: args, - result: result, - files: (files is List) ? files : null, - ), - ); } if (calls.isEmpty) return null; - - final main = content.replaceAll(detailsRegex, '').trim(); - return ToolCallsContent(toolCalls: calls, mainContent: main, originalContent: content); + return ToolCallsContent( + toolCalls: calls, + mainContent: buf.toString().trim(), + originalContent: content, + ); } /// Legacy helper that summarizes tool blocks to text (kept for fallback) @@ -172,24 +185,6 @@ class ToolCallsParser { return buf.toString().trim(); } - static dynamic _tryDecodeJson(String? raw) { - if (raw == null || raw.trim().isEmpty) return null; - try { - dynamic decoded = json.decode(raw); - if (decoded is String) { - final s = decoded.trim(); - if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) { - try { - decoded = json.decode(s); - } catch (_) {} - } - } - return decoded; - } catch (_) { - return raw; - } - } - static String _prettyMaybe(dynamic value, {int max = 600}) { if (value == null) return ''; try { @@ -200,17 +195,6 @@ class ToolCallsParser { return raw.length > max ? raw.substring(0, max) + '…' : raw; } } - - static String _unescapeHtml(String input) { - return input - .replaceAll('"', '"') - .replaceAll('"', '"') - .replaceAll(''', "'") - .replaceAll(''', "'") - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('&', '&'); - } } /// Ordered piece of content: either plain text or a tool-call entry @@ -225,3 +209,4 @@ class ToolCallsSegment { bool get isToolCall => entry != null; } + diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index f98a268..d37f9f0 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:convert'; +import 'dart:async'; import 'dart:io' show Platform; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; @@ -47,6 +48,8 @@ class _AssistantMessageWidgetState extends ConsumerState final Set _expandedToolIds = {}; Widget? _cachedAvatar; String _contentSansDetails = ''; + bool _allowTypingIndicator = false; + Timer? _typingGateTimer; @override void initState() { @@ -62,6 +65,7 @@ class _AssistantMessageWidgetState extends ConsumerState // Parse reasoning and tool-calls sections _reparseSections(); + _updateTypingIndicatorGate(); } @override @@ -78,6 +82,7 @@ class _AssistantMessageWidgetState extends ConsumerState // Re-parse sections when message content changes if (oldWidget.message.content != widget.message.content) { _reparseSections(); + _updateTypingIndicatorGate(); } // Rebuild cached avatar if model name changes @@ -98,17 +103,91 @@ class _AssistantMessageWidgetState extends ConsumerState if (raw.startsWith(searchBanner)) { raw = raw.substring(searchBanner.length); } + // Do not truncate content during streaming; segmented parser will skip + // incomplete details blocks and tiles will render once complete. final rc = ReasoningParser.parseReasoningContent(raw); String base = rc?.mainContent ?? raw; final tools = ToolCallsParser.parse(base); - final segments = ToolCallsParser.segments(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 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)); + } + segments = fallbackSegs; + } + } setState(() { _reasoningContent = rc; _contentSansDetails = tools?.mainContent ?? base; _toolSegments = segments ?? [ToolCallsSegment.text(_contentSansDetails)]; }); + _updateTypingIndicatorGate(); + } + + void _updateTypingIndicatorGate() { + // Only show typing indicator if streaming and nothing renderable yet, + // and only after a short delay to avoid flicker when content arrives quickly. + _typingGateTimer?.cancel(); + final hasRenderable = _hasRenderableSegments; + final contentEmpty = (widget.message.content ?? '').trim().isEmpty; + if (widget.isStreaming && !hasRenderable && contentEmpty) { + _allowTypingIndicator = false; + _typingGateTimer = Timer(const Duration(milliseconds: 150), () { + if (mounted) { + setState(() { + _allowTypingIndicator = true; + }); + } + }); + } else { + _allowTypingIndicator = false; + } } // No streaming-specific markdown fixes needed here; handled by Markdown widget @@ -264,8 +343,14 @@ class _AssistantMessageWidgetState extends ConsumerState Widget _buildSegmentedContent() { final children = []; + bool firstToolSpacerAdded = false; for (final seg in _toolSegments) { if (seg.isToolCall && seg.entry != 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!)); } else if ((seg.text ?? '').trim().isNotEmpty) { children.add( @@ -326,6 +411,7 @@ class _AssistantMessageWidgetState extends ConsumerState @override void dispose() { + _typingGateTimer?.cancel(); _fadeController.dispose(); _slideController.dispose(); super.dispose(); @@ -470,17 +556,46 @@ class _AssistantMessageWidgetState extends ConsumerState ], // Tool calls are rendered inline via segmented content - - // If there are any renderable segments (tool calls or text), - // render them even during streaming to avoid showing the - // typing indicator underneath. - if (!_hasRenderableSegments && - widget.isStreaming && - (widget.message.content.trim().isEmpty || - widget.message.content == '[TYPING_INDICATOR]')) - _buildTypingIndicator() - else - _buildSegmentedContent(), + // Smoothly crossfade between typing indicator and content + AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, anim) { + final fade = CurvedAnimation( + parent: anim, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + final size = CurvedAnimation( + parent: anim, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return FadeTransition( + opacity: fade, + child: SizeTransition( + sizeFactor: size, + axisAlignment: -1.0, // collapse/expand from top + child: child, + ), + ); + }, + child: (!_hasRenderableSegments && + _allowTypingIndicator && + widget.isStreaming && + (widget.message.content.trim().isEmpty || + widget.message.content == + '[TYPING_INDICATOR]')) + ? KeyedSubtree( + key: const ValueKey('typing'), + child: _buildTypingIndicator(), + ) + : KeyedSubtree( + key: const ValueKey('content'), + child: _buildSegmentedContent(), + ), + ), ], ), ), @@ -508,9 +623,29 @@ class _AssistantMessageWidgetState extends ConsumerState return const SizedBox.shrink(); } - // Sanitize tool-call
blocks and process images - final toolSanitized = ToolCallsParser.summarize(content); - final processedContent = _processContentForImages(toolSanitized); + // For streaming, hide any tool_calls
blocks that may be incomplete + // to avoid showing raw tag text; tiles will render once blocks complete. + String cleaned = content; + if (widget.isStreaming) { + cleaned = cleaned.replaceAll( + RegExp( + r']*>[\s\S]*?<\/details>', + multiLine: true, + dotAll: true, + ), + '', + ); + final lastOpen = cleaned.lastIndexOf('= 0) { + final tail = cleaned.substring(lastOpen); + if (!tail.contains('
')) { + cleaned = cleaned.substring(0, lastOpen); + } + } + } + + // Process images in the remaining text + final processedContent = _processContentForImages(cleaned); return StreamingMarkdownWidget( staticContent: processedContent,