feat: 添加 Agent 步骤事件与图片附件功能
- 新增 stepStarted/stepFinished 事件类型支持 - 前端实现图片附件上传和预览功能 - 后端增强工具结果存储和事件处理 - 完善相关单元测试和集成测试
This commit is contained in:
@@ -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 => (
|
||||
|
||||
Reference in New Issue
Block a user