refactor: improve ToolCallsParser for better handling of nested details and enhance typing indicator in AssistantMessageWidget
This commit is contained in:
@@ -39,112 +39,125 @@ class ToolCallsParser {
|
|||||||
static List<ToolCallsSegment>? segments(String content) {
|
static List<ToolCallsSegment>? segments(String content) {
|
||||||
if (content.isEmpty || !content.contains('<details')) return null;
|
if (content.isEmpty || !content.contains('<details')) return null;
|
||||||
|
|
||||||
final detailsRegex = RegExp(
|
|
||||||
r'<details\b([^>]*)>\s*<summary>[^<]*<\/summary>\s*<\/details>',
|
|
||||||
multiLine: true,
|
|
||||||
dotAll: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
final matches = detailsRegex.allMatches(content).toList();
|
|
||||||
if (matches.isEmpty) return null;
|
|
||||||
|
|
||||||
final segs = <ToolCallsSegment>[];
|
final segs = <ToolCallsSegment>[];
|
||||||
int lastEnd = 0;
|
int index = 0;
|
||||||
|
|
||||||
for (final m in matches) {
|
while (index < content.length) {
|
||||||
// Text before this block
|
final start = content.indexOf('<details', index);
|
||||||
if (m.start > lastEnd) {
|
if (start == -1) {
|
||||||
segs.add(ToolCallsSegment.text(content.substring(lastEnd, m.start)));
|
if (index < content.length) {
|
||||||
|
segs.add(ToolCallsSegment.text(content.substring(index)));
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
final fullMatch = m.group(0) ?? '';
|
// Text before the block
|
||||||
final attrs = m.group(1) ?? '';
|
if (start > 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final id = _attr('id') ?? '';
|
// Find end of opening tag
|
||||||
final name = _attr('name') ?? 'tool';
|
final openEnd = content.indexOf('>', start);
|
||||||
final done = (_attr('done') == 'true');
|
if (openEnd == -1) {
|
||||||
final args = _tryDecodeJson(_attr('arguments'));
|
// Malformed; append rest as text
|
||||||
final result = _tryDecodeJson(_attr('result'));
|
segs.add(ToolCallsSegment.text(content.substring(start)));
|
||||||
final files = _tryDecodeJson(_attr('files'));
|
break;
|
||||||
|
}
|
||||||
|
final openTag = content.substring(start, openEnd + 1);
|
||||||
|
|
||||||
final entry = ToolCallEntry(
|
// Find matching closing tag with nesting support
|
||||||
id: id.isNotEmpty ? id : '${name}_${m.start}',
|
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; // '</details>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = <String, String>{};
|
||||||
|
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 = (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']);
|
||||||
|
|
||||||
|
segs.add(
|
||||||
|
ToolCallsSegment.entry(
|
||||||
|
ToolCallEntry(
|
||||||
|
id: id.isNotEmpty ? id : '${name}_$start',
|
||||||
name: name,
|
name: name,
|
||||||
done: done,
|
done: done,
|
||||||
arguments: args,
|
arguments: args,
|
||||||
result: result,
|
result: result,
|
||||||
files: (files is List) ? files : null,
|
files: (files is List) ? files as List : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
segs.add(ToolCallsSegment.entry(entry));
|
|
||||||
} else {
|
} else {
|
||||||
// Not a tool_calls block: keep it as text
|
|
||||||
segs.add(ToolCallsSegment.text(fullMatch));
|
segs.add(ToolCallsSegment.text(fullMatch));
|
||||||
}
|
}
|
||||||
|
|
||||||
lastEnd = m.end;
|
index = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tail text
|
return segs.isEmpty ? null : segs;
|
||||||
if (lastEnd < content.length) {
|
|
||||||
segs.add(ToolCallsSegment.text(content.substring(lastEnd)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return segs;
|
|
||||||
}
|
|
||||||
/// Extracts tool call blocks and returns the remaining content with those blocks removed.
|
/// Extracts tool call blocks and returns the remaining content with those blocks removed.
|
||||||
static ToolCallsContent? parse(String content) {
|
static ToolCallsContent? parse(String content) {
|
||||||
if (content.isEmpty || !content.contains('<details')) return null;
|
if (content.isEmpty || !content.contains('<details')) return null;
|
||||||
|
|
||||||
final detailsRegex = RegExp(
|
final segs = segments(content);
|
||||||
r'<details\b([^>]*)>\s*<summary>[^<]*<\/summary>\s*<\/details>',
|
if (segs == null) return null;
|
||||||
multiLine: true,
|
|
||||||
dotAll: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
final matches = detailsRegex.allMatches(content).toList();
|
|
||||||
if (matches.isEmpty) return null;
|
|
||||||
|
|
||||||
final calls = <ToolCallEntry>[];
|
final calls = <ToolCallEntry>[];
|
||||||
for (final m in matches) {
|
final buf = StringBuffer();
|
||||||
final attrs = m.group(1) ?? '';
|
for (final seg in segs) {
|
||||||
if (!attrs.contains('type="tool_calls"')) continue;
|
if (seg.isToolCall && seg.entry != null) {
|
||||||
|
calls.add(seg.entry!);
|
||||||
String? _attr(String name) {
|
} else if (seg.text != null && seg.text!.isNotEmpty) {
|
||||||
final r = RegExp('$name="([^"]*)"');
|
buf.write(seg.text);
|
||||||
final mm = r.firstMatch(attrs);
|
|
||||||
return mm != null ? _unescapeHtml(mm.group(1) ?? '') : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
if (calls.isEmpty) return null;
|
||||||
|
return ToolCallsContent(
|
||||||
final main = content.replaceAll(detailsRegex, '').trim();
|
toolCalls: calls,
|
||||||
return ToolCallsContent(toolCalls: calls, mainContent: main, originalContent: content);
|
mainContent: buf.toString().trim(),
|
||||||
|
originalContent: content,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy helper that summarizes tool blocks to text (kept for fallback)
|
/// Legacy helper that summarizes tool blocks to text (kept for fallback)
|
||||||
@@ -172,24 +185,6 @@ class ToolCallsParser {
|
|||||||
return buf.toString().trim();
|
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}) {
|
static String _prettyMaybe(dynamic value, {int max = 600}) {
|
||||||
if (value == null) return '';
|
if (value == null) return '';
|
||||||
try {
|
try {
|
||||||
@@ -200,17 +195,6 @@ class ToolCallsParser {
|
|||||||
return raw.length > max ? raw.substring(0, max) + '…' : raw;
|
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
|
/// Ordered piece of content: either plain text or a tool-call entry
|
||||||
@@ -225,3 +209,4 @@ class ToolCallsSegment {
|
|||||||
|
|
||||||
bool get isToolCall => entry != null;
|
bool get isToolCall => entry != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io' show Platform;
|
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';
|
||||||
@@ -47,6 +48,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
final Set<String> _expandedToolIds = {};
|
final Set<String> _expandedToolIds = {};
|
||||||
Widget? _cachedAvatar;
|
Widget? _cachedAvatar;
|
||||||
String _contentSansDetails = '';
|
String _contentSansDetails = '';
|
||||||
|
bool _allowTypingIndicator = false;
|
||||||
|
Timer? _typingGateTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -62,6 +65,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
|
|
||||||
// Parse reasoning and tool-calls sections
|
// Parse reasoning and tool-calls sections
|
||||||
_reparseSections();
|
_reparseSections();
|
||||||
|
_updateTypingIndicatorGate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,6 +82,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
// Re-parse sections when message content changes
|
// Re-parse sections when message content changes
|
||||||
if (oldWidget.message.content != widget.message.content) {
|
if (oldWidget.message.content != widget.message.content) {
|
||||||
_reparseSections();
|
_reparseSections();
|
||||||
|
_updateTypingIndicatorGate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild cached avatar if model name changes
|
// Rebuild cached avatar if model name changes
|
||||||
@@ -98,17 +103,91 @@ 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
|
||||||
|
// incomplete details blocks and tiles will render once complete.
|
||||||
final rc = ReasoningParser.parseReasoningContent(raw);
|
final rc = ReasoningParser.parseReasoningContent(raw);
|
||||||
String base = rc?.mainContent ?? raw;
|
String base = rc?.mainContent ?? raw;
|
||||||
|
|
||||||
final tools = ToolCallsParser.parse(base);
|
final tools = ToolCallsParser.parse(base);
|
||||||
final segments = ToolCallsParser.segments(base);
|
List<ToolCallsSegment>? segments = ToolCallsParser.segments(base);
|
||||||
|
|
||||||
|
// Fallback: if parser failed but content has tool_calls details, synthesize segments
|
||||||
|
if ((segments == null || segments.isEmpty) && base.contains('<details') && base.contains('type="tool_calls"')) {
|
||||||
|
final fallbackSegs = <ToolCallsSegment>[];
|
||||||
|
final detailsRegex = RegExp(r'<details[^>]*>([\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'<details[^>]*>').firstMatch(full)?.group(0) ?? '';
|
||||||
|
if (!openTag.contains('type="tool_calls"')) continue;
|
||||||
|
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) {
|
||||||
|
final remainder = textRemainder.trim();
|
||||||
|
if (remainder.isNotEmpty) {
|
||||||
|
fallbackSegs.add(ToolCallsSegment.text(remainder));
|
||||||
|
}
|
||||||
|
segments = fallbackSegs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_reasoningContent = rc;
|
_reasoningContent = rc;
|
||||||
_contentSansDetails = tools?.mainContent ?? base;
|
_contentSansDetails = tools?.mainContent ?? base;
|
||||||
_toolSegments = segments ?? [ToolCallsSegment.text(_contentSansDetails)];
|
_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
|
// No streaming-specific markdown fixes needed here; handled by Markdown widget
|
||||||
@@ -264,8 +343,14 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
|
|
||||||
Widget _buildSegmentedContent() {
|
Widget _buildSegmentedContent() {
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
|
bool firstToolSpacerAdded = false;
|
||||||
for (final seg in _toolSegments) {
|
for (final seg in _toolSegments) {
|
||||||
if (seg.isToolCall && seg.entry != null) {
|
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!));
|
children.add(_buildToolCallTile(seg.entry!));
|
||||||
} else if ((seg.text ?? '').trim().isNotEmpty) {
|
} else if ((seg.text ?? '').trim().isNotEmpty) {
|
||||||
children.add(
|
children.add(
|
||||||
@@ -326,6 +411,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_typingGateTimer?.cancel();
|
||||||
_fadeController.dispose();
|
_fadeController.dispose();
|
||||||
_slideController.dispose();
|
_slideController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -470,17 +556,46 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Tool calls are rendered inline via segmented content
|
// Tool calls are rendered inline via segmented content
|
||||||
|
// Smoothly crossfade between typing indicator and content
|
||||||
// If there are any renderable segments (tool calls or text),
|
AnimatedSwitcher(
|
||||||
// render them even during streaming to avoid showing the
|
duration: const Duration(milliseconds: 220),
|
||||||
// typing indicator underneath.
|
switchInCurve: Curves.easeOutCubic,
|
||||||
if (!_hasRenderableSegments &&
|
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.isStreaming &&
|
||||||
(widget.message.content.trim().isEmpty ||
|
(widget.message.content.trim().isEmpty ||
|
||||||
widget.message.content == '[TYPING_INDICATOR]'))
|
widget.message.content ==
|
||||||
_buildTypingIndicator()
|
'[TYPING_INDICATOR]'))
|
||||||
else
|
? KeyedSubtree(
|
||||||
_buildSegmentedContent(),
|
key: const ValueKey('typing'),
|
||||||
|
child: _buildTypingIndicator(),
|
||||||
|
)
|
||||||
|
: KeyedSubtree(
|
||||||
|
key: const ValueKey('content'),
|
||||||
|
child: _buildSegmentedContent(),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -508,9 +623,29 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize tool-call <details> blocks and process images
|
// For streaming, hide any tool_calls <details> blocks that may be incomplete
|
||||||
final toolSanitized = ToolCallsParser.summarize(content);
|
// to avoid showing raw tag text; tiles will render once blocks complete.
|
||||||
final processedContent = _processContentForImages(toolSanitized);
|
String cleaned = content;
|
||||||
|
if (widget.isStreaming) {
|
||||||
|
cleaned = cleaned.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'<details\s+type="tool_calls"[^>]*>[\s\S]*?<\/details>',
|
||||||
|
multiLine: true,
|
||||||
|
dotAll: true,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
final lastOpen = cleaned.lastIndexOf('<details');
|
||||||
|
if (lastOpen >= 0) {
|
||||||
|
final tail = cleaned.substring(lastOpen);
|
||||||
|
if (!tail.contains('</details>')) {
|
||||||
|
cleaned = cleaned.substring(0, lastOpen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process images in the remaining text
|
||||||
|
final processedContent = _processContentForImages(cleaned);
|
||||||
|
|
||||||
return StreamingMarkdownWidget(
|
return StreamingMarkdownWidget(
|
||||||
staticContent: processedContent,
|
staticContent: processedContent,
|
||||||
|
|||||||
Reference in New Issue
Block a user