feat: 添加 Agent 步骤事件与图片附件功能

- 新增 stepStarted/stepFinished 事件类型支持
- 前端实现图片附件上传和预览功能
- 后端增强工具结果存储和事件处理
- 完善相关单元测试和集成测试
This commit is contained in:
zl-q
2026-03-12 09:29:57 +08:00
parent 87215f9d41
commit 7b8865e256
45 changed files with 3869 additions and 308 deletions
-1
View File
@@ -50,7 +50,6 @@ class ApiClient implements IApiClient {
};
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) async {
try {
return await _dio.get<T>(path, options: options);
+8 -17
View File
@@ -40,7 +40,11 @@ class MockApiClient implements IApiClient {
_handlers[key] = handler;
}
void registerPatternHandler(RegExp pattern, String method, MockHandler handler) {
void registerPatternHandler(
RegExp pattern,
String method,
MockHandler handler,
) {
_patternHandlers.add(
_PatternRoute(
pattern: pattern,
@@ -96,11 +100,7 @@ class MockApiClient implements IApiClient {
final direct = _handlers[key];
if (direct != null) {
final response = direct(
MockRequest(
path: path,
method: 'SSE',
headers: headers,
),
MockRequest(path: path, method: 'SSE', headers: headers),
);
if (response is Stream<String>) {
return response;
@@ -118,11 +118,7 @@ class MockApiClient implements IApiClient {
continue;
}
final response = route.handler(
MockRequest(
path: path,
method: 'SSE',
headers: headers,
),
MockRequest(path: path, method: 'SSE', headers: headers),
);
if (response is Stream<String>) {
return response;
@@ -147,12 +143,7 @@ class MockApiClient implements IApiClient {
if (handler != null) {
final response = handler(
MockRequest(
path: path,
method: method,
data: data,
options: options,
),
MockRequest(path: path, method: method, data: data, options: options),
);
if (response is Response) {
return response as Response<T>;
@@ -9,6 +9,8 @@ class AgUiEventTypeWire {
static const runStarted = 'RUN_STARTED';
static const runFinished = 'RUN_FINISHED';
static const runError = 'RUN_ERROR';
static const stepStarted = 'STEP_STARTED';
static const stepFinished = 'STEP_FINISHED';
static const textMessageStart = 'TEXT_MESSAGE_START';
static const textMessageContent = 'TEXT_MESSAGE_CONTENT';
static const textMessageEnd = 'TEXT_MESSAGE_END';
@@ -25,6 +27,8 @@ enum AgUiEventType {
runStarted,
runFinished,
runError,
stepStarted,
stepFinished,
textMessageStart,
textMessageContent,
textMessageEnd,
@@ -43,6 +47,8 @@ const _wireToTypeMap = {
AgUiEventTypeWire.runStarted: AgUiEventType.runStarted,
AgUiEventTypeWire.runFinished: AgUiEventType.runFinished,
AgUiEventTypeWire.runError: AgUiEventType.runError,
AgUiEventTypeWire.stepStarted: AgUiEventType.stepStarted,
AgUiEventTypeWire.stepFinished: AgUiEventType.stepFinished,
AgUiEventTypeWire.textMessageStart: AgUiEventType.textMessageStart,
AgUiEventTypeWire.textMessageContent: AgUiEventType.textMessageContent,
AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd,
@@ -60,6 +66,8 @@ const _typeToWireMap = {
AgUiEventType.runStarted: AgUiEventTypeWire.runStarted,
AgUiEventType.runFinished: AgUiEventTypeWire.runFinished,
AgUiEventType.runError: AgUiEventTypeWire.runError,
AgUiEventType.stepStarted: AgUiEventTypeWire.stepStarted,
AgUiEventType.stepFinished: AgUiEventTypeWire.stepFinished,
AgUiEventType.textMessageStart: AgUiEventTypeWire.textMessageStart,
AgUiEventType.textMessageContent: AgUiEventTypeWire.textMessageContent,
AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd,
@@ -83,6 +91,8 @@ final _typeToFactory = {
AgUiEventType.runStarted: RunStartedEvent.fromJson,
AgUiEventType.runFinished: RunFinishedEvent.fromJson,
AgUiEventType.runError: RunErrorEvent.fromJson,
AgUiEventType.stepStarted: StepStartedEvent.fromJson,
AgUiEventType.stepFinished: StepFinishedEvent.fromJson,
AgUiEventType.textMessageStart: TextMessageStartEvent.fromJson,
AgUiEventType.textMessageContent: TextMessageContentEvent.fromJson,
AgUiEventType.textMessageEnd: TextMessageEndEvent.fromJson,
@@ -170,6 +180,34 @@ class RunErrorEvent extends AgUiEvent {
Map<String, dynamic> toJson() => _$RunErrorEventToJson(this);
}
@JsonSerializable()
class StepStartedEvent extends AgUiEvent {
final String stepName;
StepStartedEvent({required this.stepName})
: super(type: AgUiEventType.stepStarted);
factory StepStartedEvent.fromJson(Map<String, dynamic> json) =>
_$StepStartedEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$StepStartedEventToJson(this);
}
@JsonSerializable()
class StepFinishedEvent extends AgUiEvent {
final String stepName;
StepFinishedEvent({required this.stepName})
: super(type: AgUiEventType.stepFinished);
factory StepFinishedEvent.fromJson(Map<String, dynamic> json) =>
_$StepFinishedEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$StepFinishedEventToJson(this);
}
@JsonSerializable()
class TextMessageStartEvent extends AgUiEvent {
final String messageId;
@@ -310,10 +348,33 @@ class ToolCallResultEvent extends AgUiEvent {
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) {
final rawContent = json['content'];
final content = rawContent is String ? rawContent : '';
final hasStructuredFields =
json['ui'] != null || json['result'] != null || json['error'] != null;
final content = switch (rawContent) {
String value when value.trim().startsWith('{') => value,
String value when value.trim().startsWith('[') => value,
String value when hasStructuredFields => jsonEncode({
'toolName': json['toolName'],
'result': json['result'],
'error': json['error'],
'ui': json['ui'],
'content': value,
}),
String value => value,
_ => jsonEncode({
'toolName': json['toolName'],
'result': json['result'],
'error': json['error'],
'ui': json['ui'],
'content': json['content'],
}),
};
final toolCallId =
json['toolCallId'] as String? ?? json['callId'] as String? ?? '';
final messageId = json['messageId'] as String? ?? 'tool-result-$toolCallId';
return ToolCallResultEvent(
messageId: json['messageId'] as String,
toolCallId: json['toolCallId'] as String,
messageId: messageId,
toolCallId: toolCallId,
content: content,
);
}
@@ -388,6 +449,7 @@ class SnapshotMessage {
final String? toolCallId;
final UiCard? ui;
final DateTime? timestamp;
final List<Map<String, dynamic>>? attachments;
SnapshotMessage({
required this.id,
@@ -396,6 +458,7 @@ class SnapshotMessage {
this.toolCallId,
this.ui,
this.timestamp,
this.attachments,
});
factory SnapshotMessage.fromJson(Map<String, dynamic> json) =>
@@ -14,6 +14,8 @@ const _$AgUiEventTypeEnumMap = {
AgUiEventType.runStarted: 'runStarted',
AgUiEventType.runFinished: 'runFinished',
AgUiEventType.runError: 'runError',
AgUiEventType.stepStarted: 'stepStarted',
AgUiEventType.stepFinished: 'stepFinished',
AgUiEventType.textMessageStart: 'textMessageStart',
AgUiEventType.textMessageContent: 'textMessageContent',
AgUiEventType.textMessageEnd: 'textMessageEnd',
@@ -53,6 +55,18 @@ RunErrorEvent _$RunErrorEventFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$RunErrorEventToJson(RunErrorEvent instance) =>
<String, dynamic>{'message': instance.message, 'code': instance.code};
StepStartedEvent _$StepStartedEventFromJson(Map<String, dynamic> json) =>
StepStartedEvent(stepName: json['stepName'] as String);
Map<String, dynamic> _$StepStartedEventToJson(StepStartedEvent instance) =>
<String, dynamic>{'stepName': instance.stepName};
StepFinishedEvent _$StepFinishedEventFromJson(Map<String, dynamic> json) =>
StepFinishedEvent(stepName: json['stepName'] as String);
Map<String, dynamic> _$StepFinishedEventToJson(StepFinishedEvent instance) =>
<String, dynamic>{'stepName': instance.stepName};
TextMessageStartEvent _$TextMessageStartEventFromJson(
Map<String, dynamic> json,
) => TextMessageStartEvent(
@@ -170,6 +184,9 @@ SnapshotMessage _$SnapshotMessageFromJson(Map<String, dynamic> json) =>
timestamp: json['timestamp'] == null
? null
: DateTime.parse(json['timestamp'] as String),
attachments: (json['attachments'] as List<dynamic>?)
?.whereType<Map<String, dynamic>>()
.toList(),
);
Map<String, dynamic> _$SnapshotMessageToJson(SnapshotMessage instance) =>
@@ -180,4 +197,5 @@ Map<String, dynamic> _$SnapshotMessageToJson(SnapshotMessage instance) =>
'toolCallId': instance.toolCallId,
'ui': instance.ui,
'timestamp': instance.timestamp?.toIso8601String(),
'attachments': instance.attachments,
};
@@ -22,6 +22,7 @@ class TextMessageItem extends ChatListItem {
@override
final MessageSender sender;
final bool isStreaming;
final List<Map<String, dynamic>> attachments;
TextMessageItem({
required this.id,
@@ -29,6 +30,7 @@ class TextMessageItem extends ChatListItem {
required this.timestamp,
required this.sender,
this.isStreaming = false,
this.attachments = const [],
});
@override
@@ -40,12 +42,14 @@ class TextMessageItem extends ChatListItem {
DateTime? timestamp,
MessageSender? sender,
bool? isStreaming,
List<Map<String, dynamic>>? attachments,
}) => TextMessageItem(
id: id ?? this.id,
content: content ?? this.content,
timestamp: timestamp ?? this.timestamp,
sender: sender ?? this.sender,
isStreaming: isStreaming ?? this.isStreaming,
attachments: attachments ?? this.attachments,
);
}
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
@@ -80,6 +81,18 @@ class AgUiService {
onEvent(event);
}
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
final response = await _apiClient.get<List<int>>(
previewPath,
options: Options(responseType: ResponseType.bytes),
);
final payload = response.data;
if (payload is List<int>) {
return Uint8List.fromList(payload);
}
throw StateError('Invalid attachment payload');
}
Future<String> transcribeAudio(String filePath) async {
final formData = FormData.fromMap({
'audio': await MultipartFile.fromFile(
@@ -247,22 +260,27 @@ class AgUiService {
final runId = _nextId(_runIdPrefix);
final contentBlocks = <Map<String, dynamic>>[];
final attachmentMetadata = <Map<String, dynamic>>[];
if (content.isNotEmpty) {
contentBlocks.add({'type': 'text', 'text': content});
}
if (images != null && images.isNotEmpty) {
for (final image in images) {
final bytes = await image.readAsBytes();
final base64 = base64Encode(bytes);
final uploadedAttachments = await _uploadAttachments(
threadId: threadId,
images: images,
);
for (final attachment in uploadedAttachments) {
contentBlocks.add({
'type': 'image',
'source': {
'type': 'base64',
'media_type': 'image/jpeg',
'data': base64,
},
'type': 'binary',
'mimeType': attachment['mimeType'],
'url': attachment['url'],
});
attachmentMetadata.add({
'bucket': attachment['bucket'],
'path': attachment['path'],
'mimeType': attachment['mimeType'],
});
}
}
@@ -286,10 +304,64 @@ class AgUiService {
],
'tools': _buildTools(),
'context': <Map<String, dynamic>>[],
'forwardedProps': <String, dynamic>{},
'forwardedProps': {
if (attachmentMetadata.isNotEmpty) 'attachments': attachmentMetadata,
},
};
}
Future<List<Map<String, dynamic>>> _uploadAttachments({
required String threadId,
required List<XFile> images,
}) async {
final attachments = <Map<String, dynamic>>[];
for (final image in images) {
final mimeType = image.mimeType ?? 'image/jpeg';
final fileBytes = await image.readAsBytes();
final formData = FormData.fromMap({
'threadId': threadId,
'file': MultipartFile.fromBytes(
fileBytes,
filename: image.name,
contentType: DioMediaType.parse(mimeType),
),
});
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/attachments',
data: formData,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/attachments response');
}
final attachment = payload['attachment'];
if (attachment is! Map<String, dynamic>) {
throw StateError('Missing attachment in /agent/attachments response');
}
final bucket = attachment['bucket'];
final path = attachment['path'];
final uploadedMime = attachment['mimeType'];
final url = attachment['url'];
if (bucket is! String ||
path is! String ||
uploadedMime is! String ||
url is! String ||
bucket.isEmpty ||
path.isEmpty ||
uploadedMime.isEmpty ||
url.isEmpty) {
throw StateError('Invalid attachment reference');
}
attachments.add({
'bucket': bucket,
'path': path,
'mimeType': uploadedMime,
'url': url,
});
}
return attachments;
}
List<Map<String, dynamic>> _buildTools() {
return [
{
@@ -360,6 +432,11 @@ class AgUiService {
'SSE',
_handleMockSse,
);
client.registerHandler(
'/api/v1/agent/attachments',
'POST',
_handleMockUploadAttachment,
);
client.registerHandler(
'/api/v1/agent/transcribe',
'POST',
@@ -371,6 +448,26 @@ class AgUiService {
return {'transcript': '这是模拟语音转写'};
}
Map<String, dynamic> _handleMockUploadAttachment(MockRequest request) {
final payload = request.data;
final threadId = payload is Map<String, dynamic>
? (payload['threadId'] as String?)
: null;
final resolvedThreadId = (threadId != null && threadId.isNotEmpty)
? threadId
: (_threadId ?? _newUuid());
final path =
'agent-inputs/mock/$resolvedThreadId/${_nextId('upload_')}.png';
return {
'attachment': {
'bucket': 'mock-bucket',
'path': path,
'mimeType': 'image/png',
'url': 'https://mock.local/$path',
},
};
}
Map<String, dynamic> _handleMockRun(MockRequest request) {
final payload = request.data;
final runInput = payload is Map<String, dynamic>
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
@@ -10,6 +11,8 @@ import '../../data/models/ag_ui_event.dart';
import '../../data/models/chat_list_item.dart';
import '../../data/services/ag_ui_service.dart';
enum AgentStage { intent, execution, report }
class ChatState {
final List<ChatListItem> items;
final bool isSending;
@@ -21,6 +24,7 @@ class ChatState {
final String? error;
final DateTime? oldestLoadedDate;
final bool hasEarlierHistory;
final AgentStage? currentStage;
const ChatState({
this.items = const [],
@@ -33,6 +37,7 @@ class ChatState {
this.error,
this.oldestLoadedDate,
this.hasEarlierHistory = false,
this.currentStage,
});
bool get isLoading =>
@@ -55,6 +60,7 @@ class ChatState {
Object? error = _unset,
Object? oldestLoadedDate = _unset,
bool? hasEarlierHistory,
Object? currentStage = _unset,
}) {
return ChatState(
items: items ?? this.items,
@@ -71,6 +77,9 @@ class ChatState {
? this.oldestLoadedDate
: oldestLoadedDate as DateTime?,
hasEarlierHistory: hasEarlierHistory ?? this.hasEarlierHistory,
currentStage: currentStage == _unset
? this.currentStage
: currentStage as AgentStage?,
);
}
}
@@ -78,6 +87,9 @@ class ChatState {
class ChatBloc extends Cubit<ChatState> {
final AgUiService _service;
final Map<String, String> _toolCallArgsBuffer = {};
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
<String, Future<Uint8List?>>{};
ChatBloc({AgUiService? service, IApiClient? apiClient})
: _service =
@@ -102,6 +114,7 @@ class ChatBloc extends Cubit<ChatState> {
isWaitingFirstToken: true,
isCancelling: false,
error: null,
currentStage: null,
),
);
case AgUiEventType.runFinished:
@@ -112,6 +125,7 @@ class ChatBloc extends Cubit<ChatState> {
isStreaming: false,
isCancelling: false,
currentMessageId: null,
currentStage: null,
),
);
case AgUiEventType.runError:
@@ -124,8 +138,13 @@ class ChatBloc extends Cubit<ChatState> {
isCancelling: false,
currentMessageId: null,
error: errorEvent.message,
currentStage: null,
),
);
case AgUiEventType.stepStarted:
_handleStepStarted(event as StepStartedEvent);
case AgUiEventType.stepFinished:
_handleStepFinished(event as StepFinishedEvent);
case AgUiEventType.textMessageStart:
_handleTextMessageStart(event as TextMessageStartEvent);
case AgUiEventType.textMessageContent:
@@ -151,6 +170,16 @@ class ChatBloc extends Cubit<ChatState> {
}
}
void _handleStepStarted(StepStartedEvent event) {
emit(state.copyWith(currentStage: _stageFromName(event.stepName)));
}
void _handleStepFinished(StepFinishedEvent event) {
if (state.currentStage == _stageFromName(event.stepName)) {
emit(state.copyWith(currentStage: null));
}
}
void _handleTextMessageStart(TextMessageStartEvent startEvent) {
final newMessage = TextMessageItem(
id: startEvent.messageId,
@@ -327,6 +356,7 @@ class ChatBloc extends Cubit<ChatState> {
content: msg.content ?? '',
timestamp: timestamp,
sender: MessageSender.user,
attachments: msg.attachments ?? const [],
);
case 'assistant':
return TextMessageItem(
@@ -369,11 +399,20 @@ class ChatBloc extends Cubit<ChatState> {
}
Future<void> sendMessage(String content, {List<XFile>? images}) async {
final attachments = (images ?? const <XFile>[])
.map(
(image) => <String, dynamic>{
"path": image.path,
"mimeType": "image/*",
},
)
.toList();
final userMessage = TextMessageItem(
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
content: content,
timestamp: DateTime.now(),
sender: MessageSender.user,
attachments: attachments,
);
emit(
state.copyWith(
@@ -509,7 +548,43 @@ class ChatBloc extends Cubit<ChatState> {
}
}
Future<Uint8List?> loadAttachmentPreview(String previewPath) async {
final cached = _attachmentPreviewCache[previewPath];
if (cached != null) {
return cached;
}
final pending = _attachmentPreviewInflight[previewPath];
if (pending != null) {
return pending;
}
final future = _service
.fetchAttachmentPreview(previewPath)
.then((bytes) {
_attachmentPreviewCache[previewPath] = bytes;
return bytes;
})
.catchError((_) => null)
.whenComplete(() {
_attachmentPreviewInflight.remove(previewPath);
});
_attachmentPreviewInflight[previewPath] = future;
return future;
}
void clearError() {
emit(state.copyWith(error: null));
}
}
AgentStage? _stageFromName(String value) {
switch (value) {
case 'intent':
return AgentStage.intent;
case 'execution':
return AgentStage.execution;
case 'report':
return AgentStage.report;
default:
return null;
}
}
@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -34,12 +35,15 @@ const _rippleDurationMs = 1200;
const _recordingDotSize = 10.0;
const _transcribingSpinnerSize = 18.0;
const _transcribingStrokeWidth = 2.0;
const _attachmentPreviewSize = 88.0;
const _attachmentPreviewRadius = 10.0;
const _attachmentPreviewGap = 8.0;
const _inputActionButtonKey = ValueKey('home_input_action_button');
const _inputActionIconKey = ValueKey('home_input_action_icon');
/// 颜色常量
const _chatBgColor = Color(0xFFF8FAFC);
const _userBubbleColor = Color(0xFFEAF1FB);
const _chatBgColor = AppColors.slate50;
const _userBubbleColor = AppColors.blue50;
class HomeScreen extends StatefulWidget {
final VoiceRecorder? voiceRecorder;
@@ -265,7 +269,8 @@ class _HomeScreenState extends State<HomeScreen>
),
),
),
if (showWaitingIndicator) _buildWaitingIndicator(),
if (showWaitingIndicator)
_buildWaitingIndicator(currentStage: state.currentStage),
],
);
}
@@ -310,12 +315,19 @@ class _HomeScreenState extends State<HomeScreen>
),
),
),
if (showWaitingIndicator) _buildWaitingIndicator(),
if (showWaitingIndicator)
_buildWaitingIndicator(currentStage: state.currentStage),
],
);
}
Widget _buildWaitingIndicator() {
Widget _buildWaitingIndicator({required AgentStage? currentStage}) {
final label = switch (currentStage) {
AgentStage.intent => '意图识别中',
AgentStage.execution => '任务执行中',
AgentStage.report => '结果总结中',
null => '正在思考...',
};
return Padding(
padding: const EdgeInsets.fromLTRB(
_defaultPadding,
@@ -325,7 +337,7 @@ class _HomeScreenState extends State<HomeScreen>
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
children: [
SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
@@ -336,7 +348,7 @@ class _HomeScreenState extends State<HomeScreen>
),
SizedBox(width: 8),
Text(
'正在思考...',
label,
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
],
@@ -413,38 +425,152 @@ class _HomeScreenState extends State<HomeScreen>
Widget _buildMessageItem(TextMessageItem item) {
final isUser = item.sender == MessageSender.user;
return Row(
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
final imageAttachments = _collectRenderableImageAttachments(
item.attachments,
);
final hasRenderableAttachments = imageAttachments.isNotEmpty;
return Column(
crossAxisAlignment: isUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: _messagePaddingH,
vertical: _messagePaddingV,
),
decoration: BoxDecoration(
color: isUser ? _userBubbleColor : AppColors.white,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(_cornerRadius),
topRight: const Radius.circular(_cornerRadius),
bottomLeft: Radius.circular(isUser ? _cornerRadius : 0),
bottomRight: Radius.circular(isUser ? 0 : _cornerRadius),
Row(
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: _messagePaddingH,
vertical: _messagePaddingV,
),
decoration: BoxDecoration(
color: isUser ? _userBubbleColor : AppColors.white,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(_cornerRadius),
topRight: const Radius.circular(_cornerRadius),
bottomLeft: Radius.circular(isUser ? _cornerRadius : 0),
bottomRight: Radius.circular(isUser ? 0 : _cornerRadius),
),
border: isUser ? null : Border.all(color: AppColors.slate300),
),
child: Text(
item.content,
style: const TextStyle(
fontSize: 14,
color: AppColors.slate900,
),
),
),
border: isUser ? null : Border.all(color: AppColors.slate300),
),
child: Text(
item.content,
style: const TextStyle(fontSize: 14, color: AppColors.slate900),
if (item.attachments.isNotEmpty && !hasRenderableAttachments) ...[
const SizedBox(width: _itemSpacing / 2),
_buildAttachmentBadge(item.attachments.length),
],
],
),
if (hasRenderableAttachments)
Padding(
padding: const EdgeInsets.only(top: _attachmentPreviewGap),
child: _buildHistoryAttachmentPreviews(
item.attachments,
imageAttachments: imageAttachments,
),
),
),
],
);
}
Widget _buildHistoryAttachmentPreviews(
List<Map<String, dynamic>> attachments, {
List<Map<String, dynamic>>? imageAttachments,
}) {
final renderableAttachments =
imageAttachments ?? _collectRenderableImageAttachments(attachments);
if (renderableAttachments.isEmpty) {
return _buildAttachmentBadge(attachments.length);
}
return Wrap(
spacing: _attachmentPreviewGap,
runSpacing: _attachmentPreviewGap,
crossAxisAlignment: WrapCrossAlignment.start,
children: renderableAttachments.map(_buildHistoryAttachmentTile).toList(),
);
}
List<Map<String, dynamic>> _collectRenderableImageAttachments(
List<Map<String, dynamic>> attachments,
) {
return attachments.where(_isRenderableImageAttachment).toList();
}
bool _isRenderableImageAttachment(Map<String, dynamic> attachment) {
final mimeType = attachment['mimeType'];
final previewPath = attachment['previewPath'];
return mimeType is String &&
mimeType.startsWith('image/') &&
previewPath is String &&
previewPath.isNotEmpty;
}
Widget _buildHistoryAttachmentTile(Map<String, dynamic> attachment) {
final previewPath = attachment['previewPath'];
if (previewPath is! String || previewPath.isEmpty) {
return _buildAttachmentBadge(1);
}
return ClipRRect(
borderRadius: BorderRadius.circular(_attachmentPreviewRadius),
child: Container(
width: _attachmentPreviewSize,
height: _attachmentPreviewSize,
color: AppColors.slate100,
child: FutureBuilder<Uint8List?>(
future: _chatBloc.loadAttachmentPreview(previewPath),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: _transcribingStrokeWidth,
),
),
);
}
final data = snapshot.data;
if (data == null || data.isEmpty) {
return const Center(
child: Icon(
LucideIcons.imageOff,
size: _iconSize,
color: AppColors.slate500,
),
);
}
return Image.memory(data, fit: BoxFit.cover, gaplessPlayback: true);
},
),
),
);
}
Widget _buildAttachmentBadge(int count) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.slate200,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'图片附件 x$count',
style: const TextStyle(fontSize: 12, color: AppColors.slate600),
),
);
}
Widget _buildToolCallItem(ToolCallItem item) {
final (statusText, statusColor, statusIcon) = switch (item.status) {
ToolCallStatus.pending => (