import '/core/utils/logger.dart'; import '/modules/agent/views/google_navigation_card.dart'; import '/modules/agent/views/music_card.dart'; import '/modules/agent/views/qqmusic_card.dart'; import '/modules/agent/views/weather_baidu.dart'; import '/modules/agent/views/weather_hefeng.dart'; import '../../../data/models/song_model.dart'; import '../../../modules/agent/views/navigation_card.dart'; import 'weather_card.dart'; import 'travel_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; // 添加这个导入 import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'dart:io'; import 'package:flutter_markdown/flutter_markdown.dart'; import '../controllers/agent_controller.dart'; /// 消息气泡组件 class MessageBubble extends GetView { final String sessionid; final bool isUser; final String message; final DateTime timestamp; final bool isRecognizing; final bool hasImage; final String? imagePath; final Map? meta; final Map>? functionCalls; final bool isToolCallsExpanded; const MessageBubble({ Key? key, required this.sessionid, required this.isUser, required this.message, required this.timestamp, this.isRecognizing = false, this.hasImage = false, this.imagePath, this.meta, this.functionCalls, this.isToolCallsExpanded = false, }) : super(key: key); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Flexible( child: Column( crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Stack( children: [ Container( padding: (hasImage && message == '[图片]') ? EdgeInsets.zero : const EdgeInsets.all(12), decoration: BoxDecoration( color: _getBubbleColor(context), borderRadius: BorderRadius.only( topLeft: isUser ? const Radius.circular(18) : const Radius.circular(4), topRight: isUser ? const Radius.circular(4) : const Radius.circular(18), bottomLeft: const Radius.circular(18), bottomRight: const Radius.circular(18), ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), blurRadius: 2, offset: const Offset(0, 1), ), ], ), child: Padding( padding: (controller.isShowToolInterruptEnabled.value && !isUser && functionCalls != null && functionCalls!.isNotEmpty) ? const EdgeInsets.only(top: 30) : EdgeInsets.zero, child: _buildMessageContent(context), ), ), //工具调用流程显示(仅在AI消息且有functionCalls时显示) if (controller.isShowToolInterruptEnabled.value && !isUser && functionCalls != null && functionCalls!.isNotEmpty) _buildFunctionCallsWidget(context), ], ), const SizedBox(height: 4), // 识别中的消息不显示时间 if (!isRecognizing) Row( mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.spaceBetween, children: [ // 时间戳(左对齐或右对齐) Text( _formatTimestamp(timestamp), style: TextStyle( fontSize: 8, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[400] : Colors.grey[500], fontWeight: FontWeight.w400, ), ), // AI标识(仅在AI消息时显示) if (!isUser) Padding( padding: const EdgeInsets.only(left: 8), child: Text( "generatedByAi".tr, // 由AI生成 style: TextStyle( fontSize: 8, color: Colors.blue[600], fontWeight: FontWeight.bold, fontStyle: FontStyle.italic, ), ), ), ], ), ], ), ), ], ), ); } /// 获取气泡颜色 Color _getBubbleColor(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; if (isUser) { return isRecognizing ? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色 : Color(0xFF3B82F6); // 使用更明亮的蓝色 } else { return isDarkMode ? Color(0xFF2A2A2A) : Color(0xFFF1F5F9); // 根据主题调整AI消息背景色 } } /// 构建消息内容 Widget _buildMessageContent(BuildContext context) { // 如果是识别中的消息,添加特殊样式 if (isRecognizing) { return Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( message, style: const TextStyle( color: Colors.white, fontStyle: FontStyle.italic, fontSize: 14, height: 1.4, ), // 允许自动换行,不再强制单行 softWrap: true, maxLines: null, ), ), const SizedBox(width: 8), _buildTypingIndicator(), ], ); } if (!isUser && message.isEmpty && (functionCalls == null || !controller.isShowToolInterruptEnabled.value) && meta == null) { return _buildTypingIndicator(); } // 如果只有工具调用而没有文本内容,显示一个占位符确保气泡有足够宽度 if (!isUser && message.isEmpty && functionCalls != null && controller.isShowToolInterruptEnabled.value && meta == null) { return Container( constraints: const BoxConstraints( minWidth: 200, // 确保最小宽度 minHeight: 20, // 确保最小高度 ), child: const SizedBox.shrink(), // 透明占位符 ); } // 如果是图片消息,显示图片 if (hasImage && imagePath != null) { // 如果消息是默认的[图片]标记,只显示图片 if (message == '[图片]') { return Container( padding: const EdgeInsets.all(8), child: GestureDetector( onTap: () { // 点击图片时打开图片查看器 Get.find() .openImageViewer(context, imagePath ?? ''); }, child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.file( File('${controller.AppDir.value}$imagePath'), width: 200, height: 200, fit: BoxFit.cover, ), ), ), ); } // 如果消息是自定义文本,显示文本+图片 return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( message, style: TextStyle( color: isUser ? Colors.white : Colors.black87, fontSize: 14, height: 1.4, fontWeight: FontWeight.w400, ), ), const SizedBox(height: 8), GestureDetector( onTap: () { // 点击图片时打开图片查看器 Get.find() .openImageViewer(context, imagePath ?? ''); }, child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.file( File('${controller.AppDir.value}$imagePath'), width: 200, height: 200, fit: BoxFit.cover, ), ), ), ], ); } // 判断消息内容,显示特殊卡片 if (!isUser && meta != null) { if (meta!.keys.contains('music_searchandplay_songs')) { final music_search_songs = meta!['music_searchandplay_songs'] as Map; final data = music_search_songs['data']; if (data != null && data is List) { final rawList = data as List? ?? []; final playlist = rawList.map((e) => Map.from(e)).toList(); //音乐播放列表 return buildMusicContent(context, playlist); } } else if (meta!.keys.contains('qqmusic_search_songs')) { final qqmusic_search_songs = meta!['qqmusic_search_songs'] as Map; final data = qqmusic_search_songs['data']; if (data != null && data is List) { //qq音乐 final rawList = data as List? ?? []; final songList = rawList .map((item) { try { return SongModel.fromJson(item); } catch (e) { Logger.e('QQMusicPlaylist', '解析歌曲数据失败: $e, 数据: $item'); return null; } }) .where((song) => song != null) .cast() .toList(); //音乐播放列表 return buildQQMusicContent(context, songList); } } else if (meta!.keys.contains('hefeng_weather')) { //高德的 final hefeng_weather = meta!['hefeng_weather'] as Map; final weather_city = hefeng_weather['city']; final weather_day = hefeng_weather['day']; final weather_data = hefeng_weather['data']; if (weather_city != null) { final rawList = weather_data as List? ?? []; final daylist = rawList.map((e) => Map.from(e)).toList(); // final now = daylist.isNotEmpty ? daylist[0] : {}; final now = daylist.firstWhere( (day) => day['fxDate'] == weather_day, orElse: () => daylist.isNotEmpty ? daylist[0] : {}, ); //天气 return hefengWeatherContent( context, weather_city, now, daylist, ); } } else if (meta!.keys.contains('maps_weather')) { //高德的 final maps_weather = meta!['maps_weather'] as Map; final city = maps_weather['city']; if (city != null) { final rawList = maps_weather['forecasts'] as List? ?? []; final daylist = rawList.map((e) => Map.from(e)).toList(); final now = daylist.isNotEmpty ? daylist[0] : {}; //天气 return buildWeatherContent( context, city, now, daylist, ); } } else if (meta!.keys.contains('map_weather')) { //百度的 final map_weather = meta!['map_weather'] as Map; final status = map_weather['status']; if (status == 0) { final result = map_weather['result'] as Map; final city = result["location"]["city"] as String? ?? ''; final rawList = result['forecasts'] as List? ?? []; final daylist = rawList.map((e) => Map.from(e)).toList(); final now = daylist.isNotEmpty ? daylist[0] : {}; //天气 return buildWeatherBaiduContent( context, city, now, daylist, ); } } else if (meta!.keys.contains('card_travel')) { final rawList = meta!['card_travel']['items'] as List? ?? []; final travellist = rawList.map((e) => Map.from(e)).toList(); // 旅游 return buildTravelContent(context, travellist); } else if (meta!.keys.contains('maps_route_navigation')) { final maps_route_navigation = meta!['maps_route_navigation']; if (maps_route_navigation is Map) { final card_navigation = maps_route_navigation["card_navigation"] as Map; final start = card_navigation['start'] as String? ?? ''; final end = card_navigation['end'] as String? ?? ''; final end_longitude = card_navigation["end_longitude"] as String; final end_latitude = card_navigation["end_latitude"] as String; final destination = card_navigation['destination'] as String? ?? ''; final rawList = card_navigation['items'] as List? ?? null; if (rawList != null) { final list = rawList.map((e) => Map.from(e)).toList(); final rawNavigation = card_navigation['navigation']; final Map? navigation = rawNavigation is Map ? rawNavigation : null; // print("导航实时数据 ${rawNavigation}"); // 导航 return navigationCard(context, start: start, end: end, endlatitude: end_latitude, endlongitude: end_longitude, destination: destination, modes: list, navigation: navigation); } } } else if (meta!.keys.contains('maps_google_route_navigation')) { final maps_google_route_navigation = meta!['maps_google_route_navigation']; if (maps_google_route_navigation is Map) { final data = maps_google_route_navigation['data']; if (data != null) { final origin = data['origin'] as String? ?? ''; final destination = data['destination'] as String? ?? ''; final end = data['end'] as String? ?? ''; final rawList = data['items'] as List? ?? null; if (rawList != null) { final list = rawList.map((e) => Map.from(e)).toList(); final rawNavigation = data['navigation']; final Map? navigation = rawNavigation is Map ? rawNavigation : null; // print("导航实时数据 ${rawNavigation}"); // 导航 return googlenavigationCard(context, start: origin, end: destination, destination: end, modes: list, navigation: navigation); } } } } // if (message.contains('播放列表') || message.contains('音乐')) { // return _buildMusicContent(context); // } else if (message.contains('景点') || message.contains('旅游')) { // return _buildTravelContent(context); // } else if (message.contains('天气') || // message.contains('气温') || // message.contains('预报')) { // return _buildWeatherContent(context); // } } // 创建消息内容Widget Widget messageContent; // 使用Markdown渲染AI消息,用户消息仍然使用普通文本 if (!isUser) { messageContent = MarkdownBody( data: message, styleSheet: MarkdownStyleSheet( p: TextStyle( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.9) : Colors.black87, fontSize: 14, height: 1.4, fontWeight: FontWeight.w400, ), h1: TextStyle( color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, fontSize: 18, fontWeight: FontWeight.bold, ), h2: TextStyle( color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, fontSize: 16, fontWeight: FontWeight.bold, ), h3: TextStyle( color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, fontSize: 15, fontWeight: FontWeight.bold, ), code: TextStyle( backgroundColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey[800] : Colors.grey[200], fontFamily: 'monospace', fontSize: 13, ), codeblockDecoration: BoxDecoration( color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[800] : Colors.grey[200], borderRadius: BorderRadius.circular(4), ), blockquote: TextStyle( color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[400] : Colors.grey[700], fontSize: 14, fontStyle: FontStyle.italic, ), blockquoteDecoration: BoxDecoration( border: Border( left: BorderSide( color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[600]! : Colors.grey[400]!, width: 4, ), ), ), listBullet: TextStyle( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.9) : Colors.black87, fontSize: 14, ), ), //selectable: true, ); } else { // 用户消息仍然使用普通文本 messageContent = Text( message, style: TextStyle( color: isUser ? Colors.white : (Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.9) : Colors.black87), fontSize: 14, height: 1.4, fontWeight: FontWeight.w400, ), ); } // 如果是AI消息且有工具调用,确保最小宽度以防止工具调用列表越界 if (!isUser && functionCalls != null && functionCalls!.isNotEmpty) { return Container( constraints: const BoxConstraints( minWidth: 200, // 确保最小宽度,防止工具调用列表越界 ), child: messageContent, ); } return messageContent; } /// 构建输入中的指示器 Widget _buildTypingIndicator() { return SizedBox( width: 40, height: 20, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildDot(100), _buildDot(300), _buildDot(500), ], ), ); } /// 构建动画点 Widget _buildDot(int milliseconds) { return _AnimatedDot(delayMilliseconds: milliseconds); } /// 智能格式化时间戳 String _formatTimestamp(DateTime messageTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final yesterday = today.subtract(const Duration(days: 1)); final messageDate = DateTime(messageTime.year, messageTime.month, messageTime.day); // 计算时间差 final difference = now.difference(messageTime); // 1分钟内显示"刚刚" if (difference.inMinutes < 1) { return 'justNow'.tr; // 刚刚 } // 1小时内显示"X分钟前" if (difference.inHours < 1) { return 'minutesAgo' .trParams({'minutes': difference.inMinutes.toString()}); // X分钟前 } // 今天的消息显示时间 if (messageDate == today) { return DateFormat('HH:mm').format(messageTime); } // 昨天的消息显示"昨天 HH:mm" if (messageDate == yesterday) { return 'yesterday'.tr + ' ' + DateFormat('HH:mm').format(messageTime); // 昨天 HH:mm } // 本周内的消息显示"星期X HH:mm" if (difference.inDays < 7) { final weekday = _getWeekdayName(messageTime.weekday); return weekday + ' ' + DateFormat('HH:mm').format(messageTime); } // 今年的消息显示"MM-dd HH:mm" if (messageTime.year == now.year) { return DateFormat('MM-dd HH:mm').format(messageTime); } // 其他情况显示完整日期"yyyy-MM-dd HH:mm" return DateFormat('yyyy-MM-dd HH:mm').format(messageTime); } /// 获取星期名称 String _getWeekdayName(int weekday) { switch (weekday) { case 1: return 'monday'.tr; // 星期一 case 2: return 'tuesday'.tr; // 星期二 case 3: return 'wednesday'.tr; // 星期三 case 4: return 'thursday'.tr; // 星期四 case 5: return 'friday'.tr; // 星期五 case 6: return 'saturday'.tr; // 星期六 case 7: return 'sunday'.tr; // 星期日 default: return ''; } } /// 构建工具调用流程组件 Widget _buildFunctionCallsWidget(BuildContext context) { return Positioned( top: 0, left: 0, right: 0, child: Container( padding: const EdgeInsets.symmetric( horizontal: 0, vertical: 0), // 减少垂直padding child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题行 Container( height: 40, // 增加固定高度,给字体更多空间 padding: EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context).brightness == Brightness.dark ? const Color.fromARGB(255, 71, 68, 68)?.withOpacity(0.8) : Colors.grey[100]?.withOpacity(0.9), borderRadius: const BorderRadius.only( topLeft: Radius.circular(6), topRight: Radius.circular(6), ), border: Border.all( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.1) : Colors.black.withOpacity(0.1), width: 0.5, ), ), child: Row( children: [ Icon( Icons.build, // 工具icon size: 20, // 减小图标大小 color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.7) : Colors.black54, ), const SizedBox(width: 4), // 显示最后一个工具名称 - 可点击展开 Expanded( child: GestureDetector( onTap: () async { await controller.updateToolCallsExpanded( sessionid, !isToolCallsExpanded, ); }, child: Text( functionCalls != null && functionCalls!.isNotEmpty ? functionCalls!.keys.last : 'toolCall'.tr, style: TextStyle( fontSize: 16, // 适当增大字体 color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.7) : Colors.black54, fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), const SizedBox(width: 4), // 拷贝按钮 GestureDetector( onTap: () { _copyFunctionCallsToClipboard(); }, child: Container( padding: const EdgeInsets.all(2), child: Icon( Icons.copy, size: 20, color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.7) : Colors.black54, ), ), ), const SizedBox(width: 4), // 展开按钮 GestureDetector( onTap: () async { await controller.updateToolCallsExpanded( sessionid, !isToolCallsExpanded, ); }, child: Container( padding: const EdgeInsets.all(2), // 增加 padding 从 2 到 8 child: Icon( isToolCallsExpanded ? Icons.keyboard_arrow_down // 展开时显示向上箭头 : Icons.keyboard_arrow_up, // 收起时显示向下箭头 size: 20, // 增加图标大小从 14 到 16 color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.7) : Colors.black54, ), ), ), ], ), ), // 展开的工具调用列表 if (isToolCallsExpanded && functionCalls != null) Container( margin: const EdgeInsets.only(top: 2), // 减少顶部间距 padding: const EdgeInsets.all(6), // 减少内边距 constraints: const BoxConstraints( maxHeight: 120, // 限制最大高度,避免超界 ), decoration: BoxDecoration( // 添加更强的背景色来遮挡下层UI color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[900]?.withOpacity(0.95) // 深色模式下使用深灰色背景 : Colors.white.withOpacity(0.95), // 浅色模式下使用白色背景 borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(6), bottomRight: Radius.circular(6), ), border: Border.all( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.2), width: 0.5, ), // 添加阴影增强遮挡效果 boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: SingleChildScrollView( // 添加滚动,防止超界 child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: functionCalls!.entries.map((entry) { String toolName = entry.key; dynamic toolResult = entry.value; return Padding( padding: const EdgeInsets.only(bottom: 8), // 减少间距 child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 工具名称 - 固定宽度 SizedBox( width: 80, // 固定宽度 child: Text( toolName, style: TextStyle( fontSize: 12, color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.6) : Colors.black45, fontWeight: FontWeight.w500, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 12), // 增加间距 // 工具结果 - 剩余空间 Expanded( child: Text( toolResult?.toString() ?? 'noResult'.tr, style: TextStyle( fontSize: 12, color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.8) : Colors.black87, height: 1.1, // 减小行高 ), ), ), ], ), ); }).toList(), ), ), ), ], ), )); } /// 拷贝工具调用内容到剪贴板 void _copyFunctionCallsToClipboard() { if (functionCalls == null || functionCalls!.isEmpty) { Get.snackbar( 'tip'.tr, 'noToolCallContentToCopy'.tr, snackPosition: SnackPosition.BOTTOM, duration: const Duration(seconds: 2), ); return; } StringBuffer buffer = StringBuffer(); buffer.writeln('toolCallDetails'.tr); buffer.writeln('=' * 30); functionCalls!.forEach((toolName, toolData) { buffer.writeln('🔧 工具名称: $toolName'); if (toolData is Map) { toolData.forEach((key, value) { buffer.writeln(' $key: $value'); }); } else { buffer.writeln(' 结果: $toolData'); } buffer.writeln('-' * 20); }); Clipboard.setData(ClipboardData(text: buffer.toString())); Get.snackbar( 'success'.tr, 'toolCallContentCopied'.tr, snackPosition: SnackPosition.BOTTOM, duration: const Duration(seconds: 2), ); } } /// 动画点组件 class _AnimatedDot extends StatefulWidget { final int delayMilliseconds; const _AnimatedDot({required this.delayMilliseconds}); @override _AnimatedDotState createState() => _AnimatedDotState(); } class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), ); _animation = Tween(begin: 0, end: 6).animate( CurvedAnimation( parent: _controller, curve: Interval(0.0, 0.5, curve: Curves.easeOut), reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn), ), )..addListener(() { setState(() {}); }); Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () { if (mounted) { _controller.repeat(); } }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( width: 6, height: 6, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(3), boxShadow: [ BoxShadow( color: Colors.white.withOpacity(0.5), blurRadius: _animation.value, spreadRadius: _animation.value / 2, ), ], ), ); } }