You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

616 lines
20 KiB

import 'package:voitrans/core/utils/logger.dart';
import 'package:voitrans/modules/agent/views/google_navigation_card.dart';
import 'package:voitrans/modules/agent/views/music_card.dart';
import 'package:voitrans/modules/agent/views/qqmusic_card.dart';
import 'package:voitrans/modules/agent/views/weather_baidu.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: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<AgentController> {
final bool isUser;
final String message;
final DateTime timestamp;
final bool isRecognizing;
final bool hasImage;
final String? imagePath;
final Map? meta;
const MessageBubble({
Key? key,
required this.isUser,
required this.message,
required this.timestamp,
this.isRecognizing = false,
this.hasImage = false,
this.imagePath,
this.meta,
}) : 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: [
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: _buildMessageContent(context),
),
const SizedBox(height: 4),
// 识别中的消息不显示时间
if (!isRecognizing)
Row(
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.spaceBetween,
children: [
// 时间戳(左对齐或右对齐)
Text(
_formatTimestamp(timestamp),
style: TextStyle(
fontSize: 10,
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(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontStyle: FontStyle.italic,
fontSize: 14,
height: 1.4,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_buildTypingIndicator(),
],
);
}
if (!isUser && message.isEmpty) {
return _buildTypingIndicator();
}
// 如果是图片消息,显示图片
if (hasImage && imagePath != null) {
// 如果消息是默认的[图片]标记,只显示图片
if (message == '[图片]') {
return Container(
padding: const EdgeInsets.all(8),
child: GestureDetector(
onTap: () {
// 点击图片时打开图片查看器
Get.find<AgentController>()
.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<AgentController>()
.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<String, dynamic>;
final data = music_search_songs['data'];
if (data != null && data is List) {
final rawList = data as List? ?? [];
final playlist =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
//音乐播放列表
return buildMusicContent(context, playlist);
}
} else if (meta!.keys.contains('qqmusic_searchandplay_songs')) {
final qqmusic_search_songs =
meta!['qqmusic_searchandplay_songs'] as Map<String, dynamic>;
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<SongModel>()
.toList();
//音乐播放列表
return buildQQMusicContent(context, songList);
}
} else if (meta!.keys.contains('maps_weather')) {
//高德的
final maps_weather = meta!['maps_weather'] as Map<String, dynamic>;
final city = maps_weather['city'];
if (city != null) {
final rawList = maps_weather['forecasts'] as List? ?? [];
final daylist =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
final now = daylist.isNotEmpty ? daylist[0] : <String, dynamic>{};
//天气
return buildWeatherContent(
context,
city,
now,
daylist,
);
}
} else if (meta!.keys.contains('map_weather')) {
//百度的
final map_weather = meta!['map_weather'] as Map<String, dynamic>;
final status = map_weather['status'];
if (status == 0) {
final result = map_weather['result'] as Map<String, dynamic>;
final city = result["location"]["city"] as String? ?? '';
final rawList = result['forecasts'] as List? ?? [];
final daylist =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
final now = daylist.isNotEmpty ? daylist[0] : <String, dynamic>{};
//天气
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<String, dynamic>.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<String, dynamic>) {
final card_navigation =
maps_route_navigation["card_navigation"] as Map<String, dynamic>;
final start = card_navigation['start'] as String? ?? '';
final end = card_navigation['end'] 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<String, dynamic>.from(e)).toList();
final rawNavigation = card_navigation['navigation'];
final Map<String, dynamic>? navigation =
rawNavigation is Map<String, dynamic> ? rawNavigation : null;
// print("导航实时数据 ${rawNavigation}");
// 导航
return navigationCard(context,
start: start,
end: end,
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<String, dynamic>) {
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<String, dynamic>.from(e)).toList();
final rawNavigation = data['navigation'];
final Map<String, dynamic>? navigation =
rawNavigation is Map<String, dynamic> ? 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);
// }
}
// 使用Markdown渲染AI消息,用户消息仍然使用普通文本
if (!isUser) {
return 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,
);
}
// 用户消息仍然使用普通文本
return 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,
),
);
}
/// 构建输入中的指示器
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 '';
}
}
}
/// 动画点组件
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<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_animation = Tween<double>(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,
),
],
),
);
}
}