Browse Source

Merge branch 'lxm' of https://github.com/deepcloud2048/deep_voice into dev

newdev_shunjiawei
liwei1dao 1 year ago
parent
commit
89faafbbad
  1. 119
      lib/modules/agent/controllers/agent_controller.dart
  2. 2
      lib/modules/agent/views/agent_view.dart
  3. 1204
      lib/modules/agent/views/message_bubble.dart
  4. 261
      lib/modules/agent/views/music_card.dart
  5. 211
      lib/modules/agent/views/travel_card.dart
  6. 313
      lib/modules/agent/views/weather_card.dart
  7. 4
      lib/modules/meeting/controllers/meeting_details_controller.dart
  8. 43
      lib/modules/meeting/views/speaker_all_bottom_sheet.dart
  9. 14
      lib/modules/meeting/views/speaker_bottom_sheet.dart
  10. 2
      lib/modules/meeting/views/speech_tab.dart
  11. 38
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  12. 127
      local_plugins/agent_service/lib/agent_service.dart
  13. 2
      pubspec.yaml

119
lib/modules/agent/controllers/agent_controller.dart

@ -14,6 +14,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
@ -143,10 +144,11 @@ class AgentController extends GetxController {
Future<void> _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) {
@ -155,11 +157,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();
@ -168,6 +181,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();
@ -187,11 +216,11 @@ class AgentController extends GetxController {
void _addWelcomeMessage() {
messages.add(Message(
isUser: false,
text: '你好!我是Emma,我能帮你:\n\n'
'• 回答问题和提供信息\n'
'• 实时语音翻译\n'
'• 推荐音乐和播客\n'
'• 语音备忘录和提醒\n'
text: '你好!我是**小言**,我能帮你:\n\n'
'• 答问题和提供信息\n\n'
'• 实时语音翻译\n\n'
'• 推荐音乐和播客\n\n'
'• 语音备忘录和提醒\n\n'
'• 控制耳机功能\n\n'
'有什么我可以帮你的吗?',
));
@ -493,7 +522,7 @@ class AgentController extends GetxController {
});
}
// 发送文本消息
// 发送消息(包括所有支持的消息类型)
Future<void> sendTextMessage() async {
// 如果图片输入是活跃的,使用选中的图片和文本框的内容
if (isImageInputActive.value && selectedImagePath.value != null) {
@ -535,6 +564,9 @@ class AgentController extends GetxController {
return;
}
// 复制图片到应用永久存储目录
final permanentImagePath = await _copyImageToPermanentStorage(imagePath);
// 显示的文本,如果没有提供则使用默认值
final displayText = text?.isNotEmpty == true ? text! : '[图片]';
@ -543,7 +575,7 @@ class AgentController extends GetxController {
isUser: true,
text: displayText,
hasImage: true,
imagePath: imagePath,
imagePath: permanentImagePath,
);
messages.add(message);
@ -558,9 +590,12 @@ class AgentController extends GetxController {
// 标记为新的AI回复
_isNewAssistantResponse = true;
// 保存消息到持久化存储
//await _saveMessageToStorage(message);
// 调用Agent Service处理图片,考虑TTS状态
await AgentService.processImageInput(
imagePath,
permanentImagePath,
text: text ?? '',
speakResponse: isTtsEnabled.value,
);
@ -571,6 +606,68 @@ class AgentController extends GetxController {
}
}
// 将图片复制到应用的永久存储目录
Future<String> _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<void> _saveMessageToStorage(Message message) async {
// try {
// // 准备元数据,包含图片信息
// Map<String, dynamic> metadata = {};
// if (message.meta != null) {
// metadata = Map<String, dynamic>.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 {
// 如果当前在图片输入模式,先清除

2
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,

1204
lib/modules/agent/views/message_bubble.dart

File diff suppressed because it is too large

261
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<AgentController>(); // 获取控制器
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
Map<String, String> 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<Map<String, dynamic>> 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),
// ),
// ),
// ),
// ],
// ),
],
);
}

211
lib/modules/agent/views/travel_card.dart

@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
/// 构建旅游内容卡片
Widget buildTravelContent(
BuildContext context, List<Map<String, dynamic>> 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,
),
],
),
);
}

313
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<String, dynamic> now, List<Map<String, dynamic>> 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;
}

4
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] ';
}

43
lib/modules/meeting/views/speaker_all_bottom_sheet.dart

