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,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 => (