From ba8e0b9830431bdd17480821a7a5b1c0efba001c Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 20 May 2025 11:40:26 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(message=5Fbubble):=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8Markdown=E6=B8=B2=E6=9F=93AI=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为AI消息添加Markdown渲染支持,提升消息内容的可读性和格式化效果。用户消息仍保持普通文本显示。 --- lib/modules/agent/views/message_bubble.dart | 189 ++++++++++++++------ 1 file changed, 133 insertions(+), 56 deletions(-) diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index 589b18b4a..7c8df4796 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -1,10 +1,12 @@ import 'package:common_utils/common_utils.dart'; import 'package:deep_voice/modules/agent/views/message_bubble_navigation.dart'; +import 'weather_card.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:get_storage/get_storage.dart'; import 'dart:io'; +import 'package:flutter_markdown/flutter_markdown.dart'; import '../controllers/agent_controller.dart'; @@ -120,63 +122,63 @@ class MessageBubble extends GetView { } /// 构建头像 - Widget _buildAvatar(bool isUser, BuildContext context) { - if (isUser) { - final storage = GetStorage(); - final localAvatarPath = storage.read('local_avatar') ?? ''; + // Widget _buildAvatar(bool isUser, BuildContext context) { + // if (isUser) { + // final storage = GetStorage(); + // final localAvatarPath = storage.read('local_avatar') ?? ''; - return Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: localAvatarPath.isEmpty ? Colors.grey[300] : null, - borderRadius: BorderRadius.circular(18), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: localAvatarPath.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(18), - child: Image.file( - File(localAvatarPath), - fit: BoxFit.cover, - ), - ) - : Icon( - Icons.person, - color: Colors.grey[700], - size: 20, - ), - ); - } else { - // AI头像保持不变 - return Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(18), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: Icon( - Icons.smart_toy_outlined, - color: Colors.white, - size: 20, - ), - ); - } - } + // return Container( + // width: 36, + // height: 36, + // decoration: BoxDecoration( + // color: localAvatarPath.isEmpty ? Colors.grey[300] : null, + // borderRadius: BorderRadius.circular(18), + // boxShadow: [ + // BoxShadow( + // color: Colors.black.withOpacity(0.05), + // blurRadius: 2, + // offset: const Offset(0, 1), + // ), + // ], + // ), + // child: localAvatarPath.isNotEmpty + // ? ClipRRect( + // borderRadius: BorderRadius.circular(18), + // child: Image.file( + // File(localAvatarPath), + // fit: BoxFit.cover, + // ), + // ) + // : Icon( + // Icons.person, + // color: Colors.grey[700], + // size: 20, + // ), + // ); + // } else { + // // AI头像保持不变 + // return Container( + // width: 36, + // height: 36, + // decoration: BoxDecoration( + // color: Theme.of(context).primaryColor, + // borderRadius: BorderRadius.circular(18), + // boxShadow: [ + // BoxShadow( + // color: Colors.black.withOpacity(0.05), + // blurRadius: 2, + // offset: const Offset(0, 1), + // ), + // ], + // ), + // child: Icon( + // Icons.smart_toy_outlined, + // color: Colors.white, + // size: 20, + // ), + // ); + // } + // } /// 构建消息内容 Widget _buildMessageContent(BuildContext context) { @@ -300,6 +302,81 @@ class MessageBubble extends GetView { // } } + // 使用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( From d0565d702d4e73c6ed5b5dd3be5cb0bdeb5e9024 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 20 May 2025 17:43:05 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor(agent):=20=E5=B0=86=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E5=92=8C=E5=A4=A9=E6=B0=94=E5=8D=A1=E7=89=87=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E6=8F=90=E5=8F=96=E5=88=B0=E7=8B=AC=E7=AB=8B=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E6=8F=90=E5=8D=87=E4=BB=A3=E7=A0=81=E5=8F=AF?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/agent/views/message_bubble.dart | 629 +------------------- lib/modules/agent/views/music_card.dart | 261 ++++++++ lib/modules/agent/views/weather_card.dart | 313 ++++++++++ pubspec.yaml | 2 +- 4 files changed, 579 insertions(+), 626 deletions(-) create mode 100644 lib/modules/agent/views/music_card.dart create mode 100644 lib/modules/agent/views/weather_card.dart diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index 7c8df4796..9dd85e9e7 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -1,6 +1,7 @@ import 'package:common_utils/common_utils.dart'; import 'package:deep_voice/modules/agent/views/message_bubble_navigation.dart'; import 'weather_card.dart'; +import 'music_card.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; @@ -257,14 +258,14 @@ class MessageBubble extends GetView { final musicSinger = meta!['card_music']['sgener'] as String; final musicImage = meta!['card_music']['image'] as String; //音乐卡片 - return _buildMusicItem( + return buildMusicItem( context, musicId, musicName, musicUrl, musicSinger, musicImage); } else if (meta!.keys.contains('card_musiclist')) { final rawList = meta!['card_musiclist']['musics'] as List; final playlist = rawList.map((e) => Map.from(e)).toList(); //音乐播放列表 - return _buildMusicContent(context, playlist); + return buildMusicContent(context, playlist); } else if (meta!.keys.contains('card_weather')) { final city = meta!['card_weather']['city'] as String; final rawList = meta!['card_weather']['dayweather'] as List; @@ -272,7 +273,7 @@ class MessageBubble extends GetView { rawList.map((e) => Map.from(e)).toList(); final now = meta!['card_weather']['day'] as Map; //天气 - return _buildWeatherContent( + return buildWeatherContent( context, city, now, @@ -413,153 +414,6 @@ class MessageBubble extends GetView { return _AnimatedDot(delayMilliseconds: milliseconds); } - /// 构建音乐内容卡片 - Widget _buildMusicContent( - BuildContext context, List> playlist) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '以下是一些推荐的音乐:', - style: TextStyle( - color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 12), - // Container( - // padding: const EdgeInsets.all(12), - // decoration: BoxDecoration( - // color: Colors.white, - // borderRadius: BorderRadius.circular(12), - // boxShadow: [ - // BoxShadow( - // color: Colors.black.withOpacity(0.03), - // blurRadius: 3, - // spreadRadius: 1, - // offset: const Offset(0, 1), - // ), - // ], - // ), - // child: - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.music_note, - size: 16, - color: Theme.of(context).primaryColor.withOpacity(0.7), - ), - const SizedBox(width: 6), - Text( - '播放列表'.tr, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[300] - : Colors.grey[700], - ), - ), - ], - ), - const SizedBox(height: 10), - - // 动态生成音乐条目 - ...playlist.asMap().entries.take(4).map((entry) { - final index = entry.key; - final item = entry.value; - final id = item["id"]?.toString() ?? ""; - final name = item["name"] ?? "未知歌曲"; - final url = item["url"] ?? ""; - final singer = item["sgener"] ?? "未知歌手"; - final image = item["image"] ?? ""; - return Column( - children: [ - _buildMusicItem(context, id, name, url, singer, image), - if (index != playlist.length - 1) - Divider( - height: 1, - thickness: 1, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[800] - : Colors.grey[100], - ), - ], - ); - }).toList(), - const SizedBox(height: 8), - Center( - child: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - minimumSize: Size.zero, - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - '查看更多推荐', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - //), - //const SizedBox(height: 10), - // Row( - // mainAxisSize: MainAxisSize.min, - // children: [ - // Expanded( - // child: OutlinedButton.icon( - // onPressed: () {}, - // icon: Icon(Icons.play_arrow, size: 16), - // label: Text('播放全部'), - // style: OutlinedButton.styleFrom( - // foregroundColor: Theme.of(context).primaryColor, - // side: BorderSide( - // color: Theme.of(context).primaryColor.withOpacity(0.5)), - // padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), - // shape: RoundedRectangleBorder( - // borderRadius: BorderRadius.circular(16), - // ), - // textStyle: TextStyle(fontSize: 12), - // ), - // ), - // ), - // SizedBox(width: 8), - // Expanded( - // child: OutlinedButton.icon( - // onPressed: () {}, - // icon: Icon(Icons.shuffle, size: 16), - // label: Text('随机播放'), - // style: OutlinedButton.styleFrom( - // foregroundColor: Colors.grey[700], - // side: BorderSide(color: Colors.grey[400]!), - // padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), - // shape: RoundedRectangleBorder( - // borderRadius: BorderRadius.circular(16), - // ), - // textStyle: TextStyle(fontSize: 12), - // ), - // ), - // ), - // ], - // ), - ], - ); - } - /// 构建旅游内容卡片 Widget _buildTravelContent( BuildContext context, List> travellist) { @@ -708,120 +562,6 @@ class MessageBubble extends GetView { ); } - /// 构建音乐项目 - Widget _buildMusicItem(BuildContext context, String id, String name, - String url, String singer, String image) { - final controller = Get.find(); // 获取控制器 - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - Map song = { - 'id': id, - 'url': url, - 'title': name, - 'artist': singer, - 'coverUrl': image, - }; - controller.musicStatus(); //刷新状态 - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDarkMode ? Color(0xFF1E1E1E) : Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.03), - blurRadius: 3, - spreadRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(8), - image: image.isNotEmpty - ? DecorationImage( - image: NetworkImage(image), - fit: BoxFit.cover, - ) - : null, - ), - child: image.isEmpty - ? Icon( - Icons.music_note, - size: 18, - color: Theme.of(context).primaryColor, - ) - : null, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Colors.grey[800], - ), - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 2), - Text( - singer, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Obx(() { - final isThisPlaying = controller.isPlaying.value && - controller.currentUrl.value == url; - - return IconButton( - onPressed: () { - if (controller.isPlaying.value) { - if (controller.currentUrl.value == url) { - controller.musicPause(); - } else { - controller.musicPlay(song); - } - } else { - if (controller.currentUrl.value == url) { - controller.musicResume(); - } else { - controller.musicPlay(song); - } - } - }, - icon: Icon( - isThisPlaying ? Icons.pause_circle : Icons.play_circle, - color: Theme.of(context).primaryColor, - ), - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - iconSize: 36, - ); - }), - ], - ), - ), - ); - } - /// 构建地点项目 Widget _buildLocationItem( BuildContext context, String name, String description) { @@ -883,367 +623,6 @@ class MessageBubble extends GetView { ), ); } - - /// 构建天气内容卡片 - Widget _buildWeatherContent(BuildContext context, String city, - Map now, List> days) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '查询天气情况:', - style: TextStyle( - color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: isDarkMode - ? LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF1E40AF), Color(0xFF1E3A8A)], - ) - : LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)], - ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "${city} ${now["date"]}", - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - SizedBox(height: 4), - Text( - now["weatherCondition"], - style: TextStyle( - fontSize: 14, - color: Colors.white.withOpacity(0.9), - ), - ), - SizedBox(height: 12), - Row( - children: [ - Text( - "${now["temperature"]}°", - style: const TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '最高: ${now["maxTemperature"]}°', - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.9), - ), - ), - Text( - '最低: ${now["minTemperature"]}°', - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.9), - ), - ), - ], - ), - ], - ), - ], - ), - const Icon( - Icons.wb_sunny, - size: 48, - color: Colors.white, - ), - ], - ), - SizedBox(height: 16), - Container( - height: 1, - color: Colors.white.withOpacity(isDarkMode ? 0.15 : 0.2), - ), - SizedBox(height: 16), - const Text( - '未来预报', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - SizedBox(height: 8), - // Row( - // mainAxisAlignment: MainAxisAlignment.spaceBetween, - // children: [ - // _buildForecastDay('周一', Icons.wb_sunny, '28°'), - // _buildForecastDay('周二', Icons.cloud, '25°'), - // _buildForecastDay('周三', Icons.wb_cloudy, '26°'), - // _buildForecastDay('周四', Icons.grain, '24°'), - // _buildForecastDay('周五', Icons.wb_sunny, '27°'), - // ], - // ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate( - days.length, - (index) { - String dateStr = days[index]['date']; - DateTime? date = DateUtil.getDateTime(dateStr); - IconData weatherIcon = - getWeatherIcon(days[index]['weatherCondition']); - String weekday = DateUtil.getWeekday(date, - languageCode: "zh", short: true); - String temp = "${days[index]['temperature']}°"; - return _buildForecastDay( - weekday, - weatherIcon, - temp, - ); - }, - ), - ), - SizedBox(height: 16), - Container( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withOpacity(0.15) - : Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - children: [ - Icon( - Icons.info_outline, - size: 16, - color: Colors.white, - ), - SizedBox(width: 6), - Flexible( - child: Text( - '今日紫外线指数较高,外出请做好防晒', - style: TextStyle( - fontSize: 12, - color: Colors.white, - ), - ), - ), - ], - ), - ), - ], - ), - ), - // const SizedBox(height: 10), - // Row( - // mainAxisSize: MainAxisSize.min, - // children: [ - // Expanded( - // child: OutlinedButton.icon( - // onPressed: () {}, - // icon: Icon(Icons.location_on, size: 16), - // label: Text('更多地区'), - // style: OutlinedButton.styleFrom( - // foregroundColor: Theme.of(context).primaryColor, - // side: BorderSide( - // color: Theme.of(context).primaryColor.withOpacity(0.5)), - // padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), - // shape: RoundedRectangleBorder( - // borderRadius: BorderRadius.circular(16), - // ), - // textStyle: TextStyle(fontSize: 12), - // ), - // ), - // ), - // SizedBox(width: 8), - // Expanded( - // child: OutlinedButton.icon( - // onPressed: () {}, - // icon: Icon(Icons.calendar_today, size: 16), - // label: Text('15天预报'), - // style: OutlinedButton.styleFrom( - // foregroundColor: Colors.grey[700], - // side: BorderSide(color: Colors.grey[400]!), - // padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), - // shape: RoundedRectangleBorder( - // borderRadius: BorderRadius.circular(16), - // ), - // textStyle: TextStyle(fontSize: 12), - // ), - // ), - // ), - // ], - // ), - ], - ); - } - - /// 构建单日天气预报 - Widget _buildForecastDay(String day, IconData icon, String temp) { - return Column( - children: [ - Text( - day, - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.9), - ), - ), - SizedBox(height: 4), - Icon( - icon, - size: 20, - color: Colors.white, - ), - SizedBox(height: 4), - Text( - temp, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ], - ); - } - - /// 获取天气图标 - IconData getWeatherIcon(String description) { - description = description.trim(); - - if (["晴", "少云", "晴间多云"].contains(description)) { - return Icons.wb_sunny; - } - - if (["多云", "阴"].contains(description)) { - return Icons.cloud; - } - - if (description.contains("雾") || - ["雾", "浓雾", "强浓雾", "轻雾", "大雾", "特强浓雾", "霾", "中度霾", "重度霾", "严重霾"] - .contains(description)) { - return Icons.blur_on; - } - - if (description.contains("风") || - [ - "有风", - "平静", - "微风", - "和风", - "清风", - "强风/劲风", - "疾风", - "大风", - "烈风", - "风暴", - "狂爆风", - "飓风", - "热带风暴" - ].contains(description)) { - return Icons.air; - } - - if (description.contains("雨") || - [ - "阵雨", - "雷阵雨", - "雷阵雨并伴有冰雹", - "小雨", - "中雨", - "大雨", - "暴雨", - "大暴雨", - "特大暴雨", - "强阵雨", - "强雷阵雨", - "极端降雨", - "毛毛雨/细雨", - "雨", - "小雨-中雨", - "中雨-大雨", - "大雨-暴雨", - "暴雨-大暴雨", - "大暴雨-特大暴雨" - ].contains(description)) { - return Icons.grain; // 或使用 community icon 包:weather_icons 中的 rain - } - - if (description.contains("雪") || - [ - "雨雪天气", - "雨夹雪", - "阵雨夹雪", - "冻雨", - "雪", - "阵雪", - "小雪", - "中雪", - "大雪", - "暴雪", - "小雪-中雪", - "中雪-大雪", - "大雪-暴雪" - ].contains(description)) { - return Icons.ac_unit; - } - - if (["浮尘", "扬沙", "沙尘暴", "强沙尘暴"].contains(description)) { - return Icons.filter_drama; - } - - if (["龙卷风"].contains(description)) { - return Icons.waves; - } - - if (["热"].contains(description)) { - return Icons.local_fire_department; - } - - if (["冷"].contains(description)) { - return Icons.ac_unit; - } - - // 默认或未知天气 - return Icons.help_outline; - } } /// 动画点组件 diff --git a/lib/modules/agent/views/music_card.dart b/lib/modules/agent/views/music_card.dart new file mode 100644 index 000000000..d76270cad --- /dev/null +++ b/lib/modules/agent/views/music_card.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/agent_controller.dart'; + +/// 构建音乐项目 +Widget buildMusicItem(BuildContext context, String id, String name, String url, + String singer, String image) { + final controller = Get.find(); // 获取控制器 + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + Map song = { + 'id': id, + 'url': url, + 'title': name, + 'artist': singer, + 'coverUrl': image, + }; + controller.musicStatus(); //刷新状态 + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDarkMode ? Color(0xFF1E1E1E) : Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.03), + blurRadius: 3, + spreadRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + image: image.isNotEmpty + ? DecorationImage( + image: NetworkImage(image), + fit: BoxFit.cover, + ) + : null, + ), + child: image.isEmpty + ? Icon( + Icons.music_note, + size: 18, + color: Theme.of(context).primaryColor, + ) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey[800], + ), + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 2), + Text( + singer, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Obx(() { + final isThisPlaying = + controller.isPlaying.value && controller.currentUrl.value == url; + + return IconButton( + onPressed: () { + if (controller.isPlaying.value) { + if (controller.currentUrl.value == url) { + controller.musicPause(); + } else { + controller.musicPlay(song); + } + } else { + if (controller.currentUrl.value == url) { + controller.musicResume(); + } else { + controller.musicPlay(song); + } + } + }, + icon: Icon( + isThisPlaying ? Icons.pause_circle : Icons.play_circle, + color: Theme.of(context).primaryColor, + ), + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + iconSize: 36, + ); + }), + ], + ), + ); +} + +/// 构建音乐内容卡片 +Widget buildMusicContent( + BuildContext context, List> playlist) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '以下是一些推荐的音乐:', + style: TextStyle( + color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 12), + // Container( + // padding: const EdgeInsets.all(12), + // decoration: BoxDecoration( + // color: Colors.white, + // borderRadius: BorderRadius.circular(12), + // boxShadow: [ + // BoxShadow( + // color: Colors.black.withOpacity(0.03), + // blurRadius: 3, + // spreadRadius: 1, + // offset: const Offset(0, 1), + // ), + // ], + // ), + // child: + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.music_note, + size: 16, + color: Theme.of(context).primaryColor.withOpacity(0.7), + ), + const SizedBox(width: 6), + Text( + '播放列表'.tr, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[300] + : Colors.grey[700], + ), + ), + ], + ), + const SizedBox(height: 10), + + // 动态生成音乐条目 + ...playlist.asMap().entries.take(4).map((entry) { + final index = entry.key; + final item = entry.value; + final id = item["id"]?.toString() ?? ""; + final name = item["name"] ?? "未知歌曲"; + final url = item["url"] ?? ""; + final singer = item["sgener"] ?? "未知歌手"; + final image = item["image"] ?? ""; + return Column( + children: [ + buildMusicItem(context, id, name, url, singer, image), + if (index != playlist.length - 1) + Divider( + height: 1, + thickness: 1, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[800] + : Colors.grey[100], + ), + ], + ); + }).toList(), + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + '查看更多推荐', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + //), + //const SizedBox(height: 10), + // Row( + // mainAxisSize: MainAxisSize.min, + // children: [ + // Expanded( + // child: OutlinedButton.icon( + // onPressed: () {}, + // icon: Icon(Icons.play_arrow, size: 16), + // label: Text('播放全部'), + // style: OutlinedButton.styleFrom( + // foregroundColor: Theme.of(context).primaryColor, + // side: BorderSide( + // color: Theme.of(context).primaryColor.withOpacity(0.5)), + // padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(16), + // ), + // textStyle: TextStyle(fontSize: 12), + // ), + // ), + // ), + // SizedBox(width: 8), + // Expanded( + // child: OutlinedButton.icon( + // onPressed: () {}, + // icon: Icon(Icons.shuffle, size: 16), + // label: Text('随机播放'), + // style: OutlinedButton.styleFrom( + // foregroundColor: Colors.grey[700], + // side: BorderSide(color: Colors.grey[400]!), + // padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(16), + // ), + // textStyle: TextStyle(fontSize: 12), + // ), + // ), + // ), + // ], + // ), + ], + ); +} diff --git a/lib/modules/agent/views/weather_card.dart b/lib/modules/agent/views/weather_card.dart new file mode 100644 index 000000000..aba39038e --- /dev/null +++ b/lib/modules/agent/views/weather_card.dart @@ -0,0 +1,313 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; + +/// 构建天气内容卡片 +Widget buildWeatherContent(BuildContext context, String city, + Map now, List> days) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '查询天气情况:', + style: TextStyle( + color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: isDarkMode + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1E40AF), Color(0xFF1E3A8A)], + ) + : LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${city} ${now["date"]}", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + SizedBox(height: 4), + Text( + now["weatherCondition"], + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.9), + ), + ), + SizedBox(height: 8), + Row( + children: [ + Text( + "${now["temperature"]}°", + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '最高: ${now["maxTemperature"]}°', + style: TextStyle( + fontSize: 12, + color: Colors.white.withOpacity(0.9), + ), + ), + Text( + '最低: ${now["minTemperature"]}°', + style: TextStyle( + fontSize: 12, + color: Colors.white.withOpacity(0.9), + ), + ), + ], + ), + ], + ), + ], + ), + const Icon( + Icons.wb_sunny, + size: 48, + color: Colors.white, + ), + ], + ), + SizedBox(height: 16), + Container( + height: 1, + color: Colors.white.withOpacity(isDarkMode ? 0.15 : 0.2), + ), + SizedBox(height: 16), + const Text( + '未来预报', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate( + days.length, + (index) { + String dateStr = days[index]['date']; + DateTime? date = DateUtil.getDateTime(dateStr); + IconData weatherIcon = + getWeatherIcon(days[index]['weatherCondition']); + String weekday = DateUtil.getWeekday(date, + languageCode: "zh", short: true); + String temp = "${days[index]['temperature']}°"; + return _buildForecastDay( + weekday, + weatherIcon, + temp, + ); + }, + ), + ), + SizedBox(height: 16), + Container( + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: isDarkMode + ? Colors.white.withOpacity(0.15) + : Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Colors.white, + ), + SizedBox(width: 8), + Flexible( + child: Text( + '今日紫外线指数较高,外出请做好防晒', + style: TextStyle( + fontSize: 12, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); +} + +/// 构建单日天气预报 +Widget _buildForecastDay(String day, IconData icon, String temp) { + return Column( + children: [ + Text( + day, + style: TextStyle( + fontSize: 12, + color: Colors.white.withOpacity(0.9), + ), + ), + SizedBox(height: 4), + Icon( + icon, + size: 20, + color: Colors.white, + ), + SizedBox(height: 4), + Text( + temp, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ], + ); +} + +/// 获取天气图标 +IconData getWeatherIcon(String description) { + description = description.trim(); + + if (["晴", "少云", "晴间多云"].contains(description)) { + return Icons.wb_sunny; + } + + if (["多云", "阴"].contains(description)) { + return Icons.cloud; + } + + if (description.contains("雾") || + ["雾", "浓雾", "强浓雾", "轻雾", "大雾", "特强浓雾", "霾", "中度霾", "重度霾", "严重霾"] + .contains(description)) { + return Icons.blur_on; + } + + if (description.contains("风") || + [ + "有风", + "平静", + "微风", + "和风", + "清风", + "强风/劲风", + "疾风", + "大风", + "烈风", + "风暴", + "狂爆风", + "飓风", + "热带风暴" + ].contains(description)) { + return Icons.air; + } + + if (description.contains("雨") || + [ + "阵雨", + "雷阵雨", + "雷阵雨并伴有冰雹", + "小雨", + "中雨", + "大雨", + "暴雨", + "大暴雨", + "特大暴雨", + "强阵雨", + "强雷阵雨", + "极端降雨", + "毛毛雨/细雨", + "雨", + "小雨-中雨", + "中雨-大雨", + "大雨-暴雨", + "暴雨-大暴雨", + "大暴雨-特大暴雨" + ].contains(description)) { + return Icons.grain; // 或使用 community icon 包:weather_icons 中的 rain + } + + if (description.contains("雪") || + [ + "雨雪天气", + "雨夹雪", + "阵雨夹雪", + "冻雨", + "雪", + "阵雪", + "小雪", + "中雪", + "大雪", + "暴雪", + "小雪-中雪", + "中雪-大雪", + "大雪-暴雪" + ].contains(description)) { + return Icons.ac_unit; + } + + if (["浮尘", "扬沙", "沙尘暴", "强沙尘暴"].contains(description)) { + return Icons.filter_drama; + } + + if (["龙卷风"].contains(description)) { + return Icons.waves; + } + + if (["热"].contains(description)) { + return Icons.local_fire_department; + } + + if (["冷"].contains(description)) { + return Icons.ac_unit; + } + + // 默认或未知天气 + return Icons.help_outline; +} diff --git a/pubspec.yaml b/pubspec.yaml index 7a8bbb5c2..cb2913215 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: flutter_screenutil: ^5.9.0 path_provider: ^2.1.5 audio_session: ^0.1.18 - flutter_markdown: ^0.6.18+2 + flutter_markdown: ^0.7.7+1 markdown: ^7.1.1 permission_handler: ^11.4.0 web_socket_channel: ^3.0.2 From 2fd6147954837c5fdd7d4360f00b81010deb8f52 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 20 May 2025 20:34:30 +0800 Subject: [PATCH 3/5] =?UTF-8?q?refactor(agent):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=97=85=E6=B8=B8=E5=8D=A1=E7=89=87=E7=BB=84=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将旅游卡片相关逻辑从 `message_bubble.dart` 中提取到独立的 `travel_card.dart` 文件中,提高代码可维护性。同时优化了代码格式,包括缩进、空格和换行,提升代码可读性。 --- .../agent/controllers/agent_controller.dart | 38 ++- lib/modules/agent/views/message_bubble.dart | 230 +----------------- lib/modules/agent/views/travel_card.dart | 211 ++++++++++++++++ 3 files changed, 241 insertions(+), 238 deletions(-) create mode 100644 lib/modules/agent/views/travel_card.dart diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 536514dbf..97d17f807 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -180,11 +180,11 @@ class AgentController extends GetxController { void _addWelcomeMessage() { messages.add(Message( isUser: false, - text: '你好!我是Emma,我能帮你:\n\n' - '• 回答问题和提供信息\n' - '• 实时语音翻译\n' - '• 推荐音乐和播客\n' - '• 语音备忘录和提醒\n' + text: '你好!我是**Emma**,我能帮你:\n\n' + '• 答问题和提供信息\n\n' + '• 实时语音翻译\n\n' + '• 推荐音乐和播客\n\n' + '• 语音备忘录和提醒\n\n' '• 控制耳机功能\n\n' '有什么我可以帮你的吗?', )); @@ -395,12 +395,12 @@ class AgentController extends GetxController { Logger.e(TAG, '解析meta数据失败: $e'); } } - if(metaResult.isNotEmpty){ - + if (metaResult.isNotEmpty) { // 判断是否为新的回复或响应ID是否改变 if (_isNewAssistantResponse || _currentResponseId != responseId) { // 添加新消息 - messages.add(Message(isUser: false, text: "", meta: metaResult)); + messages + .add(Message(isUser: false, text: "", meta: metaResult)); _isNewAssistantResponse = false; _currentResponseId = responseId; } else { @@ -535,24 +535,22 @@ class AgentController extends GetxController { try { // 创建待请求权限列表 List permissionsToRequest = []; - + if (Platform.isAndroid) { // Android 需要考虑SDK版本不同的权限要求 permissionsToRequest.add(Permission.microphone); - - } else if (Platform.isIOS) { // iOS 只需要麦克风权限 permissionsToRequest.add(Permission.microphone); } - + // 批量请求所有需要的权限 final statuses = await permissionsToRequest.request(); - + // 检查是否所有权限都已授予 bool allGranted = true; Permission? deniedPermission; - + statuses.forEach((permission, status) { if (!status.isGranted) { allGranted = false; @@ -560,33 +558,33 @@ class AgentController extends GetxController { Logger.e(TAG, '权限 ${permission.toString()} 未被授予: $status'); } }); - + // 单独检查麦克风权限状态,因为这是主要需求 final micStatus = await Permission.microphone.status; - + // 如果所有权限都已授予,或至少麦克风权限已授予,返回true if (allGranted || micStatus.isGranted) { return true; } - + // 处理特定权限被永久拒绝的情况 if (micStatus.isPermanentlyDenied) { _showPermissionDialog(true); return false; } - + // 普通拒绝情况 if (deniedPermission != null) { _showPermissionDialog(false); } - + return false; } catch (e) { Logger.e(TAG, '检查麦克风权限失败: $e'); return false; } } - + // 获取Android SDK版本 Future _getAndroidSdkVersion() async { if (Platform.isAndroid) { diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index 9dd85e9e7..38845bbfc 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -2,6 +2,7 @@ import 'package:common_utils/common_utils.dart'; import 'package:deep_voice/modules/agent/views/message_bubble_navigation.dart'; import 'weather_card.dart'; import 'music_card.dart'; +import 'travel_card.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; @@ -210,13 +211,16 @@ class MessageBubble extends GetView { if (hasImage && imagePath != null) { // 如果消息是默认的[图片]标记,只显示图片 if (message == '[图片]') { - return ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Image.file( - File(imagePath!), - width: 200, - height: 200, - fit: BoxFit.cover, + return Container( + padding: const EdgeInsets.all(8), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(imagePath!), + width: 200, + height: 200, + fit: BoxFit.cover, + ), ), ); } @@ -284,7 +288,7 @@ class MessageBubble extends GetView { final travellist = rawList.map((e) => Map.from(e)).toList(); // 旅游 - return _buildTravelContent(context, travellist); + return buildTravelContent(context, travellist); } else if (meta!.keys.contains('card_navigation')) { final rawList = meta!['card_navigation']['items'] as List; final list = rawList.map((e) => Map.from(e)).toList(); @@ -413,216 +417,6 @@ class MessageBubble extends GetView { Widget _buildDot(int milliseconds) { return _AnimatedDot(delayMilliseconds: milliseconds); } - - /// 构建旅游内容卡片 - Widget _buildTravelContent( - BuildContext context, List> travellist) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '以下是一些推搜索结果:', - style: TextStyle( - color: Colors.black87, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.03), - blurRadius: 3, - spreadRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Row( - // children: [ - // Icon( - // Icons.place, - // size: 16, - // color: Color(0xFFE11D48), - // ), - // const SizedBox(width: 6), - // Text( - // '热门景点 · 东京', - // style: TextStyle( - // fontSize: 13, - // fontWeight: FontWeight.w600, - // color: Colors.grey[700], - // ), - // ), - // ], - // ), - // const SizedBox(height: 10), - - // 动态生成音乐条目 - ...travellist.asMap().entries.map((entry) { - final index = entry.key; - final item = entry.value; - final name = item["name"] ?? "未知歌曲"; //名字 - final addr = item["addr"] ?? ""; //地址 - final tag = item["tag"] ?? "未知歌手"; //标签 - final rating = item["rating"] ?? ""; //评分 - final distance = item["distance"] ?? ""; //距离 - final images = item["images"] ?? []; // 图片列表 - return Column( - children: [ - _buildLocationItem( - context, - name, - addr, - ), - if (index != travellist.length - 1) - Divider(height: 1, thickness: 1, color: Colors.grey[100]), - ], - ); - }).toList(), - // // 景点列表 - // _buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context), - // Divider(height: 1, thickness: 1, color: Colors.grey[100]), - // _buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context), - // Divider(height: 1, thickness: 1, color: Colors.grey[100]), - // _buildLocationItem('浅草寺', '东京最古老的寺庙', context), - - const SizedBox(height: 8), - Center( - child: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - minimumSize: Size.zero, - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - '查看更多景点', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - icon: Icon(Icons.map, size: 16), - label: Text('查看地图'), - style: OutlinedButton.styleFrom( - foregroundColor: Theme.of(context).primaryColor, - side: BorderSide( - color: Theme.of(context).primaryColor.withOpacity(0.5)), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - textStyle: TextStyle(fontSize: 12), - ), - ), - ), - SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - icon: Icon(Icons.bookmark_border, size: 16), - label: Text('保存到收藏'), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.grey[700], - side: BorderSide(color: Colors.grey[400]!), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - textStyle: TextStyle(fontSize: 12), - ), - ), - ), - ], - ), - ], - ); - } - - /// 构建地点项目 - Widget _buildLocationItem( - BuildContext context, String name, String description) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: isDarkMode ? Color(0xFF1A365D) : Colors.blue[50], - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.place, - size: 18, - color: Color(0xFFE11D48), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: isDarkMode - ? Colors.white.withOpacity(0.9) - : Colors.grey[800], - ), - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 2), - Text( - description, - style: TextStyle( - fontSize: 12, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - IconButton( - onPressed: () {}, - icon: Icon(Icons.arrow_forward, color: Colors.grey[400]), - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - iconSize: 20, - ), - ], - ), - ); - } } /// 动画点组件 diff --git a/lib/modules/agent/views/travel_card.dart b/lib/modules/agent/views/travel_card.dart new file mode 100644 index 000000000..e5c01a163 --- /dev/null +++ b/lib/modules/agent/views/travel_card.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; + +/// 构建旅游内容卡片 +Widget buildTravelContent( + BuildContext context, List> travellist) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '以下是一些推搜索结果:', + style: TextStyle( + color: Colors.black87, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 3, + spreadRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row( + // children: [ + // Icon( + // Icons.place, + // size: 16, + // color: Color(0xFFE11D48), + // ), + // const SizedBox(width: 6), + // Text( + // '热门景点 · 东京', + // style: TextStyle( + // fontSize: 13, + // fontWeight: FontWeight.w600, + // color: Colors.grey[700], + // ), + // ), + // ], + // ), + // const SizedBox(height: 10), + + // 动态生成音乐条目 + ...travellist.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final name = item["name"] ?? "未知歌曲"; //名字 + final addr = item["addr"] ?? ""; //地址 + final tag = item["tag"] ?? "未知歌手"; //标签 + final rating = item["rating"] ?? ""; //评分 + final distance = item["distance"] ?? ""; //距离 + final images = item["images"] ?? []; // 图片列表 + return Column( + children: [ + buildLocationItem( + context, + name, + addr, + ), + if (index != travellist.length - 1) + Divider(height: 1, thickness: 1, color: Colors.grey[100]), + ], + ); + }).toList(), + // // 景点列表 + // _buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context), + // Divider(height: 1, thickness: 1, color: Colors.grey[100]), + // _buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context), + // Divider(height: 1, thickness: 1, color: Colors.grey[100]), + // _buildLocationItem('浅草寺', '东京最古老的寺庙', context), + + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + '查看更多景点', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: Icon(Icons.map, size: 16), + label: Text('查看地图'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).primaryColor, + side: BorderSide( + color: Theme.of(context).primaryColor.withOpacity(0.5)), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + textStyle: TextStyle(fontSize: 12), + ), + ), + ), + SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: Icon(Icons.bookmark_border, size: 16), + label: Text('保存到收藏'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey[700], + side: BorderSide(color: Colors.grey[400]!), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + textStyle: TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + ], + ); +} + +/// 构建地点项目 +Widget buildLocationItem( + BuildContext context, String name, String description) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isDarkMode ? Color(0xFF1A365D) : Colors.blue[50], + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.place, + size: 18, + color: Color(0xFFE11D48), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isDarkMode + ? Colors.white.withOpacity(0.9) + : Colors.grey[800], + ), + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 2), + Text( + description, + style: TextStyle( + fontSize: 12, + color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.arrow_forward, color: Colors.grey[400]), + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + iconSize: 20, + ), + ], + ), + ); +} From d9e764891bcea7682c2212a1dbaa7526ad0b7ffe Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Thu, 22 May 2025 14:48:38 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=80=BB=E7=BB=93?= =?UTF-8?q?=E4=BA=BA=E7=9A=84=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting_details_controller.dart | 4 +- .../views/speaker_all_bottom_sheet.dart | 43 ++++++++++++++++--- .../meeting/views/speaker_bottom_sheet.dart | 14 +++++- lib/modules/meeting/views/speech_tab.dart | 2 +- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/lib/modules/meeting/controllers/meeting_details_controller.dart b/lib/modules/meeting/controllers/meeting_details_controller.dart index 8f085f76e..e894d1bf1 100644 --- a/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -166,7 +166,7 @@ class MeetingDetailsController extends GetxController String text = ''; for (var element in textList) { if (element['speaker'].isNotEmpty) { - text += '${element['speaker']}:'; + text += '[${element['speaker']}]:'; } text += '${element['content']}\n'; } @@ -319,7 +319,7 @@ class MeetingDetailsController extends GetxController String speaker = ''; if (element['additions'].isNotEmpty) { speaker = 'Speaker ${element['additions']['speaker']}'; - text += '$speaker:'; + text += '[$speaker]:'; if (!personnel.contains(speaker)) { personnel += '[$speaker] '; } diff --git a/lib/modules/meeting/views/speaker_all_bottom_sheet.dart b/lib/modules/meeting/views/speaker_all_bottom_sheet.dart index bb3271747..cd455a945 100644 --- a/lib/modules/meeting/views/speaker_all_bottom_sheet.dart +++ b/lib/modules/meeting/views/speaker_all_bottom_sheet.dart @@ -462,12 +462,20 @@ class _SpeakerAllBottomSheetState extends State { List textList = _controller.textList; List newTextList = []; List editSpeakerList = []; + List personnelList = []; String personnel = ''; + bool isLoopEnd = false; for (var element in textList) { final dataMap = Map.from(element); for (var data in _dataList) { String speaker = data['speakerText'].text; if (speaker.isNotEmpty && speaker != data['speaker']) { + if (!isLoopEnd) { + personnelList.add({ + 'original': data['speaker'], + 'speaker': speaker, + }); + } if (dataMap['speaker'] == data['speaker']) { dataMap['speaker'] = speaker; editSpeakerList.add({ @@ -485,13 +493,36 @@ class _SpeakerAllBottomSheetState extends State { personnel += '[${dataMap['speaker']}] '; } newTextList.add(dataMap); + isLoopEnd = true; } - var result = await SqfliteApi.editMeetingSpeaker(editSpeakerList); - if (result != null) { - _controller.textList.value = newTextList; - SqfliteApi.editMeeting(_controller.id, {'personnel': personnel}); - _controller.meetingDetails.value.personnel = personnel; - _storage.write("speaker_list", _historySpeakerList); + if (editSpeakerList.isNotEmpty) { + var result = await SqfliteApi.editMeetingSpeaker(editSpeakerList); + if (result != null) { + bool isEditSummary = false; + String summaryText = _controller.meetingDetails.value.summary; + _controller.textList.value = newTextList; + for (var element in personnelList) { + if (summaryText.contains('[${element['original']}]')) { + summaryText = summaryText.replaceAll( + '[${element['original']}]', '[${element['speaker']}]'); + isEditSummary = true; + } + } + if (isEditSummary) { + SqfliteApi.editMeeting(_controller.id, { + 'summary': summaryText, + 'personnel': personnel, + }); + _controller.meetingDetails.value.summary = summaryText; + } else { + SqfliteApi.editMeeting(_controller.id, {'personnel': personnel}); + } + _controller.meetingDetails.value.personnel = personnel; + _controller.meetingDetails.refresh(); + _storage.write("speaker_list", _historySpeakerList); + Get.back(); + } + } else { Get.back(); } EasyLoading.dismiss(); diff --git a/lib/modules/meeting/views/speaker_bottom_sheet.dart b/lib/modules/meeting/views/speaker_bottom_sheet.dart index 3bc629643..94c6130cd 100644 --- a/lib/modules/meeting/views/speaker_bottom_sheet.dart +++ b/lib/modules/meeting/views/speaker_bottom_sheet.dart @@ -365,7 +365,19 @@ class _SpeakerBottomSheetState extends State { var result = await SqfliteApi.editMeetingSpeaker(editSpeakerList); if (result != null) { _controller.textList.value = newTextList; - SqfliteApi.editMeeting(_controller.id, {'personnel': personnel}); + if (_controller.meetingDetails.value.summary + .contains('[${widget.speakerName}]')) { + String summaryText = _controller.meetingDetails.value.summary + .replaceAll( + '[${widget.speakerName}]', '[${_speakerController.text}]'); + SqfliteApi.editMeeting(_controller.id, { + 'summary': summaryText, + 'personnel': personnel, + }); + _controller.meetingDetails.value.summary = summaryText; + } else { + SqfliteApi.editMeeting(_controller.id, {'personnel': personnel}); + } _controller.meetingDetails.value.personnel = personnel; _addHistorySpeaker(); Get.back(); diff --git a/lib/modules/meeting/views/speech_tab.dart b/lib/modules/meeting/views/speech_tab.dart index a61ebae6a..1d742a493 100644 --- a/lib/modules/meeting/views/speech_tab.dart +++ b/lib/modules/meeting/views/speech_tab.dart @@ -162,7 +162,7 @@ class SpeechTab extends GetView { } if (controller.currentDuration.value != 0) { if (controller.currentDuration.value >= starttime && - controller.currentDuration.value <= endtime) { + controller.currentDuration.value < endtime) { isActive = true; } } From a89ef59a13fb939aeda4d147641dfcab2e4b5b43 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Thu, 22 May 2025 16:14:29 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(agent):=20=E6=94=AF=E6=8C=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=B6=88=E6=81=AF=E6=8C=81=E4=B9=85=E5=8C=96=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在AgentService中新增图片路径参数,用于处理带图片的聊天消息 - 优化聊天记录加载逻辑,支持从元数据中提取图片信息 - 将图片复制到应用永久存储目录,确保图片持久化 - 更新欢迎消息中的助手名称从Emma改为小言 --- .../agent/controllers/agent_controller.dart | 111 ++++++++++++++- lib/modules/agent/views/agent_view.dart | 2 +- .../agent_service/AgentService.kt | 38 ++++-- .../agent_service/lib/agent_service.dart | 127 +++++++++--------- 4 files changed, 197 insertions(+), 81 deletions(-) diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 97d17f807..446845002 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -11,6 +11,7 @@ import 'package:get_storage/get_storage.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:app_settings/app_settings.dart'; import 'package:deep_voice/core/utils/logger.dart'; +import 'package:path_provider/path_provider.dart'; class Message { final bool isUser; //标识信息是否来自用户还是AI @@ -136,10 +137,11 @@ class AgentController extends GetxController { Future _loadChatHistory() async { try { Logger.i(TAG, '正在加载聊天历史...'); + //通过本地插件ChatStorage获取聊天记录 final result = await ChatStorage.getMessages( - sessionId: sessionId, - page: 1, - pageSize: 20, + sessionId: sessionId, //会话ID + page: 1, //从第一页开始 + pageSize: 20, //每页20条记录 ); if (result.messages.isNotEmpty) { @@ -148,11 +150,22 @@ class AgentController extends GetxController { final meta = msg.metadata != null && msg.metadata != "" ? jsonDecode(msg.metadata!) : null; // 如果为空,使用空对象 + + // 从元数据中提取图片信息 + final hasImage = meta != null && meta.containsKey('hasImage') + ? meta['hasImage'] as bool + : false; + final imagePath = meta != null && meta.containsKey('imagePath') + ? meta['imagePath'] as String? + : null; + return Message( isUser: msg.sender == 'user', text: msg.message, meta: meta, timestamp: DateTime.parse(msg.timestamp), + hasImage: hasImage, + imagePath: imagePath, ); }).toList(); @@ -161,6 +174,22 @@ class AgentController extends GetxController { messages.assignAll(historyMessages); Logger.i(TAG, '成功加载 ${historyMessages.length} 条聊天记录'); + // 详细打印每条消息的内容和属性,便于调试 + Logger.d(TAG, '===== 历史消息详情开始 ====='); + for (int i = 0; i < historyMessages.length; i++) { + final msg = historyMessages[i]; + Logger.d( + TAG, + '消息[$i]: {' + 'isUser: ${msg.isUser}, ' + 'text: "${msg.text.length > 50 ? msg.text.substring(0, 50) + "..." : msg.text}", ' + 'time: ${msg.timestamp}, ' + 'hasImage: ${msg.hasImage}, ' + 'imagePath: ${msg.imagePath ?? "无"}, ' + 'meta: ${msg.meta != null ? "有" : "无"}' + '}'); + } + Logger.d(TAG, '===== 历史消息详情结束 ====='); } else { // 没有历史记录时添加欢迎消息 _addWelcomeMessage(); @@ -180,7 +209,7 @@ class AgentController extends GetxController { void _addWelcomeMessage() { messages.add(Message( isUser: false, - text: '你好!我是**Emma**,我能帮你:\n\n' + text: '你好!我是**小言**,我能帮你:\n\n' '• 答问题和提供信息\n\n' '• 实时语音翻译\n\n' '• 推荐音乐和播客\n\n' @@ -424,7 +453,7 @@ class AgentController extends GetxController { }); } - // 发送文本消息 + // 发送消息(包括所有支持的消息类型) Future sendTextMessage() async { // 如果图片输入是活跃的,使用选中的图片和文本框的内容 if (isImageInputActive.value && selectedImagePath.value != null) { @@ -466,6 +495,9 @@ class AgentController extends GetxController { return; } + // 复制图片到应用永久存储目录 + final permanentImagePath = await _copyImageToPermanentStorage(imagePath); + // 显示的文本,如果没有提供则使用默认值 final displayText = text?.isNotEmpty == true ? text! : '[图片]'; @@ -474,7 +506,7 @@ class AgentController extends GetxController { isUser: true, text: displayText, hasImage: true, - imagePath: imagePath, + imagePath: permanentImagePath, ); messages.add(message); @@ -489,9 +521,12 @@ class AgentController extends GetxController { // 标记为新的AI回复 _isNewAssistantResponse = true; + // 保存消息到持久化存储 + //await _saveMessageToStorage(message); + // 调用Agent Service处理图片,考虑TTS状态 await AgentService.processImageInput( - imagePath, + permanentImagePath, text: text ?? '', speakResponse: isTtsEnabled.value, ); @@ -502,6 +537,68 @@ class AgentController extends GetxController { } } +// 将图片复制到应用的永久存储目录 + Future _copyImageToPermanentStorage(String sourcePath) async { + try { + final File sourceFile = File(sourcePath); + if (!await sourceFile.exists()) { + Logger.e(TAG, '源图片文件不存在: $sourcePath'); + return sourcePath; // 如果源文件不存在,返回原路径 + } + + // 创建应用文档目录下的chatImages文件夹 + final appDir = await getApplicationDocumentsDirectory(); + final imagesDir = Directory('${appDir.path}/chatImages'); + if (!await imagesDir.exists()) { + await imagesDir.create(recursive: true); + } + + // 生成唯一文件名 + final fileName = + '${DateTime.now().millisecondsSinceEpoch}_${sourcePath.split('/').last}'; + final targetPath = '${imagesDir.path}/$fileName'; + + // 复制文件 + await sourceFile.copy(targetPath); + Logger.i(TAG, '图片已复制到永久存储: $targetPath'); + + return targetPath; + } catch (e) { + Logger.e(TAG, '复制图片到永久存储失败: $e'); + return sourcePath; // 出错时返回原路径 + } + } + + // 保存消息到持久化存储 + // Future _saveMessageToStorage(Message message) async { + // try { + // // 准备元数据,包含图片信息 + // Map metadata = {}; + // if (message.meta != null) { + // metadata = Map.from(message.meta!); + // } + + // // 添加图片相关信息到元数据 + // if (message.hasImage) { + // metadata['hasImage'] = true; + // metadata['imagePath'] = message.imagePath; + // } + + // // 保存消息 + // await ChatStorage.saveMessage( + // sessionId: sessionId, + // sender: message.isUser ? 'user' : 'assistant', + // message: message.text, + // metadata: metadata.isNotEmpty ? jsonEncode(metadata) : null, + // //timestamp: message.timestamp.toIso8601String(), + // ); + + // Logger.i(TAG, '消息已保存到持久化存储'); + // } catch (e) { + // Logger.e(TAG, '保存消息到持久化存储失败: $e'); + // } + // } + // 切换输入模式 void toggleInputMode() async { // 如果当前在图片输入模式,先清除 diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 9d9aec1d7..54cb541d3 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -52,7 +52,7 @@ class AgentView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Emma', + '小言'.tr, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index a3ef740d6..cda7c7df7 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -91,7 +91,6 @@ object AgentService : CoroutineScope { // 空闲检测相关 private var idleCheckJob: Job? = null private val maxIdleSeconds = 10 // 最大空闲秒数 - /** * 初始化系统提示词 */ @@ -644,12 +643,14 @@ object AgentService : CoroutineScope { * @param displayText 用于显示和存储的文本 * @param speakResponse 是否朗读回复 * @param hasImage 是否包含图片 + * @param imagePath 图片路径(如果有) */ private fun processWithOpenAIInternal( userMessage: JSONObject, displayText: String, speakResponse: Boolean = true, - hasImage: Boolean = false + hasImage: Boolean = false, + imagePath: String? = null ) { // 如果有正在进行的AI流式输出,先停止它 stopAiStream() @@ -662,8 +663,15 @@ object AgentService : CoroutineScope { // 使用历史记录作为上下文发送到OpenAI val responseBuilder = StringBuilder() - var metadata:String = "" - + var aiMetadata:String = "" + var userMetadata = JSONObject() + + // 如果有图片,添加图片信息到元数据 + if (hasImage) { + userMetadata.put("hasImage", true) + userMetadata.put("imagePath", imagePath) + } + // 添加系统提示词到历史消息的副本中 val messagesWithSystemPrompt = JSONArray() @@ -741,7 +749,9 @@ object AgentService : CoroutineScope { addToHistoryMessages(openAIService.createAssistantMessage(response)) // 保存聊天记录 - saveChatMessage(displayText, response,metadata) + saveChatMessage(displayText, response,aiMetadata,userMetadata.toString()) + + } // 标记AI流式输出已完成 @@ -784,7 +794,7 @@ object AgentService : CoroutineScope { "result" to functionCallResult.toString(), )) Log.d(TAG, "mcp调用结果: $functionCallResult") - metadata = autoHandleFcunCallResult(functionCallResult); + aiMetadata = autoHandleFcunCallResult(functionCallResult); } } ) @@ -887,7 +897,7 @@ object AgentService : CoroutineScope { /** * 保存聊天消息 */ - private fun saveChatMessage(userMessage: String, assistantMessage: String,metadata : String) { + private fun saveChatMessage(userMessage: String, assistantMessage: String,aiMetadata : String,userMetadata : String) { // 显式指定IO调度器,数据库操作应在后台线程执行,即使父协程在Main上下文中 launch(Dispatchers.IO) { try { @@ -895,7 +905,8 @@ object AgentService : CoroutineScope { val userMessageId = chatStorageHelper.saveMessage( sessionId = sessionId, message = userMessage, - sender = "user" + sender = "user", + metadata = userMetadata ) if (userMessageId != -1L) { @@ -904,7 +915,7 @@ object AgentService : CoroutineScope { sessionId = sessionId, message = assistantMessage, sender = "assistant", - metadata = metadata + metadata = aiMetadata ) // if (assistantMessageId != -1L) { @@ -1009,6 +1020,11 @@ object AgentService : CoroutineScope { )) return@launch } + + // 创建带图片的用户消息并处理 + val userMessage = openAIService.createUserMessageWithImage(text, imageBase64) + // 图片描述用于存储 + val displayText = text.ifEmpty { "[图片]" } // 通知图片准备完成 sendEvent("image_ready", mapOf( @@ -1016,8 +1032,8 @@ object AgentService : CoroutineScope { "imagePath" to imagePath )) - // 处理包含图片的消息 - processImageWithOpenAI(imageBase64, text, speakResponse) + // 处理消息并传入存储路径 + processWithOpenAIInternal(userMessage, displayText, speakResponse, true, imagePath) } catch (e: Exception) { Log.e(TAG, "处理图片失败: ${e.message}") sendEvent("error", mapOf( diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index ffcdd5fbb..4397f4a85 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -7,61 +7,61 @@ import 'package:flutter/services.dart'; enum AgentServiceEventType { /// 识别开始 recognitionStarted, - + /// 识别中 recognizing, - + /// 识别最终结果 recognitionResult, - + /// 识别停止 recognitionStopped, - + /// 识别取消 recognitionCanceled, - + /// 自动停止 autoStop, - + /// 识别错误 recognitionError, - + /// TTS开始 ttsStarted, - + /// TTS完成 ttsCompleted, - + /// TTS取消 ttsCanceled, - + /// TTS停止 ttsStopped, - + /// 响应中断 responseInterrupted, - + /// AI助手Token assistantToken, - + /// AI助手完整响应 assistantResponse, - + /// 函数调用 functionCall, - + /// 函数调用结果 functionCallResult, - + /// 图片处理中 imageProcessing, - + /// 图片准备就绪 imageReady, - + /// 错误 error, - + /// 未知事件 unknown } @@ -70,12 +70,12 @@ enum AgentServiceEventType { class AgentServiceEvent { /// 事件类型 final AgentServiceEventType type; - + /// 事件数据 final Map data; - + AgentServiceEvent({required this.type, required this.data}); - + @override String toString() => 'AgentServiceEvent(type: $type, data: $data)'; } @@ -85,20 +85,22 @@ class AgentServiceException implements Exception { final String code; final String message; final dynamic details; - + AgentServiceException(this.code, this.message, [this.details]); - + @override String toString() => '代理服务异常($code): $message'; } /// 代理服务插件 class AgentService { - static const MethodChannel _channel = MethodChannel('com.yunqiinnovation.agent_service'); - static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.agent_service/events'); - + static const MethodChannel _channel = + MethodChannel('com.yunqiinnovation.agent_service'); + static const EventChannel _eventChannel = + EventChannel('com.yunqiinnovation.agent_service/events'); + static Stream? _eventStream; - + /// 获取事件流 static Stream get events { _eventStream ??= _eventChannel.receiveBroadcastStream().map((event) { @@ -106,22 +108,21 @@ class AgentService { final Map eventMap = jsonDecode(event); final String eventName = eventMap['event']; final Map eventData = eventMap['data']; - + // 将事件名称转换为枚举 final AgentServiceEventType type = _stringToEventType(eventName); - + return AgentServiceEvent(type: type, data: eventData); } catch (e) { return AgentServiceEvent( - type: AgentServiceEventType.unknown, - data: {'error': e.toString(), 'rawEvent': event} - ); + type: AgentServiceEventType.unknown, + data: {'error': e.toString(), 'rawEvent': event}); } }); - + return _eventStream!; } - + /// 将字符串事件名转换为枚举类型 static AgentServiceEventType _stringToEventType(String eventName) { switch (eventName) { @@ -165,9 +166,9 @@ class AgentService { return AgentServiceEventType.unknown; } } - + /// 初始化代理服务 - /// + /// /// [config] 初始化配置,应包含以下参数: /// - azureSpeechKey: Azure语音服务密钥 /// - azureSpeechRegion: Azure语音服务区域 @@ -176,7 +177,7 @@ class AgentService { /// - openaiModel: (可选) OpenAI模型名称,默认为"gpt-3.5-turbo" /// - mcpServer: (可选) MCP服务器地址 /// - systemPrompt: (可选) 自定义系统提示词,用于设置AI语音助手的行为和风格 - /// + /// /// 返回是否初始化成功 static Future initialize(Map config) async { try { @@ -188,9 +189,9 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '初始化失败', e.details); } } - + /// 启动语音助手服务 - /// + /// /// 启动常驻的语音助手服务,支持后台蓝牙设备唤醒和媒体按钮唤醒 /// [config] 配置参数,应包含以下参数: /// - azureSpeechKey: Azure语音服务密钥 @@ -199,7 +200,7 @@ class AgentService { /// - openaiBaseUrl: (可选) OpenAI API 基础URL /// - openaiModel: (可选) OpenAI模型名称,默认为"gpt-3.5-turbo" /// - mcpServer: (可选) MCP服务器地址 - /// + /// /// 返回是否成功启动服务 static Future startAgentService(Map config) async { try { @@ -211,9 +212,9 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '启动语音助手服务失败', e.details); } } - + /// 停止语音助手服务 - /// + /// /// 停止常驻的语音助手服务 /// 返回是否成功停止服务 static Future stopAgentService() async { @@ -224,9 +225,9 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '停止语音助手服务失败', e.details); } } - + /// 开始对话 - /// + /// /// 启动语音识别,开始监听用户语音输入 /// 返回是否成功开始对话 static Future startConversation() async { @@ -237,9 +238,9 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '启动对话失败', e.details); } } - + /// 停止对话 - /// + /// /// 停止语音识别 /// 返回是否成功停止对话 static Future stopConversation() async { @@ -250,13 +251,14 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '停止对话失败', e.details); } } - + /// 处理文本输入 - /// + /// /// [text] 文本内容 /// [speakResponse] 是否朗读响应 /// 返回是否成功处理文本 - static Future processTextInput(String text, {bool speakResponse = false}) async { + static Future processTextInput(String text, + {bool speakResponse = false}) async { try { final bool result = await _channel.invokeMethod('processTextInput', { 'text': text, @@ -267,9 +269,9 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '处理文本输入失败', e.details); } } - + /// 朗读文本 - /// + /// /// [text] 要朗读的文本 static Future speakText(String text) async { try { @@ -281,7 +283,7 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '语音合成失败', e.details); } } - + /// 停止语音合成 static Future stopTts() async { try { @@ -291,7 +293,7 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '停止语音合成失败', e.details); } } - + /// 清除聊天历史 static Future clearChatHistory() async { try { @@ -301,15 +303,15 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '清除聊天历史失败', e.details); } } - + /// 中断当前响应 - /// + /// /// 停止语音合成和AI流输出 static Future interruptCurrentResponse() async { try { // 首先尝试停止TTS播放 await stopTts(); - + // 根据原生层的实现,processTextInput可以被用来中断当前的AI流输出 // 这里我们通过发送一个特殊的空字符串来实现这一点 // 然后立即返回true,因为我们不需要真正处理任何文本 @@ -318,14 +320,15 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '中断响应失败', e.details); } } - + /// 处理图片输入 - /// + /// /// [imagePath] 图片文件路径 /// [text] 可选的文本描述或问题 /// [speakResponse] 是否朗读响应 /// 返回是否成功处理图片 - static Future processImageInput(String imagePath, {String text = "", bool speakResponse = false}) async { + static Future processImageInput(String imagePath, + {String text = "", bool speakResponse = false}) async { try { final bool result = await _channel.invokeMethod('processImageInput', { 'imagePath': imagePath, @@ -337,7 +340,7 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '处理图片输入失败', e.details); } } - + /// 释放资源 static Future dispose() async { try { @@ -347,4 +350,4 @@ class AgentService { throw AgentServiceException(e.code, e.message ?? '释放资源失败', e.details); } } -} \ No newline at end of file +}