@ -462,12 +462,20 @@ class _SpeakerAllBottomSheetState extends State<SpeakerAllBottomSheet> {
List textList = _controller.textList;
List newTextList = [];
List editSpeakerList = [];
List personnelList = [];
String personnel = '';
bool isLoopEnd = false;
for (var element in textList) {
final dataMap = Map<String, dynamic>.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<SpeakerAllBottomSheet> {
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();

14
lib/modules/meeting/views/speaker_bottom_sheet.dart

@ -365,7 +365,19 @@ class _SpeakerBottomSheetState extends State<SpeakerBottomSheet> {
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();

2
lib/modules/meeting/views/speech_tab.dart

@ -162,7 +162,7 @@ class SpeechTab extends GetView<MeetingDetailsController> {
}
if (controller.currentDuration.value != 0) {
if (controller.currentDuration.value >= starttime &&
controller.currentDuration.value <= endtime) {
controller.currentDuration.value < endtime) {
isActive = true;
}
}

38
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt

@ -88,7 +88,6 @@ object AgentService : CoroutineScope {
// 空闲检测相关
private var idleCheckJob: Job? = null
private val maxIdleSeconds = 10 // 最大空闲秒数
/**
* 初始化系统提示词
*/
@ -641,12 +640,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()
@ -659,8 +660,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()
@ -738,7 +746,9 @@ object AgentService : CoroutineScope {
addToHistoryMessages(openAIService.createAssistantMessage(response))
// 保存聊天记录
saveChatMessage(displayText, response,metadata)
saveChatMessage(displayText, response,aiMetadata,userMetadata.toString())
}
// 标记AI流式输出已完成
@ -781,7 +791,7 @@ object AgentService : CoroutineScope {
"result" to functionCallResult.toString(),
))
Log.d(TAG, "mcp调用结果: $functionCallResult")
metadata = autoHandleFcunCallResult(functionCallResult);
aiMetadata = autoHandleFcunCallResult(functionCallResult);
}
}
)
@ -884,7 +894,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 {
@ -892,7 +902,8 @@ object AgentService : CoroutineScope {
val userMessageId = chatStorageHelper.saveMessage(
sessionId = sessionId,
message = userMessage,
sender = "user"
sender = "user",
metadata = userMetadata
)
if (userMessageId != -1L) {
@ -901,7 +912,7 @@ object AgentService : CoroutineScope {
sessionId = sessionId,
message = assistantMessage,
sender = "assistant",
metadata = metadata
metadata = aiMetadata
)
// if (assistantMessageId != -1L) {
@ -1006,6 +1017,11 @@ object AgentService : CoroutineScope {
))
return@launch
}
// 创建带图片的用户消息并处理
val userMessage = openAIService.createUserMessageWithImage(text, imageBase64)
// 图片描述用于存储
val displayText = text.ifEmpty { "[图片]" }
// 通知图片准备完成
sendEvent("image_ready", mapOf(
@ -1013,8 +1029,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(

127
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<String, dynamic> 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<AgentServiceEvent>? _eventStream;
/// 获取事件流
static Stream<AgentServiceEvent> get events {
_eventStream ??= _eventChannel.receiveBroadcastStream().map((event) {
@ -106,22 +108,21 @@ class AgentService {
final Map<String, dynamic> eventMap = jsonDecode(event);
final String eventName = eventMap['event'];
final Map<String, dynamic> 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<bool> initialize(Map<String, dynamic> 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<bool> startAgentService(Map<String, dynamic> config) async {
try {
@ -211,9 +212,9 @@ class AgentService {
throw AgentServiceException(e.code, e.message ?? '启动语音助手服务失败', e.details);
}
}
/// 停止语音助手服务
///
///
/// 停止常驻的语音助手服务
/// 返回是否成功停止服务
static Future<bool> stopAgentService() async {
@ -224,9 +225,9 @@ class AgentService {
throw AgentServiceException(e.code, e.message ?? '停止语音助手服务失败', e.details);
}
}
/// 开始对话
///
///
/// 启动语音识别,开始监听用户语音输入
/// 返回是否成功开始对话
static Future<bool> startConversation() async {
@ -237,9 +238,9 @@ class AgentService {
throw AgentServiceException(e.code, e.message ?? '启动对话失败', e.details);
}
}
/// 停止对话
///
///
/// 停止语音识别
/// 返回是否成功停止对话
static Future<bool> stopConversation() async {
@ -250,13 +251,14 @@ class AgentService {
throw AgentServiceException(e.code, e.message ?? '停止对话失败', e.details);
}
}
/// 处理文本输入
///
///
/// [text] 文本内容
/// [speakResponse] 是否朗读响应
/// 返回是否成功处理文本
static Future<bool> processTextInput(String text, {bool speakResponse = false}) async {
static Future<bool> 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<bool> speakText(String text) async {
try {
@ -281,7 +283,7 @@ class AgentService {
throw AgentServiceException(e.code, e.message ?? '语音合成失败', e.details);
}
}
/// 停止语音合成
static Future<bool> stopTts() async {
try {
@ -291,7 +293,7 @@ class AgentService {
throw AgentServiceException(e.code, e.message ?? '停止语音合成失败', e.details);
}
}
/// 清除聊天历史
static Future<bool> clearChatHistory() async {
try {
@ -301,15 +303,15 @@ class AgentService {
throw AgentServiceException(e.code, e.message ?? '清除聊天历史失败', e.details);
}
}
/// 中断当前响应
///
///
/// 停止语音合成和AI流输出
static Future<bool> 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<bool> processImageInput(String imagePath, {String text = "", bool speakResponse = false}) async {
static Future<bool> 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<bool> dispose() async {
try {
@ -347,4 +350,4 @@ class AgentService {
throw AgentServiceException(e.code, e.message ?? '释放资源失败', e.details);
}
}
}
}

2
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

Loading…
Cancel
Save