fix: stabilize chat run lifecycle rendering

This commit is contained in:
qzl
2026-03-17 15:58:29 +08:00
parent 3bf7640000
commit cf56b358ad
5 changed files with 673 additions and 75 deletions
@@ -13,6 +13,34 @@ typedef EventCallback = void Function(AgUiEvent event);
const _runIdPrefix = 'run_';
class UploadedAttachment {
const UploadedAttachment({
required this.localPath,
required this.url,
required this.mimeType,
});
final String localPath;
final String url;
final String mimeType;
}
class SendMessageResult {
const SendMessageResult({required this.uploadedAttachments});
final List<UploadedAttachment> uploadedAttachments;
}
class _RunInputPayload {
const _RunInputPayload({
required this.input,
required this.uploadedAttachments,
});
final Map<String, dynamic> input;
final List<UploadedAttachment> uploadedAttachments;
}
class AgUiService {
final IApiClient _apiClient;
EventCallback onEvent;
@@ -26,23 +54,40 @@ class AgUiService {
: onEvent = onEvent ?? ((_) {}),
_apiClient = apiClient;
Future<void> sendMessage(String content, {List<XFile>? images}) async {
Future<SendMessageResult> sendMessage(
String content, {
List<XFile>? images,
}) async {
final streamToken = ++_activeStreamToken;
final runInput = await _buildRunInput(content: content, images: images);
final runInputPayload = await _buildRunInput(
content: content,
images: images,
);
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs',
data: runInput,
data: runInputPayload.input,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/runs response');
}
final threadId = payload['threadId'] as String?;
final runId = payload['runId'] as String?;
if (threadId == null || threadId.isEmpty) {
throw StateError('Missing threadId in /agent/runs response');
}
if (runId == null || runId.isEmpty) {
throw StateError('Missing runId in /agent/runs response');
}
_threadId = threadId;
await _streamEventsFromApi(threadId, streamToken: streamToken);
await _streamEventsFromApi(
threadId,
expectedRunId: runId,
streamToken: streamToken,
);
return SendMessageResult(
uploadedAttachments: runInputPayload.uploadedAttachments,
);
}
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
@@ -108,6 +153,7 @@ class AgUiService {
Future<void> _streamEventsFromApi(
String threadId, {
required String expectedRunId,
required int streamToken,
}) async {
final lastEventId = _lastEventIdByThread[threadId];
@@ -122,6 +168,7 @@ class AgUiService {
String? eventType;
String? eventId;
var hasBoundExpectedRun = false;
final dataBuffer = StringBuffer();
await for (final line in sseLines) {
if (streamToken != _activeStreamToken) {
@@ -131,11 +178,32 @@ class AgUiService {
if (dataBuffer.isNotEmpty) {
final raw = dataBuffer.toString();
dataBuffer.clear();
Map<String, dynamic>? decoded;
String? eventRunId;
String? eventThreadId;
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
final event = AgUiEvent.fromJson(decoded);
onEvent(event);
final parsed = jsonDecode(raw);
if (parsed is Map<String, dynamic>) {
decoded = parsed;
final runId = parsed['runId'];
final thread = parsed['threadId'];
eventRunId = runId is String ? runId : null;
eventThreadId = thread is String ? thread : null;
final isRunStarted = eventType == AgUiEventTypeWire.runStarted;
final isTargetRun = eventRunId == expectedRunId;
if (isRunStarted && isTargetRun) {
hasBoundExpectedRun = true;
}
final isThreadMatched =
eventThreadId == null || eventThreadId == threadId;
final shouldDispatch =
isTargetRun || (hasBoundExpectedRun && isThreadMatched);
if (shouldDispatch) {
final event = AgUiEvent.fromJson(parsed);
onEvent(event);
}
}
} catch (_) {
// Ignore malformed SSE payload and keep stream alive.
@@ -144,8 +212,14 @@ class AgUiService {
if (currentEventId != null && currentEventId.isNotEmpty) {
_lastEventIdByThread[threadId] = currentEventId;
}
if (eventType == AgUiEventTypeWire.runFinished ||
eventType == AgUiEventTypeWire.runError) {
final isTerminalEvent =
eventType == AgUiEventTypeWire.runFinished ||
eventType == AgUiEventTypeWire.runError;
final isTargetRun = eventRunId == expectedRunId;
final isThreadMatched =
eventThreadId == null || eventThreadId == threadId;
if (isTerminalEvent &&
(isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
break;
}
}
@@ -174,7 +248,7 @@ class AgUiService {
}
}
Future<Map<String, dynamic>> _buildRunInput({
Future<_RunInputPayload> _buildRunInput({
required String content,
List<XFile>? images,
}) async {
@@ -187,16 +261,17 @@ class AgUiService {
contentBlocks.add({'type': 'text', 'text': content});
}
var uploadedAttachments = const <UploadedAttachment>[];
if (images != null && images.isNotEmpty) {
final uploadedAttachments = await _uploadAttachments(
uploadedAttachments = await _uploadAttachments(
threadId: threadId,
images: images,
);
for (final attachment in uploadedAttachments) {
contentBlocks.add({
'type': 'binary',
'mimeType': attachment['mimeType'],
'url': attachment['url'],
'mimeType': attachment.mimeType,
'url': attachment.url,
});
}
}
@@ -211,24 +286,27 @@ class AgUiService {
messageContent = contentBlocks;
}
return {
'threadId': threadId,
'runId': runId,
'state': <String, dynamic>{},
'messages': [
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
],
'tools': <Map<String, dynamic>>[],
'context': <Map<String, dynamic>>[],
'forwardedProps': <String, dynamic>{},
};
return _RunInputPayload(
input: {
'threadId': threadId,
'runId': runId,
'state': <String, dynamic>{},
'messages': [
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
],
'tools': <Map<String, dynamic>>[],
'context': <Map<String, dynamic>>[],
'forwardedProps': <String, dynamic>{},
},
uploadedAttachments: uploadedAttachments,
);
}
Future<List<Map<String, dynamic>>> _uploadAttachments({
Future<List<UploadedAttachment>> _uploadAttachments({
required String threadId,
required List<XFile> images,
}) async {
final attachments = <Map<String, dynamic>>[];
final attachments = <UploadedAttachment>[];
for (final image in images) {
final mimeType = image.mimeType ?? 'image/jpeg';
final fileBytes = await image.readAsBytes();
@@ -266,12 +344,13 @@ class AgUiService {
url.isEmpty) {
throw StateError('Invalid attachment reference');
}
attachments.add({
'bucket': bucket,
'path': path,
'mimeType': uploadedMime,
'url': url,
});
attachments.add(
UploadedAttachment(
localPath: image.path,
url: url,
mimeType: uploadedMime,
),
);
}
return attachments;
}
@@ -118,10 +118,16 @@ class ChatBloc extends Cubit<ChatState> {
),
);
case AgUiEventType.runFinished:
emit(_resetRunState());
emit(
_resetRunState().copyWith(items: _removeToolCallItems(state.items)),
);
case AgUiEventType.runError:
final errorEvent = event as RunErrorEvent;
emit(_resetRunState(error: errorEvent.message));
emit(
_resetRunState(
error: errorEvent.message,
).copyWith(items: _markActiveToolCallsFailed(state.items)),
);
case AgUiEventType.stepStarted:
_handleStepStarted(event as StepStartedEvent);
case AgUiEventType.stepFinished:
@@ -167,9 +173,11 @@ class ChatBloc extends Cubit<ChatState> {
_upsertUiSchema(items, event.messageId, uiSchema, timestamp);
}
final withoutToolCalls = _removeToolCallItems(items);
emit(
state.copyWith(
items: items,
items: withoutToolCalls,
currentMessageId: null,
isWaitingFirstToken: false,
isStreaming: false,
@@ -264,13 +272,38 @@ class ChatBloc extends Cubit<ChatState> {
}
void _handleToolCallResult(ToolCallResultEvent event) {
final items = state.items.where((item) {
return !(item is ToolCallItem && item.id == event.toolCallId);
final items = state.items.map((item) {
if (item is ToolCallItem && item.id == event.toolCallId) {
return item.copyWith(status: ToolCallStatus.completed);
}
return item;
}).toList();
emit(state.copyWith(items: items));
}
List<ChatListItem> _removeToolCallItems(List<ChatListItem> items) {
return items.where((item) => item is! ToolCallItem).toList();
}
List<ChatListItem> _markActiveToolCallsFailed(List<ChatListItem> items) {
return items.map((item) {
if (item is! ToolCallItem) {
return item;
}
if (item.status == ToolCallStatus.error) {
return item;
}
if (item.status == ToolCallStatus.completed) {
return item;
}
return item.copyWith(
status: ToolCallStatus.error,
errorMessage: '本次运行已失败',
);
}).toList();
}
void _handleToolCallError(ToolCallErrorEvent event) {
final items = state.items.map((item) {
if (item is ToolCallItem && item.id == event.toolCallId) {
@@ -341,16 +374,18 @@ class ChatBloc extends Cubit<ChatState> {
}
Future<void> sendMessage(String content, {List<XFile>? images}) async {
final messageId = 'user-${DateTime.now().millisecondsSinceEpoch}';
final attachments = (images ?? const <XFile>[])
.map(
(image) => <String, dynamic>{
'path': image.path,
'mimeType': 'image/*',
'mimeType': image.mimeType ?? 'image/jpeg',
'uploading': true,
},
)
.toList();
final userMessage = TextMessageItem(
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
id: messageId,
content: content,
timestamp: DateTime.now(),
sender: MessageSender.user,
@@ -367,8 +402,13 @@ class ChatBloc extends Cubit<ChatState> {
),
);
try {
await _service.sendMessage(content, images: images);
final sendResult = await _service.sendMessage(content, images: images);
_syncUploadedAttachments(
messageId: messageId,
uploadedAttachments: sendResult.uploadedAttachments,
);
} catch (error) {
_markAttachmentUploadDone(messageId);
emit(
state.copyWith(
isSending: false,
@@ -381,6 +421,63 @@ class ChatBloc extends Cubit<ChatState> {
}
}
void _syncUploadedAttachments({
required String messageId,
required List<UploadedAttachment> uploadedAttachments,
}) {
if (uploadedAttachments.isEmpty) {
_markAttachmentUploadDone(messageId);
return;
}
final items = state.items.map((item) {
if (item is! TextMessageItem || item.id != messageId) {
return item;
}
final synced = item.attachments.map((attachment) {
final localPath = attachment['path'];
if (localPath is! String || localPath.isEmpty) {
return <String, dynamic>{...attachment, 'uploading': false};
}
UploadedAttachment? matched;
for (final candidate in uploadedAttachments) {
if (candidate.localPath == localPath) {
matched = candidate;
break;
}
}
if (matched == null) {
return <String, dynamic>{...attachment, 'uploading': false};
}
return <String, dynamic>{
...attachment,
'url': matched.url,
'mimeType': matched.mimeType,
'uploading': false,
};
}).toList();
return item.copyWith(attachments: synced);
}).toList();
emit(state.copyWith(items: items));
}
void _markAttachmentUploadDone(String messageId) {
final items = state.items.map((item) {
if (item is! TextMessageItem || item.id != messageId) {
return item;
}
final done = item.attachments
.map(
(attachment) => <String, dynamic>{
...attachment,
'uploading': false,
},
)
.toList();
return item.copyWith(attachments: done);
}).toList();
emit(state.copyWith(items: items));
}
Future<void> loadHistory() async {
if (state.isLoadingHistory) return;
emit(state.copyWith(isLoadingHistory: true));
@@ -44,6 +44,7 @@ const _attachmentPreviewGap = 8.0;
const _bottomStackReservedHeight = 116.0;
const _toolResultWidthFactor = 0.9;
const _pullRefreshMinVisibleMs = 450;
const _waitingIndicatorReservedHeight = 42.0;
const homeConversationStageKey = ValueKey('home_conversation_stage');
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
@@ -64,7 +65,6 @@ const _recordingActiveLabelColor = AppColors.white;
class HomeScreen extends StatefulWidget {
final VoiceRecorder? voiceRecorder;
final Future<String> Function(String filePath)? onTranscribeAudio;
final Future<void> Function(String transcript)? onAutoSendTranscript;
final ChatBloc? chatBloc;
final bool autoLoadHistory;
final List<XFile> initialSelectedImages;
@@ -73,7 +73,6 @@ class HomeScreen extends StatefulWidget {
super.key,
this.voiceRecorder,
this.onTranscribeAudio,
this.onAutoSendTranscript,
this.chatBloc,
this.autoLoadHistory = true,
this.initialSelectedImages = const [],
@@ -91,7 +90,6 @@ class _HomeScreenState extends State<HomeScreen>
late final VoiceRecorder _voiceRecorder;
late final InboxApi _inboxApi;
late final Future<String> Function(String filePath) _transcribeAudio;
late final Future<void> Function(String transcript) _autoSendTranscript;
late final AnimationController _listeningAnimationController;
bool _isRecording = false;
bool _isHoldToSpeakMode = true;
@@ -101,6 +99,8 @@ class _HomeScreenState extends State<HomeScreen>
bool _isPullRefreshing = false;
int _unreadCount = 0;
final List<XFile> _selectedImages = [];
int _lastObservedItemCount = 0;
bool _lastObservedWaiting = false;
@override
void initState() {
@@ -110,7 +110,6 @@ class _HomeScreenState extends State<HomeScreen>
_inboxApi = sl<InboxApi>();
_transcribeAudio =
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
_autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage;
_listeningAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: _rippleDurationMs),
@@ -119,6 +118,8 @@ class _HomeScreenState extends State<HomeScreen>
if (widget.autoLoadHistory) {
_chatBloc.loadHistory();
}
_lastObservedItemCount = _chatBloc.state.items.length;
_lastObservedWaiting = _isAgentWaiting(_chatBloc.state);
_loadUnreadCount();
}
@@ -154,6 +155,15 @@ class _HomeScreenState extends State<HomeScreen>
if (state.error != null) {
Toast.show(context, state.error!, type: ToastType.error);
}
final isWaitingNow = _isAgentWaiting(state);
final hasItemCountChanged =
state.items.length != _lastObservedItemCount;
final waitingStateChanged = isWaitingNow != _lastObservedWaiting;
if (hasItemCountChanged || waitingStateChanged) {
_scheduleAutoScroll(animated: hasItemCountChanged);
}
_lastObservedItemCount = state.items.length;
_lastObservedWaiting = isWaitingNow;
},
builder: (context, state) {
return Scaffold(
@@ -190,8 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
}
Widget _buildChatArea(BuildContext context, ChatState state) {
final showWaitingIndicator =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
final showWaitingIndicator = _isAgentWaiting(state);
if (state.isLoadingHistory && state.items.isEmpty) {
return const Center(
@@ -219,7 +228,12 @@ class _HomeScreenState extends State<HomeScreen>
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: AppSpacing.sm),
padding: EdgeInsets.only(
top: AppSpacing.sm,
bottom: showWaitingIndicator
? _waitingIndicatorReservedHeight
: AppSpacing.none,
),
itemCount:
state.items.length + (state.hasEarlierHistory ? 1 : 0),
itemBuilder: (context, index) {
@@ -391,6 +405,28 @@ class _HomeScreenState extends State<HomeScreen>
context.read<ChatBloc>().loadMoreHistory();
}
bool _isAgentWaiting(ChatState state) {
return state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
}
void _scheduleAutoScroll({required bool animated}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) {
return;
}
final maxExtent = _scrollController.position.maxScrollExtent;
if (animated) {
_scrollController.animateTo(
maxExtent,
duration: const Duration(milliseconds: _scrollDurationMs),
curve: Curves.easeOut,
);
return;
}
_scrollController.jumpTo(maxExtent);
});
}
Widget _buildChatItem(ChatListItem item) {
switch (item.type) {
case ChatItemType.message:
@@ -482,47 +518,89 @@ class _HomeScreenState extends State<HomeScreen>
}
bool _isRenderableImageAttachment(Map<String, dynamic> attachment) {
final path = attachment['path'];
final url = attachment['url'];
final mimeType = attachment['mimeType'];
return url is String &&
url.isNotEmpty &&
final hasRenderableSource =
(url is String && url.isNotEmpty) ||
(path is String && path.isNotEmpty);
return hasRenderableSource &&
mimeType is String &&
mimeType.startsWith('image/');
}
Widget _buildHistoryAttachmentTile(Map<String, dynamic> attachment) {
final path = attachment['path'];
final url = attachment['url'];
if (url is! String || url.isEmpty) {
final isUploading = attachment['uploading'] == true;
final Widget image;
if (url is String && url.isNotEmpty) {
image = Image.network(
url,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(
child: AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: _transcribingSpinnerSize,
strokeWidth: _transcribingStrokeWidth,
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
LucideIcons.imageOff,
size: _iconSize,
color: AppColors.slate500,
),
);
},
);
} else if (path is String && path.isNotEmpty) {
image = Image.file(
File(path),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
LucideIcons.imageOff,
size: _iconSize,
color: AppColors.slate500,
),
);
},
);
} else {
return const SizedBox.shrink();
}
return ClipRRect(
borderRadius: BorderRadius.circular(_attachmentPreviewRadius),
child: Container(
width: _attachmentPreviewSize,
height: _attachmentPreviewSize,
color: AppColors.slate100,
child: Image.network(
url,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(
child: AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: _transcribingSpinnerSize,
strokeWidth: _transcribingStrokeWidth,
child: Stack(
fit: StackFit.expand,
children: [
image,
if (isUploading)
ColoredBox(
color: AppColors.slate900.withValues(alpha: 0.2),
child: const Center(
child: AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: _transcribingSpinnerSize,
strokeWidth: _transcribingStrokeWidth,
color: AppColors.white,
trackColor: AppColors.slate200,
),
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
LucideIcons.imageOff,
size: _iconSize,
color: AppColors.slate500,
),
);
},
],
),
),
);
@@ -841,7 +919,7 @@ class _HomeScreenState extends State<HomeScreen>
});
try {
await context.read<ChatBloc>().sendMessage(content, images: images);
await _chatBloc.sendMessage(content, images: images);
} finally {
if (mounted) {
setState(() {
@@ -1034,8 +1112,11 @@ class _HomeScreenState extends State<HomeScreen>
TextPosition(offset: transcript.length),
);
if (autoSendAfterTranscribe) {
_messageController.clear();
await _autoSendTranscript(normalizedTranscript);
_messageController.text = normalizedTranscript;
_messageController.selection = TextSelection.fromPosition(
TextPosition(offset: normalizedTranscript.length),
);
await _sendMessage(context);
}
} catch (error) {
if (!mounted) {