Browse Source

补充底层日志采集接口

newdev_shunjiawei
liwei1dao 7 months ago
parent
commit
315ba39189
  1. 209
      lib/core/utils/logger.dart
  2. 17
      lib/data/services/ble_manager.dart
  3. 82
      lib/data/services/network/dio_manager.dart
  4. 31
      lib/data/services/speech_impl/azure_asr_service.dart
  5. 3
      lib/main.dart
  6. 9
      lib/modules/log_export/bindings/log_export_binding.dart
  7. 130
      lib/modules/log_export/controllers/log_export_controller.dart
  8. 271
      lib/modules/log_export/views/log_export_view.dart
  9. 16
      lib/modules/settings/controllers/settings_controller.dart
  10. 20
      lib/modules/settings/views/settings_view.dart
  11. 9
      lib/routes/app_pages.dart
  12. 1
      lib/routes/app_routes.dart
  13. 153
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  14. 14
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  15. 187
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  16. 15
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift
  17. 19
      local_plugins/agent_service/lib/agent_service.dart
  18. 19
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  19. 3
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  20. 101
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt
  21. 21
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift
  22. 5
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  23. 239
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift
  24. 2
      pubspec.yaml

209
lib/core/utils/logger.dart

@ -1,4 +1,8 @@
import 'dart:developer' as developer;
import 'dart:io';
import 'dart:async';
import 'package:path_provider/path_provider.dart';
import 'package:intl/intl.dart';
/// 日志级别
enum LogLevel {
@ -9,62 +13,229 @@ enum LogLevel {
}
/// 日志工具类
///
/// 提供统一的日志记录接口,方便后续扩展和管理
///
/// 提供统一的日志记录接口,支持控制台输出和文件存储
class Logger {
/// 当前日志级别,低于此级别的日志不会被输出
static LogLevel _currentLevel = LogLevel.debug;
/// 默认标签
static const String _defaultTag = 'App';
/// 文件日志相关
static IOSink? _logSink;
static File? _currentLogFile;
static bool _fileLogEnabled = false;
static String? _logDirPath;
static final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss.SSS');
static final DateFormat _fileDateFormat = DateFormat('yyyy-MM-dd');
/// 最大单个日志文件大小 (2MB)
static const int _maxFileSize = 2 * 1024 * 1024;
/// 最多保留日志文件数量
static const int _maxLogFiles = 7;
/// 写入计数器,用于节流轮转检查和刷盘
static int _writeCount = 0;
/// 轮转检查间隔(每 100 次写入检查一次)
static const int _rotationCheckInterval = 100;
/// 刷盘间隔(每 50 次写入刷一次)
static const int _flushInterval = 50;
/// 轮转锁,防止并发轮转
static bool _isRotating = false;
/// 当前日志文件的日期标识,用于快速判断是否需要换天
static String _currentFileDate = '';
/// 初始化文件日志
static Future<void> initFileLog() async {
try {
final dir = await getApplicationDocumentsDirectory();
_logDirPath = '${dir.path}/logs';
final logDir = Directory(_logDirPath!);
if (!await logDir.exists()) {
await logDir.create(recursive: true);
}
await _openLogFile();
_fileLogEnabled = true;
Logger.i('Logger', '文件日志初始化成功: $_logDirPath');
} catch (e) {
developer.log('[Logger] 文件日志初始化失败: $e');
}
}
/// 打开/创建当前日志文件
static Future<void> _openLogFile() async {
final today = _fileDateFormat.format(DateTime.now());
final filePath = '$_logDirPath/app_$today.log';
_currentLogFile = File(filePath);
// 先关闭旧 sink,必须 await 确保关闭完成
await _logSink?.flush();
await _logSink?.close();
_logSink = _currentLogFile!.openWrite(mode: FileMode.append);
_currentFileDate = today;
}
/// 检查日志文件是否需要轮转(受节流和锁保护)
static Future<void> _checkRotation() async {
if (_isRotating || _currentLogFile == null) return;
_isRotating = true;
try {
// 检查日期是否变更
final today = _fileDateFormat.format(DateTime.now());
if (_currentFileDate != today) {
await _openLogFile();
}
// 检查文件大小
if (await _currentLogFile!.exists()) {
final size = await _currentLogFile!.length();
if (size > _maxFileSize) {
final now = DateTime.now();
final suffix = DateFormat('HHmmss').format(now);
final todayStr = _fileDateFormat.format(now);
final newPath = '$_logDirPath/app_${todayStr}_$suffix.log';
await _logSink?.flush();
await _logSink?.close();
_currentLogFile = File(newPath);
_logSink = _currentLogFile!.openWrite(mode: FileMode.append);
}
}
// 清理旧日志文件
await _cleanOldLogs();
} catch (_) {} finally {
_isRotating = false;
}
}
/// 清理旧日志文件,保留最近 _maxLogFiles 个
static Future<void> _cleanOldLogs() async {
if (_logDirPath == null) return;
try {
final logDir = Directory(_logDirPath!);
final files = await logDir
.list()
.where((e) => e is File && e.path.endsWith('.log'))
.cast<File>()
.toList();
if (files.length > _maxLogFiles) {
files.sort((a, b) => a.path.compareTo(b.path));
final toDelete = files.sublist(0, files.length - _maxLogFiles);
for (final f in toDelete) {
await f.delete();
}
}
} catch (_) {}
}
/// 单行最大字符数,超过则分批写入
static const int _maxLineLength = 4000;
/// 写入日志到文件
static void _writeToFile(String level, String tag, String message) {
if (!_fileLogEnabled || _logSink == null) return;
try {
final timestamp = _dateFormat.format(DateTime.now());
if (message.length <= _maxLineLength) {
_logSink!.writeln('$timestamp [$level][$tag] $message');
} else {
final totalParts = (message.length + _maxLineLength - 1) ~/ _maxLineLength;
for (var i = 0; i < totalParts; i++) {
final start = i * _maxLineLength;
final end = start + _maxLineLength > message.length ? message.length : start + _maxLineLength;
final chunk = message.substring(start, end);
_logSink!.writeln('$timestamp [$level][$tag] [${i + 1}/$totalParts] $chunk');
}
}
_writeCount++;
// 定期刷盘,确保数据写入磁盘
if (_writeCount % _flushInterval == 0) {
_logSink!.flush();
}
// 定期检查轮转(节流,不是每次写入都检查)
if (_writeCount % _rotationCheckInterval == 0) {
_checkRotation();
}
} catch (e) {
// 文件写入失败不应影响业务逻辑(尤其是 Dio 拦截器链)
developer.log('[Logger] 文件写入失败: $e');
}
}
/// 获取日志目录路径
static String? get logDirPath => _logDirPath;
/// 获取所有日志文件列表
static Future<List<File>> getLogFiles() async {
if (_logDirPath == null) return [];
final logDir = Directory(_logDirPath!);
if (!await logDir.exists()) return [];
final files = await logDir
.list()
.where((e) => e is File && e.path.endsWith('.log'))
.cast<File>()
.toList();
files.sort((a, b) => b.path.compareTo(a.path)); // 最新在前
return files;
}
/// 刷新缓冲区(导出前调用)
static Future<void> flush() async {
await _logSink?.flush();
}
/// 设置日志级别
static void setLevel(LogLevel level) {
_currentLevel = level;
}
/// 输出调试日志 (不带标签版本)
static void debug(String message) {
if (_currentLevel.index <= LogLevel.debug.index) {
_log('DEBUG', _defaultTag, message);
}
}
/// 输出调试日志
static void d(String tag, String message) {
if (_currentLevel.index <= LogLevel.debug.index) {
_log('DEBUG', tag, message);
}
}
/// 输出信息日志 (不带标签版本)
static void info(String message) {
if (_currentLevel.index <= LogLevel.info.index) {
_log('INFO', _defaultTag, message);
}
}
/// 输出信息日志
static void i(String tag, String message) {
if (_currentLevel.index <= LogLevel.info.index) {
_log('INFO', tag, message);
}
}
/// 输出警告日志 (不带标签版本)
static void warning(String message) {
if (_currentLevel.index <= LogLevel.warning.index) {
_log('WARNING', _defaultTag, message);
}
}
/// 输出警告日志
static void w(String tag, String message) {
if (_currentLevel.index <= LogLevel.warning.index) {
_log('WARNING', tag, message);
}
}
/// 输出错误日志 (不带标签版本)
static void error(String message, [dynamic error, StackTrace? stackTrace]) {
if (_currentLevel.index <= LogLevel.error.index) {
@ -77,7 +248,7 @@ class Logger {
}
}
}
/// 输出错误日志
static void e(String tag, String message, [dynamic error, StackTrace? stackTrace]) {
if (_currentLevel.index <= LogLevel.error.index) {
@ -90,9 +261,19 @@ class Logger {
}
}
}
/// 内部日志输出方法
static void _log(String level, String tag, String message) {
developer.log('[$tag]: $message');
if (message.length <= _maxLineLength) {
developer.log('[$tag]: $message');
} else {
final totalParts = (message.length + _maxLineLength - 1) ~/ _maxLineLength;
for (var i = 0; i < totalParts; i++) {
final start = i * _maxLineLength;
final end = start + _maxLineLength > message.length ? message.length : start + _maxLineLength;
developer.log('[$tag]: [${i + 1}/$totalParts] ${message.substring(start, end)}');
}
}
_writeToFile(level, tag, message);
}
}

17
lib/data/services/ble_manager.dart

@ -1002,6 +1002,23 @@ class BleManager extends GetxService {
}
}
// 设置原生日志回调,将 Agent 底层日志写入文件
AgentService.nativeLogCallback ??= (level, message) {
switch (level) {
case 'ERROR':
Logger.e('NativeAgent', message);
break;
case 'WARNING':
Logger.w('NativeAgent', message);
break;
case 'DEBUG':
Logger.d('NativeAgent', message);
break;
default:
Logger.i('NativeAgent', message);
}
};
Logger.info('lxm--准备调用AgentService.startAgentService启动BLE服务');
final result = await AgentService.startAgentService(config);
Logger.info('lxm--AgentService.startAgentService调用结果: $result');

82
lib/data/services/network/dio_manager.dart

@ -1,9 +1,11 @@
import 'dart:io';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get/get.dart' hide Response;
import 'package:get/get.dart' hide Response, FormData;
import '../../../core/utils/logger.dart';
import '../../models/channel_app_model.dart';
import '../version_update_service.dart';
import 'auth_interceptor.dart';
@ -54,17 +56,10 @@ class DioManager {
dio = Dio(options);
// 日志拦截器必须在 AuthInterceptor 前面,
// 否则 AuthInterceptor 的 handler.resolve() 会跳过后续拦截器
dio.interceptors.add(_ApiLogInterceptor());
dio.interceptors.add(AuthInterceptor());
// 调试:输出请求与响应的详细日志,便于排查服务端创建订单或查询订单问题
dio.interceptors.add(LogInterceptor(
request: true,
requestHeader: true,
requestBody: true,
responseHeader: false,
responseBody: true,
error: true,
logPrint: (obj) => print('[DIO] $obj'),
));
}
// 请求,返回参数为 T
@ -86,3 +81,68 @@ class DioManager {
return response.data;
}
}
/// API 请求/响应日志拦截器,写入文件日志
/// 必须放在 AuthInterceptor 之前,才能完整记录原始响应
class _ApiLogInterceptor extends Interceptor {
static const String _tag = 'API';
String _formatBody(dynamic data) {
if (data == null) return 'null';
try {
if (data is FormData) {
final fields = data.fields.map((e) => '${e.key}=${e.value}').join(', ');
final files = data.files.map((e) => '${e.key}=${e.value.filename}').join(', ');
return 'FormData{fields: [$fields], files: [$files]}';
}
if (data is Map || data is List) {
return const JsonEncoder().convert(data);
}
return data.toString();
} catch (_) {
return data.toString();
}
}
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final method = options.method;
final path = options.path;
final buf = StringBuffer();
buf.writeln('>>> $method $path');
if (options.queryParameters.isNotEmpty) {
buf.writeln(' Query: ${_formatBody(options.queryParameters)}');
}
if (options.data != null) {
buf.writeln(' Body: ${_formatBody(options.data)}');
}
Logger.i(_tag, buf.toString().trimRight());
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final method = response.requestOptions.method;
final path = response.requestOptions.path;
final statusCode = response.statusCode;
final buf = StringBuffer();
buf.writeln('<<< $method $path [$statusCode]');
buf.writeln(' Response: ${_formatBody(response.data)}');
Logger.i(_tag, buf.toString().trimRight());
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final method = err.requestOptions.method;
final path = err.requestOptions.path;
final statusCode = err.response?.statusCode ?? 'N/A';
final buf = StringBuffer();
buf.writeln('<<< $method $path [$statusCode] ${err.type}: ${err.message}');
if (err.response?.data != null) {
buf.writeln(' Error Body: ${_formatBody(err.response?.data)}');
}
Logger.e(_tag, buf.toString().trimRight());
handler.next(err);
}
}

31
lib/data/services/speech_impl/azure_asr_service.dart

@ -349,15 +349,31 @@ class AzureAsrService extends GetxService implements AsrService {
}
}
// 添加日志帮助调试
switch (eventType) {
case 'nativeLog':
final String level = eventMap['level'] as String? ?? 'INFO';
final String message = eventMap['message'] as String? ?? '';
switch (level) {
case 'ERROR':
Logger.e('NativeASR', message);
break;
case 'WARNING':
Logger.w('NativeASR', message);
break;
case 'DEBUG':
Logger.d('NativeASR', message);
break;
default:
Logger.i('NativeASR', message);
}
return;
case 'result':
final String text = eventMap['text'] as String? ?? '';
final String detectedLanguage =
eventMap['detectedLanguage'] as String? ?? '';
_latestRecognizedText = text;
_latestDetectedLanguage = detectedLanguage;
Logger.d('ASR', '[$_latestAsrProvider] result: lang=$detectedLanguage, text=${text}');
_eventStreamController?.add(RecognitionEvent(
type: RecognitionEventType.finalResult,
text: text,
@ -371,6 +387,7 @@ class AzureAsrService extends GetxService implements AsrService {
eventMap['detectedLanguage'] as String? ?? '';
_latestRecognizedText = text;
_latestDetectedLanguage = detectedLanguage;
Logger.d('ASR', '[$_latestAsrProvider] result1: lang=$detectedLanguage, text=${text}');
_eventStreamController?.add(RecognitionEvent(
type: RecognitionEventType.finalResult1,
text: text,
@ -391,6 +408,7 @@ class AzureAsrService extends GetxService implements AsrService {
));
break;
case 'sessionStarted':
Logger.i('ASR', '[$_latestAsrProvider] sessionStarted');
_eventStreamController?.add(RecognitionEvent(
type: RecognitionEventType.sessionStarted,
provider: _latestAsrProvider,
@ -399,6 +417,7 @@ class AzureAsrService extends GetxService implements AsrService {
case 'sessionStopped':
_isContinuousRecognitionActive = false;
Logger.i('ASR', '[$_latestAsrProvider] sessionStopped');
_eventStreamController?.add(RecognitionEvent(
type: RecognitionEventType.sessionStopped,
provider: _latestAsrProvider,
@ -409,11 +428,7 @@ class AzureAsrService extends GetxService implements AsrService {
_isContinuousRecognitionActive = false;
final String reason = eventMap['reason'] as String? ?? '';
final String errorDetails = eventMap['errorDetails'] as String? ?? '';
if (reason.isNotEmpty || errorDetails.isNotEmpty) {
Logger.error('识别取消: $reason - ${errorDetails.toString()}');
}
Logger.e('ASR', '[$_latestAsrProvider] canceled: $reason - $errorDetails');
_eventStreamController?.add(RecognitionEvent(
type: RecognitionEventType.canceled,
error: '$reason: $errorDetails',
@ -423,7 +438,7 @@ class AzureAsrService extends GetxService implements AsrService {
case 'error':
final String error = eventMap['message'] as String? ?? '';
Logger.error('识别错误: ${error.toString()}');
Logger.e('ASR', '[$_latestAsrProvider] error: $error');
_eventStreamController?.add(RecognitionEvent(
type: RecognitionEventType.error,
error: error,

3
lib/main.dart

@ -36,6 +36,9 @@ void main() async {
await dotenv.load(fileName: ".env");
Logger.i("Main", "环境变量加载成功");
// 初始化文件日志
await Logger.initFileLog();
// 初始化登录服务
Get.put(LoginService());

9
lib/modules/log_export/bindings/log_export_binding.dart

@ -0,0 +1,9 @@
import 'package:get/get.dart';
import '../controllers/log_export_controller.dart';
class LogExportBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<LogExportController>(() => LogExportController());
}
}

130
lib/modules/log_export/controllers/log_export_controller.dart

@ -0,0 +1,130 @@
import 'dart:io';
import 'package:get/get.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/utils/logger.dart';
class LogExportController extends GetxController {
final RxList<LogFileInfo> logFiles = <LogFileInfo>[].obs;
final RxBool isLoading = false.obs;
final RxString totalSize = ''.obs;
@override
void onInit() {
super.onInit();
loadLogFiles();
}
/// 加载日志文件列表
Future<void> loadLogFiles() async {
isLoading.value = true;
try {
final files = await Logger.getLogFiles();
int total = 0;
logFiles.clear();
for (final file in files) {
final stat = await file.stat();
total += stat.size;
logFiles.add(LogFileInfo(
file: file,
name: file.path.split('/').last,
size: _formatSize(stat.size),
modifiedTime: stat.modified,
));
}
totalSize.value = _formatSize(total);
} catch (e) {
Logger.e('LogExport', '加载日志文件失败: $e');
} finally {
isLoading.value = false;
}
}
/// 导出所有日志
Future<void> exportAllLogs() async {
if (logFiles.isEmpty) {
Get.snackbar('提示', '暂无日志文件');
return;
}
try {
await Logger.flush();
final xFiles = logFiles
.map((logFile) => XFile(logFile.file.path))
.toList();
await SharePlus.instance.share(ShareParams(
files: xFiles,
text: '应用运行日志',
));
} catch (e) {
Logger.e('LogExport', '导出日志失败: $e');
Get.snackbar('错误', '导出日志失败: $e');
}
}
/// 分享单个日志文件
Future<void> shareSingleLog(LogFileInfo logFile) async {
try {
await Logger.flush();
await SharePlus.instance.share(ShareParams(
files: [XFile(logFile.file.path)],
text: '应用日志: ${logFile.name}',
));
} catch (e) {
Logger.e('LogExport', '分享日志失败: $e');
Get.snackbar('错误', '分享日志失败: $e');
}
}
/// 查看单个日志文件内容(最后200行)
Future<String> readLogContent(LogFileInfo logFile) async {
try {
await Logger.flush();
final lines = await logFile.file.readAsLines();
final start = lines.length > 200 ? lines.length - 200 : 0;
return lines.sublist(start).join('\n');
} catch (e) {
return '读取日志失败: $e';
}
}
/// 清除所有日志
Future<void> clearAllLogs() async {
try {
for (final logFile in logFiles) {
if (await logFile.file.exists()) {
await logFile.file.delete();
}
}
logFiles.clear();
totalSize.value = '0 B';
Get.snackbar('提示', '日志已清除');
} catch (e) {
Logger.e('LogExport', '清除日志失败: $e');
Get.snackbar('错误', '清除日志失败: $e');
}
}
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
class LogFileInfo {
final File file;
final String name;
final String size;
final DateTime modifiedTime;
LogFileInfo({
required this.file,
required this.name,
required this.size,
required this.modifiedTime,
});
}

271
lib/modules/log_export/views/log_export_view.dart

@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../controllers/log_export_controller.dart';
class LogExportView extends GetView<LogExportController> {
const LogExportView({super.key});
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? const Color(0xFF1A1A2E) : Colors.grey[50],
appBar: AppBar(
title: Text(
'运行日志',
style: TextStyle(fontSize: 18.sp),
),
backgroundColor: isDarkMode ? const Color(0xFF1A1A2E) : Colors.white,
actions: [
Obx(() => controller.logFiles.isNotEmpty
? IconButton(
icon: Icon(Icons.delete_outline, size: 22.sp),
onPressed: () => _showClearConfirm(context, isDarkMode),
)
: const SizedBox.shrink()),
],
),
body: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.logFiles.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.article_outlined,
size: 64.sp,
color: isDarkMode ? Colors.white30 : Colors.grey[300]),
SizedBox(height: 16.h),
Text(
'暂无日志文件',
style: TextStyle(
fontSize: 16.sp,
color: isDarkMode ? Colors.white54 : Colors.grey,
),
),
],
),
);
}
return Column(
children: [
// 顶部信息和导出按钮
Container(
margin: EdgeInsets.all(16.w),
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[850] : Colors.white,
borderRadius: BorderRadius.circular(12.r),
),
child: Row(
children: [
Icon(Icons.folder_outlined,
color: isDarkMode ? Colors.white70 : Colors.grey[600],
size: 24.sp),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${controller.logFiles.length} 个日志文件',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
SizedBox(height: 2.h),
Obx(() => Text(
'总大小: ${controller.totalSize.value}',
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode
? Colors.white54
: Colors.grey[600],
),
)),
],
),
),
ElevatedButton.icon(
onPressed: controller.exportAllLogs,
icon: Icon(Icons.ios_share, size: 18.sp),
label: Text('导出全部', style: TextStyle(fontSize: 13.sp)),
style: ElevatedButton.styleFrom(
backgroundColor:
isDarkMode ? Colors.blue[700] : Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
padding: EdgeInsets.symmetric(
horizontal: 16.w, vertical: 10.h),
),
),
],
),
),
// 日志文件列表
Expanded(
child: ListView.separated(
padding: EdgeInsets.symmetric(horizontal: 16.w),
itemCount: controller.logFiles.length,
separatorBuilder: (_, __) => SizedBox(height: 8.h),
itemBuilder: (context, index) {
final logFile = controller.logFiles[index];
return _buildLogFileItem(
context, logFile, isDarkMode);
},
),
),
],
);
}),
);
}
Widget _buildLogFileItem(
BuildContext context, LogFileInfo logFile, bool isDarkMode) {
final timeStr =
DateFormat('yyyy-MM-dd HH:mm').format(logFile.modifiedTime);
return Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[850] : Colors.white,
borderRadius: BorderRadius.circular(10.r),
),
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 4.h),
leading: Icon(
Icons.description_outlined,
color: isDarkMode ? Colors.amber[300] : Colors.amber[700],
size: 28.sp,
),
title: Text(
logFile.name,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
subtitle: Text(
'$timeStr | ${logFile.size}',
style: TextStyle(
fontSize: 11.sp,
color: isDarkMode ? Colors.white54 : Colors.grey[600],
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.visibility_outlined,
size: 20.sp,
color: isDarkMode ? Colors.white54 : Colors.grey[600]),
onPressed: () => _showLogContent(context, logFile, isDarkMode),
),
IconButton(
icon: Icon(Icons.ios_share,
size: 20.sp,
color: isDarkMode ? Colors.blue[300] : Colors.blue),
onPressed: () => controller.shareSingleLog(logFile),
),
],
),
),
);
}
void _showLogContent(
BuildContext context, LogFileInfo logFile, bool isDarkMode) async {
final content = await controller.readLogContent(logFile);
Get.bottomSheet(
Container(
height: Get.height * 0.75,
decoration: BoxDecoration(
color: isDarkMode ? const Color(0xFF2A2A3E) : Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
),
child: Column(
children: [
// 顶部栏
Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isDarkMode
? Colors.white.withValues(alpha: 0.1)
: Colors.grey[200]!,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
logFile.name,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
IconButton(
icon: Icon(Icons.close,
size: 22.sp,
color: isDarkMode ? Colors.white54 : Colors.grey[600]),
onPressed: () => Get.back(),
),
],
),
),
// 日志内容
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.all(12.w),
reverse: true,
child: SelectableText(
content,
style: TextStyle(
fontSize: 10.sp,
fontFamily: 'monospace',
color: isDarkMode ? Colors.white70 : Colors.black87,
height: 1.5,
),
),
),
),
],
),
),
isScrollControlled: true,
);
}
void _showClearConfirm(BuildContext context, bool isDarkMode) {
Get.defaultDialog(
title: '确认清除',
middleText: '确定要清除所有日志文件吗?此操作不可恢复。',
textConfirm: '清除',
textCancel: '取消',
confirmTextColor: Colors.white,
buttonColor: Colors.red,
onConfirm: () {
Get.back();
controller.clearAllLogs();
},
);
}
}

16
lib/modules/settings/controllers/settings_controller.dart

@ -15,6 +15,7 @@ import '../../../routes/app_routes.dart';
import '../models/language_model.dart';
import '../models/app_supported_languages.dart';
import '../../../data/services/language_manager.dart';
import '../../../data/models/appconfig.dart';
import '../../../data/services/version_update_service.dart';
import '../../common/widgets/version_update_dialog.dart';
@ -392,7 +393,7 @@ class SettingsController extends GetxController {
String getSomeAppInfo() {
try {
final versionService = Get.find<VersionUpdateService>();
return '''
var info = '''
应用版本: ${versionService.cachedAppVersion}
构建号: ${versionService.cachedBuildNumber}
平台: ${versionService.cachedPlatform}
@ -408,6 +409,19 @@ class SettingsController extends GetxController {
语言编码: ${versionService.cachedLanguageCode}
服务器地址: ${DioManager().dio.options.baseUrl}
''';
// 追加服务端下发的环境配置信息
if (AppConfig.isInitialized()) {
final env = AppConfig.config.env;
if (env.isNotEmpty) {
info += '\n--- 服务端环境配置 ---\n';
for (final entry in env.entries) {
info += '${entry.key}: ${entry.value}\n';
}
}
}
return info;
} catch (e) {
return '获取版本服务数据失败: $e';
}

20
lib/modules/settings/views/settings_view.dart

@ -574,6 +574,26 @@ class SettingsView extends GetView<SettingsController> {
)
: const SizedBox.shrink()),
Obx(() => controller.showDebugInfo.value
? _buildSimpleNavigationSetting(
title: '日志导出',
subtitle: '查看和导出应用运行日志',
icon: Icons.article_outlined,
iconBgColor: isDarkMode
? Colors.orange[900]!.withValues(alpha: 0.3)
: Colors.orange[100]!,
iconColor: isDarkMode
? Colors.orange[300]!
: Colors.orange[600]!,
titleColor: isDarkMode ? Colors.white : null,
subtitleColor: isDarkMode ? Colors.white70 : null,
onTap: () {
Get.toNamed(Routes.logExport);
},
isDarkMode: isDarkMode,
)
: const SizedBox.shrink()),
// Divider(
// height: 1,
// color: isDarkMode

9
lib/routes/app_pages.dart

@ -25,6 +25,8 @@ import '../modules/meeting/views/meeting_record_view.dart';
import '../modules/meeting/views/meeting_view.dart';
import '../modules/opus_test/bindings/opus_test_binding.dart';
import '../modules/opus_test/views/opus_test_view.dart';
import '../modules/log_export/bindings/log_export_binding.dart';
import '../modules/log_export/views/log_export_view.dart';
import '../modules/profile/views/profile_view.dart';
import '../modules/profile/views/profile_meeting_view.dart';
import '../modules/profile/bindings/profile_binding.dart';
@ -284,6 +286,13 @@ abstract class AppPages {
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.logExport,
page: () => const LogExportView(),
binding: LogExportBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.agent,
page: () => const AgentView(),

1
lib/routes/app_routes.dart

@ -46,6 +46,7 @@ abstract class Routes {
static const flutterAsrTest = '/flutter_asr_test'; // Flutter ASR测试
static const jieliTest = '/jieli_test'; // 杰理芯片测试
static const opusTest = '/opus_test'; // Opus编码测试
static const logExport = '/log_export'; // 日志导出
static const bleTest = '/ble_test'; // 蓝牙测试
static const speechTest = '/speech_test'; // 综合语音测试

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

@ -64,7 +64,38 @@ interface AgentServiceListener {
*/
object AgentService : CoroutineScope {
private val TAG = "AgentService"
/// 日志回调:(level, message) -> Unit,由 Plugin 层设置,用于将原生日志回传 Dart
var logCallback: ((String, String) -> Unit)? = null
private const val LOG_CHUNK_SIZE = 800
private fun _log(level: String, message: String) {
// 分段输出,避免超长日志被系统截断
if (message.length <= LOG_CHUNK_SIZE) {
_logSingle(level, message)
} else {
val totalParts = (message.length + LOG_CHUNK_SIZE - 1) / LOG_CHUNK_SIZE
for (i in 0 until totalParts) {
val start = i * LOG_CHUNK_SIZE
val end = minOf(start + LOG_CHUNK_SIZE, message.length)
val chunk = message.substring(start, end)
val partMsg = if (totalParts > 1) "[${ i + 1}/$totalParts] $chunk" else chunk
_logSingle(level, partMsg)
}
}
}
private fun _logSingle(level: String, message: String) {
when (level) {
"DEBUG" -> Log.d(TAG, message)
"INFO" -> Log.i(TAG, message)
"WARNING" -> Log.w(TAG, message)
"ERROR" -> Log.e(TAG, message)
}
logCallback?.invoke(level, message)
}
// SharedPreferences 相关常量
private const val PREF_NAME = "agent_service_prefs"
private const val KEY_IS_INTERRUPT = "is_interrupt"
@ -220,7 +251,7 @@ object AgentService : CoroutineScope {
callingToolText = config["callingToolText"]?.toString() ?: "";
insufficientIntegralText = config["insufficientIntegralText"]?.toString() ?: "";
vipExptime = config["vipexptime"]?.toString()?.toIntOrNull() ?: 0;
Log.d(TAG, "iwei------ vipexptime ${vipExptime}")
_log("INFO", "[Agent] vipExptime=$vipExptime")
// 初始化上下文和服务
this.context = appContext.applicationContext
@ -278,7 +309,7 @@ object AgentService : CoroutineScope {
recognizeCallback()
}
} else {
Log.w(TAG, "Azure ASR初始化失败")
_log("WARNING", "[Agent] Azure ASR初始化失败")
}
}
}
@ -297,7 +328,7 @@ object AgentService : CoroutineScope {
config["musicServiceType"]?.toString() ?: "",
)
Log.e(TAG, "liwei -----------读取服务地址和用户token: $serverurl $usertoken")
_log("INFO", "[Agent] 读取服务配置: serverurl=$serverurl, tokenEmpty=${usertoken.isEmpty()}")
// 加载最近的聊天记录
loadChatHistory()
@ -305,7 +336,7 @@ object AgentService : CoroutineScope {
_isInitialized.set(true)
return true
} catch (e: Exception) {
Log.e(TAG, "初始化失败: ${e.message}")
_log("ERROR", "[Agent] 初始化失败: ${e.message}")
return false
}
}
@ -331,7 +362,7 @@ object AgentService : CoroutineScope {
config["musicServiceType"]?.toString() ?: ""
)
} catch (e: Exception) {
Log.e(TAG, "应用配置更新失败 config: $config Exception:${e.message}")
_log("ERROR", "[Agent] 应用配置更新失败: ${e.message}")
}
}
@ -366,7 +397,7 @@ object AgentService : CoroutineScope {
clearListeners()
_isInitialized.set(false)
} catch (e: Exception) {
Log.e(TAG, "释放资源异常: ${e.message}")
_log("ERROR", "[Agent] 释放资源异常: ${e.message}")
}
}
@ -402,7 +433,7 @@ object AgentService : CoroutineScope {
)
if (!success) {
Log.e(TAG, "Bytedance TTS服务初始化失败")
_log("ERROR", "[Agent] TTS服务初始化失败")
} else {
applyTtsSpeechParams()
val voiceName = getTtsVoiceNameByAsrCode(ttsLanguage)
@ -503,7 +534,7 @@ object AgentService : CoroutineScope {
}
})
} catch (e: Exception) {
Log.e(TAG, "初始化TTS引擎失败: ${e.message}")
_log("ERROR", "[Agent] 初始化TTS引擎失败: ${e.message}")
}
}
@ -649,10 +680,10 @@ object AgentService : CoroutineScope {
BleService.openEncoder()//不发指令。只是录制opus
}
if (!isInitialized) {
Log.d(TAG, "服务未初始化")
_log("WARNING", "[Agent] ASR服务未初始化")
return false
}
Log.d("TAG", "开始语音识别startRecognition?")
_log("INFO", "[Agent] 开始语音识别")
// 检查录音权限
if (!isExternalActive && !checkRecordAudioPermission()) {
@ -682,11 +713,11 @@ object AgentService : CoroutineScope {
startIdleCheckForMode(mode)
// 设置当前识别模式
currentRecognitionMode = mode
Log.d(TAG, "设置语音识别模式: $mode")
_log("INFO", "[Agent] 设置语音识别模式: $mode")
return true
} catch (e: Exception) {
_isRecognitionActive.set(false)
Log.e(TAG, "启动语音识别失败: ${e.message}")
_log("ERROR", "[Agent] 启动语音识别失败: ${e.message}")
sendEvent(
"error", mapOf(
"code" to "RECOGNITION_START_ERROR",
@ -753,7 +784,7 @@ object AgentService : CoroutineScope {
}
override fun onResult(sessionid:String,result: String, detectedLanguage: String) {
Log.d(TAG, "liwei-------- onResult: $sessionid $result $currentRecognitionMode")
_log("INFO", "[Agent] ASR onResult: sessionid=$sessionid, mode=$currentRecognitionMode, text=$result")
if (result.isNotEmpty()&&isKeepResult==true) {//加入是否结束要输出最后的结果判读
// 保存识别结果
if (currsessionAsrId.isEmpty()) {
@ -806,7 +837,7 @@ object AgentService : CoroutineScope {
}
else
{
Log.d(TAG, "liwei-------- onResult: $sessionid $result $currentRecognitionMode 空字符串")
_log("WARNING", "[Agent] ASR onResult 空字符串")
}
}
@ -816,7 +847,7 @@ object AgentService : CoroutineScope {
}
override fun onSessionStarted(sessionid:String) {
Log.d(TAG, "liwei-------- onSessionStarted: $sessionid")
_log("INFO", "[Agent] ASR sessionStarted: $sessionid")
sendEvent("recognition_started", mapOf("sessionid" to sessionid, "status" to "started"))
// 使用模式感知的空闲检测启动方法
recognitionResult = ""
@ -829,7 +860,7 @@ object AgentService : CoroutineScope {
override fun onSessionStopped(sessionid:String) {
Log.d(TAG, "liwei-------- onSessionStopped: $sessionid $currsessionAsrId $recognitionResult $currentRecognitionMode")
_log("INFO", "[Agent] ASR sessionStopped: $sessionid, mode=$currentRecognitionMode")
if (currentRecognitionMode == "push_to_talk") { //按住说话
if (recognitionResult.isEmpty()){
@ -859,7 +890,7 @@ object AgentService : CoroutineScope {
}
override fun onCanceled(sessionid: String,reason: String, errorDetails: String) {
Log.d(TAG, "liwei-------- onCanceled: $sessionid $reason $errorDetails")
_log("WARNING", "[Agent] ASR canceled: $sessionid $reason $errorDetails")
_isRecognitionActive.set(false)
stopIdleCheck()
BleService.closeCodec()
@ -873,7 +904,7 @@ object AgentService : CoroutineScope {
}
override fun onError(sessionid:String, code: Int, error: String) {
Log.d(TAG, "liwei-------- onError: $sessionid $code $error")
_log("ERROR", "[Agent] ASR error: $sessionid code=$code $error")
_isRecognitionActive.set(false)
stopIdleCheck()
BleService.closeCodec()
@ -904,7 +935,7 @@ object AgentService : CoroutineScope {
fun stopRecognition() {
if (!isRecognitionActive) return
Log.d(TAG, "停止语音识别,当前模式: $currentRecognitionMode")
_log("INFO", "[Agent] 停止语音识别,模式: $currentRecognitionMode")
try {
_isRecognitionActive.set(false)
@ -914,7 +945,7 @@ object AgentService : CoroutineScope {
// currentRecognitionMode = "normal"
return
} catch (e: Exception) {
Log.e(TAG, "停止语音识别异常: ${e.message}")
_log("ERROR", "[Agent] 停止语音识别异常: ${e.message}")
_isRecognitionActive.set(false)
stopIdleCheck()
// 重置识别模式
@ -972,7 +1003,7 @@ object AgentService : CoroutineScope {
audioPlayer?.stopAudio()
} catch (e: Exception) {
Log.e(TAG, "停止AI流输出异常", e)
_log("ERROR", "[Agent] 停止AI流异常: ${e.message}")
// 确保状态被重置,即使发生异常
_isAiStreaming.set(false)
currentAiJob = null
@ -986,13 +1017,13 @@ object AgentService : CoroutineScope {
*/
fun processTextInput(sessionid: String,text: String, speakResponse: Boolean = false): Boolean {
if (!isInitialized) {
Log.e(TAG, "服务未初始化")
_log("WARNING", "[Agent] AI服务未初始化")
sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化"))
return false
}
if (text.isEmpty()) {
Log.e(TAG, "文本输入不能为空")
_log("WARNING", "[Agent] 文本输入为空")
sendEvent("error", mapOf("code" to "EMPTY_TEXT", "message" to "文本输入不能为空"))
return false
}
@ -1019,7 +1050,7 @@ object AgentService : CoroutineScope {
text: String,
speakResponse: Boolean = true
) {
Log.d(TAG, "用户问题: $text")
_log("INFO", "[Agent] AI 用户问题: $text")
// 创建用户文本消息并处理
val userMessage = createUserMessage(text)
@ -1075,7 +1106,7 @@ object AgentService : CoroutineScope {
try {
// 设置状态为正在流式输出
_isAiStreaming.set(true)
Log.d(TAG, "processWithChatApiInternal")
_log("INFO", "[Agent] AI 开始处理")
audioPlayer?.playAudio(R.raw.await, true, 0.3f,true)
// 使用历史记录作为上下文发送到OpenAI
val responseBuilder = StringBuilder()
@ -1165,7 +1196,7 @@ object AgentService : CoroutineScope {
}
// sendStatisticsEvent(actions)
}catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onUsage 异常", e)
_log("ERROR", "[Agent] AI onUsage异常: ${e.message}")
}
}
@ -1184,7 +1215,7 @@ object AgentService : CoroutineScope {
// audioPlayer?.stopAudio()
}
} catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onToken 异常", e)
_log("ERROR", "[Agent] AI onToken异常: ${e.message}")
}
}
@ -1210,7 +1241,7 @@ object AgentService : CoroutineScope {
if (reply) {
sendEvent("assistant_response", responseData)
}
Log.d(TAG, "liwei--------------- AI Call onComplete:$response")
_log("INFO", "[Agent] AI onComplete: $response")
if (response.isNotEmpty()) {
// 添加AI回复到历史记录
addToHistoryMessages(createAssistantMessage(response))
@ -1227,7 +1258,7 @@ object AgentService : CoroutineScope {
_isAiStreaming.set(false)
currentAiJob = null
} catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onComplete 异常", e)
_log("ERROR", "[Agent] AI onComplete异常: ${e.message}")
}
}
@ -1235,7 +1266,7 @@ object AgentService : CoroutineScope {
try {
if (sessionid == currsessionId) {
audioPlayer?.stopAudio()
Log.e(TAG, "AI处理出错 $message")
_log("ERROR", "[Agent] AI error: code=$code $message")
// 优先使用 ChatApiException.code;否则根据底层异常类型推断错误码
sendEvent(
"error", mapOf(
@ -1253,7 +1284,7 @@ object AgentService : CoroutineScope {
currentAiJob = null
}
} catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onError 异常", e)
_log("ERROR", "[Agent] AI onError异常: ${e.message}")
}
}
@ -1275,13 +1306,13 @@ object AgentService : CoroutineScope {
}else if (name == "qqmusic_pause" || name == "qqmusic_stop" || name == "music_pause" || name == "music_close"){
isplaymusic = false
}
Log.d(TAG, "liwei--------------- AI Call onFunctionCall iscallingTool:$iscallingTool callingToolText:$callingToolText")
_log("INFO", "[Agent] AI onFunctionCall: callingTool=$iscallingTool")
if (iscallingTool) {
ttsService?.speakStream(callingToolText)
iscallingTool = false
}
} catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onFunctionCall 异常", e)
_log("ERROR", "[Agent] AI onFunctionCall异常: ${e.message}")
}
}
@ -1291,9 +1322,7 @@ object AgentService : CoroutineScope {
functionCallResult: JSONObject
) {
try {
Log.d(
TAG,
"liwei--------------- AI Call onFunctionCallResult ${sessionid}:${functionCallResult.toString()}",)
_log("INFO", "[Agent] AI onFunctionCallResult")
audioPlayer?.stopAudio()
val name = functionCall.get("name") as String;
val resultmap = autoHandleFcunCallResult(name, functionCallResult);
@ -1322,11 +1351,7 @@ object AgentService : CoroutineScope {
)
}
} catch (e: Exception) {
Log.e(
TAG,
"liwei--------------- AI Call onFunctionCallResult 异常",
e
)
_log("ERROR", "[Agent] AI onFunctionCallResult异常: ${e.message}")
}
}
}
@ -1334,7 +1359,7 @@ object AgentService : CoroutineScope {
} catch (e: Exception) {
val errorType = if (hasImage) "AI_IMAGE_PROCESS_ERROR" else "AI_PROCESS_ERROR"
Log.e(TAG, "liwei--------------- AI处理出错", e)
_log("ERROR", "[Agent] AI处理异常: ${e.message}")
sendEvent(
"error", mapOf(
"sessionid" to sessionid,
@ -1380,7 +1405,7 @@ object AgentService : CoroutineScope {
}
}
} catch (e: Exception) {
Log.e(TAG, "加载聊天历史失败: ${e.message}")
_log("ERROR", "[Agent] 加载聊天历史失败: ${e.message}")
}
}
@ -1434,7 +1459,7 @@ object AgentService : CoroutineScope {
*/
fun setSupportedLanguages(languages: List<String>): Boolean {
if (!isInitialized) {
Log.e(TAG, "服务未初始化")
_log("WARNING", "[Agent] 语言设置-服务未初始化")
sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化"))
return false
}
@ -1446,7 +1471,7 @@ object AgentService : CoroutineScope {
}
language = languages[0]
return try {
Log.i(TAG, "设置支持的语言: ${languages.joinToString(", ")}")
_log("INFO", "[Agent] 设置语言: ${languages.joinToString(", ")}")
val asrSuccess = azureAsrHelper?.initialize(
subscriptionKey = this.azureSpeechKey,
region = this.azureSpeechRegion,
@ -1459,7 +1484,7 @@ object AgentService : CoroutineScope {
if (asrSuccess) {
recognizeCallback()
} else {
Log.w(TAG, "Azure ASR初始化失败")
_log("WARNING", "[Agent] 语言更新-ASR初始化失败")
}
// 同时更新TTS语言设置
val ttsSuccess = updateTtsLanguage(languages.first()) // 使用第一个语言作为TTS语言
@ -1467,7 +1492,7 @@ object AgentService : CoroutineScope {
val overallSuccess = asrSuccess && ttsSuccess
if (overallSuccess) {
Log.i(TAG, "ASR和TTS语言设置更新成功")
_log("INFO", "[Agent] ASR和TTS语言设置更新成功")
sendEvent("languages_updated", mapOf(
"languages" to languages,
"status" to "success",
@ -1475,7 +1500,7 @@ object AgentService : CoroutineScope {
"tts_success" to ttsSuccess
))
} else {
Log.e(TAG, "语言设置更新失败 - ASR: $asrSuccess, TTS: $ttsSuccess")
_log("ERROR", "[Agent] 语言设置更新失败 ASR:$asrSuccess TTS:$ttsSuccess")
sendEvent("error", mapOf(
"code" to "LANGUAGE_UPDATE_FAILED",
"message" to "语言设置更新失败 - ASR: $asrSuccess, TTS: $ttsSuccess"
@ -1484,7 +1509,7 @@ object AgentService : CoroutineScope {
overallSuccess
} catch (e: Exception) {
Log.e(TAG, "设置支持语言异常: ${e.message}")
_log("ERROR", "[Agent] 设置语言异常: ${e.message}")
sendEvent("error", mapOf(
"code" to "LANGUAGE_UPDATE_ERROR",
"message" to e.message.toString()
@ -1502,7 +1527,7 @@ object AgentService : CoroutineScope {
*/
private fun updateTtsLanguage(languageCode: String): Boolean {
return try {
Log.i(TAG, "更新TTS语言设置: $languageCode")
_log("INFO", "[Agent] 更新TTS语言: $languageCode")
// 根据ASR语言代码映射到对应的TTS语音角色
val voiceName = getTtsVoiceNameByAsrCode(languageCode)
@ -1512,9 +1537,9 @@ object AgentService : CoroutineScope {
val success = ttsService?.setVoice(voiceName) ?: false
if (success) {
Log.i(TAG, "TTS语音角色设置成功: $voiceName")
_log("INFO", "[Agent] TTS语音角色设置成功: $voiceName")
} else {
Log.w(TAG, "TTS语音角色设置失败: $voiceName")
_log("WARNING", "[Agent] TTS语音角色设置失败: $voiceName")
}
success
@ -1649,10 +1674,10 @@ object AgentService : CoroutineScope {
// Log.e(TAG, "保存助手消息失败")
// }
} else {
Log.e(TAG, "保存用户消息失败")
_log("ERROR", "[Agent] 保存用户消息失败")
}
} catch (e: Exception) {
Log.e(TAG, "保存聊天记录异常", e)
_log("ERROR", "[Agent] 保存聊天记录异常: ${e.message}")
}
}
}
@ -1672,7 +1697,7 @@ object AgentService : CoroutineScope {
}
}
} else {
Log.e(TAG, "清除聊天历史失败")
_log("ERROR", "[Agent] 清除聊天历史失败")
}
// 由于launch已在Main上下文中,可以直接调用回调
@ -1702,7 +1727,7 @@ object AgentService : CoroutineScope {
try {
listener.onEvent(eventName, finalData)
} catch (e: Exception) {
Log.e(TAG, "向监听器发送事件失败", e)
_log("ERROR", "[Agent] 发送事件失败: ${e.message}")
}
}
}
@ -2675,18 +2700,18 @@ object AgentService : CoroutineScope {
fun sendStatisticsEvent(actions: Map<String, Int>) {
// 参数验证
if (actions.isEmpty()) {
Log.w(TAG, "统计事件数据为空,跳过发送")
_log("WARNING", "[Agent] 统计事件数据为空,跳过发送")
return
}
// 检查服务器配置
if (serverurl.isEmpty()) {
Log.w(TAG, "服务器地址未配置,跳过统计事件发送")
_log("WARNING", "[Agent] 服务器地址未配置,跳过统计事件发送")
return
}
if (usertoken.isEmpty()) {
Log.w(TAG, "用户令牌未配置,跳过统计事件发送")
_log("WARNING", "[Agent] 用户令牌未配置,跳过统计事件发送")
return
}
@ -2712,7 +2737,7 @@ object AgentService : CoroutineScope {
jsonObject.put("usages", actionsObject)
val json = jsonObject.toString()
Log.d(TAG, "统计事件发送成功,请求: $json")
_log("INFO", "[Agent] 统计请求发送")
val mediaType = "application/json".toMediaType()
val body = json.toRequestBody(mediaType)
@ -2736,7 +2761,7 @@ object AgentService : CoroutineScope {
// 仅读取一次响应体,避免重复读取导致异常,同时统一后续处理使用
val bodyStr = response.body?.string() ?: ""
if (response.isSuccessful) {
Log.d(TAG, "统计事件发送成功,响应: $bodyStr")
_log("INFO", "[Agent] 统计响应: $bodyStr")
try {
val json = JSONObject(bodyStr)
val code = json.optInt("code", -1)
@ -2770,7 +2795,7 @@ object AgentService : CoroutineScope {
Log.w(TAG, "解析统计响应失败: ${e.message}")
}
} else {
Log.w(TAG, "统计事件发送失败,HTTP状态码: ${response.code}")
_log("WARNING", "[Agent] 统计HTTP失败: ${response.code}")
}
}
}
@ -2778,7 +2803,7 @@ object AgentService : CoroutineScope {
} catch (e: Exception) {
// 捕获所有异常,防止崩溃
Log.e(TAG, "发送统计事件时发生异常: ${e.message}", e)
_log("ERROR", "[Agent] 统计异常: ${e.message}")
}
}
}

14
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt

@ -40,6 +40,20 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
// 添加监听器
AgentService.addListener(this)
// 设置原生日志回调,通过 EventChannel 回传 Dart
AgentService.logCallback = { level, message ->
val eventData = JSONObject().apply {
put("event", "nativeLog")
put("data", JSONObject().apply {
put("level", level)
put("message", message)
})
}
MainScope().launch {
eventSink?.success(eventData.toString())
}
}
Log.d(TAG, "插件已加载")
}

187
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift

@ -24,7 +24,42 @@ extension Notification.Name {
}
class AgentServiceImpl: NSObject {
private let TAG = "AgentServiceImpl"
/// 日志回调:(level, message) -> Void,由 Plugin 层设置,用于将原生日志回传 Dart
var logCallback: ((String, String) -> Void)?
private let osLog = OSLog(subsystem: "com.agent.service", category: "Agent")
private let logChunkSize = 800
internal func _log(_ level: String, _ message: String) {
if message.count <= logChunkSize {
_logSingle(level, message)
} else {
let totalParts = (message.count + logChunkSize - 1) / logChunkSize
for i in 0..<totalParts {
let start = message.index(message.startIndex, offsetBy: i * logChunkSize)
let end = message.index(start, offsetBy: min(logChunkSize, message.distance(from: start, to: message.endIndex)))
let chunk = String(message[start..<end])
_logSingle(level, "[\(i + 1)/\(totalParts)] \(chunk)")
}
}
}
private func _logSingle(_ level: String, _ message: String) {
switch level {
case "ERROR":
os_log("%{public}@", log: osLog, type: .error, message)
case "WARNING":
os_log("%{public}@", log: osLog, type: .default, message)
case "DEBUG":
os_log("%{public}@", log: osLog, type: .debug, message)
default:
os_log("%{public}@", log: osLog, type: .info, message)
}
logCallback?(level, message)
}
static let shared = AgentServiceImpl()
// UserDefaults 相关常量
@ -208,7 +243,7 @@ class AgentServiceImpl: NSObject {
self.vipexptime = vipexptime
}
//打印日志输出vipexptime
os_log("Vip 过期时间 %d", log: logger, type: .info, vipexptime)
_log("INFO", "[Agent] vipexptime=\(vipexptime)")
if let insufficientIntegralText = config["insufficientIntegralText"] as? String {
self.insufficientIntegralText = insufficientIntegralText
@ -361,17 +396,15 @@ class AgentServiceImpl: NSObject {
}
private func initializeChatApiService() -> Bool {
os_log("开始初始化ChatAPI服务", log: logger, type: .info)
_log("INFO", "[Agent] ChatAPI service init start")
guard let chatApiService = chatApiService else {
os_log("ChatAPI服务未创建", log: logger, type: .error)
_log("ERROR", "[Agent] ChatAPI service not created")
sendError("ChatAPI服务未创建", code: "CHATAPI_INIT_ERROR")
return false
}
os_log("ChatAPI初始化参数: apiKey=%{public}@, baseUrl=%{public}@, model=%{public}@, mcpServer=%{public}@",
log: logger, type: .info,
apiKey.isEmpty ? "空" : "已设置", baseUrl, model, mcpServer)
_log("INFO", "[Agent] ChatAPI init params: apiKey=\(apiKey.isEmpty ? "empty" : "set"), baseUrl=\(baseUrl), model=\(model)")
let success = chatApiService.initialize(
apiKey: apiKey,
@ -382,7 +415,7 @@ class AgentServiceImpl: NSObject {
musicServiceType:musicServiceType
)
os_log("ChatAPI服务初始化结果: %{public}@", log: logger, type: .info, success ? "成功" : "失败")
_log("INFO", "[Agent] ChatAPI service init result: \(success ? "success" : "failed")")
if !success {
sendError("初始化ChatAPI服务失败", code: "CHATAPI_INIT_ERROR")
@ -392,7 +425,7 @@ class AgentServiceImpl: NSObject {
}
private func checkAdnSetAudioSession() {
os_log("liwei------ 检查并设置音频会话", log: logger, type: .info)
_log("INFO", "[Agent] checkAndSetAudioSession")
let audioSourceType: AzureAsrHelper.AudioSourceType = BleService.shared.isConnected() ? .external : .microphone
azureAsrHelper?.performAudioStart(mod: currentRecognitionMode, audioSourceType: audioSourceType, audioDataCallback: nil)
}
@ -405,7 +438,7 @@ class AgentServiceImpl: NSObject {
switch mode {
case "phone_call":
// 通话模式:禁用空闲检测,保持持续激活
os_log("通话模式:禁用空闲检测", log: logger, type: .info)
_log("INFO", "[Agent] phone_call mode: idle check disabled")
return
case "ble_wakeup":
if !isSpeaking {
@ -430,7 +463,7 @@ class AgentServiceImpl: NSObject {
azureAsrHelper?.disableBluetoothAudio()
}
// 未知模式:使用默认的空闲检测
os_log("未知识别模式: %{public}@,使用默认空闲检测", log: logger, type: .error, mode)
_log("ERROR", "[Agent] unknown recognition mode: \(mode), using default idle check")
startIdleCheck(idleSeconds: maxIdleSeconds)
}
}
@ -442,21 +475,14 @@ class AgentServiceImpl: NSObject {
self.idleTimer = nil
}
guard self.isRecognizing else { return }
os_log("liwei--------启动空闲检测,超时时间: %.0f秒,模式: %{public}@",
log: self.logger,
type: .info,
idleSeconds,
self.currentRecognitionMode)
self._log("INFO", "[Agent] idle check started, timeout: \(Int(idleSeconds))s, mode: \(self.currentRecognitionMode)")
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
timer.schedule(deadline: .now() + idleSeconds)
timer.setEventHandler { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
os_log("liwei--------空闲检测触发自动停止,模式: %{public}@",
log: self.logger,
type: .info,
self.currentRecognitionMode)
self._log("INFO", "[Agent] idle check triggered auto stop, mode: \(self.currentRecognitionMode)")
//打电话模式下面 不能关闭识别器
if self.isRecognizing && !self.isSpeaking && !self.isAiStreaming{
BleService.shared.closeCodec()
@ -496,13 +522,13 @@ class AgentServiceImpl: NSObject {
}
if useBle, mode == "ble_wakeup", currentRecognitionMode == "ble_wakeup", (isRecognizing || isStartingRecognition) {
os_log("liwei-------- ble_wakeup 连续触发,识别中/启动中,忽略重复 startRecognition", log: logger, type: .info)
_log("INFO", "[Agent] ble_wakeup duplicate trigger ignored, recognizing/starting")
audioPlayer?.playStartSound()
return true
}
// 接收到唤醒信号,打开编码器 (设备侧)
print("ai启动语音\(useBle))")
_log("INFO", "[Agent] startRecognition useBle=\(useBle)")
var openResult = false // 添加openResult变量定义
if useBle{
isKeepResult = false
@ -526,7 +552,7 @@ class AgentServiceImpl: NSObject {
stopTts()
os_log("🎙️ 尝试打开编码器结果: %@", log: logger, type: .info, openResult ? "成功" : "失败")
_log("INFO", "[Agent] openEncoder result: \(openResult ? "success" : "failed")")
wasMusicPlayingBeforeRecognition = MusicService.shared.isPlaying
// 如果音乐正在播放,则暂停
@ -542,7 +568,7 @@ class AgentServiceImpl: NSObject {
// 新增:标记为"启动中",用于允许 stop 在启动未完成时也能生效
isStartingRecognition = true
print("ai启动语音=\(audioSourceType)=\(useBle)")
_log("INFO", "[Agent] startRecognition audioSource=\(audioSourceType), useBle=\(useBle)")
guard let success = azureAsrHelper?.startContinuousRecognition(
mod:self.currentRecognitionMode,
audioSourceType: audioSourceType,
@ -551,20 +577,20 @@ class AgentServiceImpl: NSObject {
// 新增:启动失败时复位"启动中"状态
isStartingRecognition = false
sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR")
os_log("liwei--------------- 启动语音识别失败: %{public}@", log: logger, type: .info, mode)
_log("ERROR", "[Agent] startRecognition failed, mode: \(mode)")
return false
}
if(useBle){
isKeepResult = true
}
os_log("liwei--------------- 启动语音识别: %{public}@", log: logger, type: .info, mode)
_log("INFO", "[Agent] startRecognition success, mode: \(mode)")
// 根据模式启动相应的空闲检测(此处 guard isRecognizing,会在 onSessionStarted 中再次启动)
startIdleCheckForMode(mode: mode)
return true
}
func recognizeCallback() -> Bool {
print("重新设置识别回调=")
_log("INFO", "[Agent] recognizeCallback re-setup")
guard let asrsetupSuccess = azureAsrHelper?.setupEventListeners(
callback: self
), asrsetupSuccess else {
@ -579,7 +605,7 @@ class AgentServiceImpl: NSObject {
/// 特性:
/// 1) 即便处于"启动中"(isStartingRecognition)也会打印日志并尝试停止
/// 2) 如果底层尚未完全进入可停止状态,记录一次待停止请求,待会话启动后立即停止
print("停止识别语音,isRecognizing=\(isRecognizing),isStartingRecognition=\(isStartingRecognition)")
_log("INFO", "[Agent] stopRecognition, isRecognizing=\(isRecognizing), isStartingRecognition=\(isStartingRecognition)")
if !(isRecognizing || isStartingRecognition) {
// 既不在识别中,也不在启动中,视为幂等处理
return true
@ -587,13 +613,13 @@ class AgentServiceImpl: NSObject {
// 关键修复:如果正在结束的是通话模式,则立即将会话重置为 normal 状态
if self.currentRecognitionMode == "phone_call" {
os_log("通话模式结束,强制重置音频会话至 'normal' 状态", log: logger, type: .info)
_log("INFO", "[Agent] phone_call mode ended, resetting audio session to normal")
self.currentRecognitionMode = "normal"
// 使用 normal 模式的配置来清理和重置音频会话
self.checkAdnSetAudioSession()
}
os_log("停止语音识别,当前模式: %{public}@", log: logger, type: .info, currentRecognitionMode)
_log("INFO", "[Agent] stopRecognition, currentMode: \(currentRecognitionMode)")
let success = azureAsrHelper?.stopContinuousRecognition() ?? false
if success {
@ -601,9 +627,9 @@ class AgentServiceImpl: NSObject {
// let audioSession = AVAudioSession.sharedInstance()
// try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .allowBluetoothA2DP])
// try audioSession.setActive(true)
os_log("Audio session switched to playback successfully after recognition.", log: logger, type: .info)
_log("INFO", "[Agent] audio session switched to playback after recognition")
} catch {
os_log("Failed to switch audio session to playback: %{public}@", log: logger, type: .error, error.localizedDescription)
_log("ERROR", "[Agent] failed to switch audio session to playback: \(error.localizedDescription)")
}
} else if isStartingRecognition {
// 启动尚未完成,先记录一次待停止请求,onSessionStarted 到来后立即 stop
@ -699,16 +725,16 @@ class AgentServiceImpl: NSObject {
}
func processTextInput(sessionid:String, _ text: String, speakResponse: Bool) -> Bool {
os_log("收到文本输入: text=%{public}@, speakResponse=%{public}@", log: logger, type: .info, text, speakResponse ? "是" : "否")
_log("INFO", "[Agent] processTextInput: text=\(text), speakResponse=\(speakResponse)")
if !isInitialized {
os_log("服务未初始化,无法处理文本输入", log: logger, type: .error)
_log("ERROR", "[Agent] processTextInput: service not initialized")
sendError("服务未初始化", code: "NOT_INITIALIZED")
return false
}
if text.isEmpty {
os_log("文本输入为空", log: logger, type: .error)
_log("ERROR", "[Agent] processTextInput: empty text")
sendError("文本输入不能为空", code: "EMPTY_TEXT")
return false
}
@ -735,17 +761,16 @@ class AgentServiceImpl: NSObject {
}
private func processWithChatApiServiceInternal(sessionid:String,userMessage: [String: Any], displayText: String, speakResponse: Bool = true, hasImage: Bool = false,imagePath:String = "") {
os_log("开始处理ChatAPI请求: displayText=%{public}@, speakResponse=%{public}@, hasImage=%{public}@",
log: logger, type: .info, displayText, speakResponse ? "是" : "否", hasImage ? "是" : "否")
_log("INFO", "[Agent] ChatAPI request start: text=\(displayText), speak=\(speakResponse), hasImage=\(hasImage)")
guard let chatApiService = chatApiService else {
os_log("ChatAPI服务未初始化", log: logger, type: .error)
_log("ERROR", "[Agent] ChatAPI service not initialized")
sendError("ChatAPI服务未初始化", code: "CHATAPI_NOT_INITIALIZED")
return
}
if isAiStreaming {
os_log("中断当前AI流式响应", log: logger, type: .info)
_log("INFO", "[Agent] interrupting current AI stream response")
_ = interruptCurrentResponse()
}
stopTts();
@ -799,11 +824,11 @@ class AgentServiceImpl: NSObject {
imagePath: imagePath
)
azureTtsHelper?.startspeak(sessionid: sessionid)
os_log("设置回调并调用sendMessageStream", log: logger, type: .info)
_log("INFO", "[Agent] sendMessageStream start, sessionId=\(sessionid)")
chatApiService.setStreamCallback(sessionid,callback)
chatApiService.sendMessageStream(sessionid,vipexptime: vipexptime,messages: messages)
audioPlayer?.playAwaitSound()
os_log("sendMessageStream调用完成", log: logger, type: .info)
_log("INFO", "[Agent] sendMessageStream dispatched")
}
func processImageInput(sessionid: String,imagePath: String,imageUrl: String, text: String, speakResponse: Bool) -> Bool {
@ -905,7 +930,7 @@ class AgentServiceImpl: NSObject {
}
}
os_log("已加载%d条历史记录", log: logger, type: .info, recentMessages.count)
_log("INFO", "[Agent] loaded \(recentMessages.count) history messages")
}
/**
@ -926,7 +951,7 @@ class AgentServiceImpl: NSObject {
metadata: metadata.isEmpty ? nil : metadata
)
if assistantMessageId == -1 {
os_log("保存助手消息失败", log: self.logger, type: .error)
self._log("ERROR", "[Agent] save assistant message failed")
}
}
// } else {
@ -1058,7 +1083,7 @@ private func jsonToString(_ json: [String: Any]) -> String? {
let jsonData = try JSONSerialization.data(withJSONObject: json, options: [])
return String(data: jsonData, encoding: .utf8)
} catch {
print("JSON序列化失败: \(error)")
_log("ERROR", "[Agent] JSON serialization failed: \(error)")
return nil
}
}
@ -1495,7 +1520,7 @@ private func jsonToString(_ json: [String: Any]) -> String? {
DispatchQueue.global(qos: .utility).async {
let success = self.chatStorageHelper.deleteMessages(agentId:self.agentId, messageIds: nil as [Int]?)
if !success {
os_log("清除聊天历史失败", log: self.logger, type: .error)
self._log("ERROR", "[Agent] clear chat history failed")
}
}
@ -1514,16 +1539,16 @@ private func jsonToString(_ json: [String: Any]) -> String? {
* @return 是否设置成功
*/
func setSupportedLanguages(_ languages: [String]) -> Bool {
os_log("设置支持的语言: %{public}@", log: logger, type: .info, languages.joined(separator: ", "))
_log("INFO", "[Agent] setSupportedLanguages: \(languages.joined(separator: ", "))")
if !isInitialized {
os_log("服务未初始化,无法设置语言", log: logger, type: .error)
_log("ERROR", "[Agent] setSupportedLanguages: service not initialized")
sendError("服务未初始化", code: "NOT_INITIALIZED")
return false
}
if languages.isEmpty {
os_log("语言列表为空,使用默认语言", log: logger, type: .info)
_log("INFO", "[Agent] setSupportedLanguages: empty list, using default")
return false
}
@ -1563,9 +1588,9 @@ private func jsonToString(_ json: [String: Any]) -> String? {
let overallSuccess = asrSuccess && ttsSuccess
if overallSuccess {
os_log("ASR和TTS语言设置更新成功: %{public}@", log: logger, type: .info, languages.joined(separator: ", "))
_log("INFO", "[Agent] ASR and TTS language update success: \(languages.joined(separator: ", "))")
} else {
os_log("语言设置更新失败 - ASR: %{public}@, TTS: %{public}@", log: logger, type: .error, String(asrSuccess), String(ttsSuccess))
_log("ERROR", "[Agent] language update failed - ASR: \(asrSuccess), TTS: \(ttsSuccess)")
}
// 如果之前正在识别,重新开始
@ -1583,7 +1608,7 @@ private func jsonToString(_ json: [String: Any]) -> String? {
* @return 是否更新成功
*/
private func updateTtsLanguage(_ languageCode: String) -> Bool {
os_log("更新TTS语言设置: %{public}@", log: logger, type: .info, languageCode)
_log("INFO", "[Agent] updateTtsLanguage: \(languageCode)")
// 根据ASR语言代码映射到对应的TTS语音角色
let voiceName = getTtsVoiceNameByAsrCode(languageCode)
@ -1593,14 +1618,14 @@ private func jsonToString(_ json: [String: Any]) -> String? {
let success = azureTtsHelper?.setVoice(voiceName) ?? false
if success {
os_log("TTS语音角色设置成功: %{public}@", log: logger, type: .info, voiceName)
_log("INFO", "[Agent] TTS voice set success: \(voiceName)")
} else {
os_log("TTS语音角色设置失败: %{public}@", log: logger, type: .error, voiceName)
_log("ERROR", "[Agent] TTS voice set failed: \(voiceName)")
}
return success
} else {
os_log("未找到语言代码 %{public}@ 对应的TTS语音角色", log: logger, type: .error, languageCode)
_log("ERROR", "[Agent] TTS voice not found for language: \(languageCode)")
return false
}
}
@ -1731,13 +1756,13 @@ private func jsonToString(_ json: [String: Any]) -> String? {
return "fil-PH-BlessicaNeural"
default:
os_log("未支持的语言代码: %{public}@,使用默认英语语音", log: logger, type: .error, asrCode)
_log("WARNING", "[Agent] unsupported language code: \(asrCode), using default en-US voice")
return "en-US-AriaNeural"
}
}
func dispose() -> Bool {
print("ai释放资源")
_log("INFO", "[Agent] dispose")
if isRecognizing {
// 关闭编码器 (设备侧)
BleService.shared.closeCodec()
@ -1823,7 +1848,7 @@ class ChatApiStreamCallback: StreamCallback {
try agentService.sendEvent(name: "assistant_token", data: ["sessionid":sessionid,"token": token])
}
}catch {
print("liwei--------------- AI Call onToken 异常: \(error)")
agentService?._log("ERROR", "[Agent] AI onToken error: \(error)")
}
}
@ -1835,7 +1860,7 @@ class ChatApiStreamCallback: StreamCallback {
// 停止等待音效
agentService.audioPlayer?.stopAwaitSound()
}
os_log("onComplete agentService.isInterrupt:%{public}@", log: agentService.logger, type: .info, String(agentService.isInterrupt))
agentService._log("INFO", "[Agent] AI onComplete, isInterrupt=\(agentService.isInterrupt)")
if(agentService.isInterrupt){
BleService.shared.closeCodec()
}
@ -1870,9 +1895,9 @@ class ChatApiStreamCallback: StreamCallback {
try agentService.saveChatMessage(sessionId: sessionid,userMessage: displayText, assistantMessage: response, metadata: metadata,userMetadata:toJSONString(userMetadata))
agentService.isAiStreaming = false
iscallingTool = true
os_log("设置AI流式状态为false", log: agentService.logger, type: .info)
agentService._log("INFO", "[Agent] AI stream completed, response=\(response)")
}catch{
print("liwei--------------- AI Call onComplete 异常: \(error)")
agentService?._log("ERROR", "[Agent] AI onComplete error: \(error)")
}
}
@ -1897,15 +1922,9 @@ class ChatApiStreamCallback: StreamCallback {
agentService.azureTtsHelper?.speakStream(sessionid: sessionid,agentService.insufficientIntegralText)
}
agentService.isAiStreaming = false
os_log(
"liwei--------------- AI Call onError code: %{public}d message: %{public}@ ",
log: agentService.logger,
type: .info,
code, // 直接传 Int 类型
message // 假设 message 是 String 类型
)
agentService._log("ERROR", "[Agent] AI onError code=\(code), message=\(message)")
}catch{
print("liwei--------------- AI Call onError 异常: \(error)")
agentService?._log("ERROR", "[Agent] AI onError exception: \(error)")
}
}
@ -1918,8 +1937,7 @@ class ChatApiStreamCallback: StreamCallback {
}
let name = functionCall["name"] as? String ?? ""
let arguments = functionCall["arguments"] as? String ?? ""
os_log("收到函数调用:%{public}@", log: agentService.logger, type: .info, functionCall)
print("onFunctionCall=functionCall\(functionCall)")
agentService._log("INFO", "[Agent] AI onFunctionCall: name=\(name), args=\(arguments)")
if (name == "exit_interaction") {
// 关闭编码器 (设备侧)
@ -1936,7 +1954,7 @@ class ChatApiStreamCallback: StreamCallback {
])
if (iscallingTool) {
os_log("收到函数调用: callingToolText:%{public}@", log: agentService.logger, type: .info, agentService.callingToolText)
agentService._log("INFO", "[Agent] AI onFunctionCall: callingToolText=\(agentService.callingToolText)")
// _ = agentService.azureTtsHelper?.setTtsMode(mod: agentService.currentRecognitionMode)
agentService.azureTtsHelper?.speakStream(sessionid: sessionid,agentService.callingToolText)
iscallingTool = false
@ -1944,7 +1962,7 @@ class ChatApiStreamCallback: StreamCallback {
}catch{
print("liwei--------------- AI Call onFunctionCall 异常: \(error)")
agentService?._log("ERROR", "[Agent] AI onFunctionCall error: \(error)")
}
}
@ -1961,10 +1979,7 @@ class ChatApiStreamCallback: StreamCallback {
broadcast = resultmap["broadcast"] as? Bool ?? true
reply = !(resultmap["notreply"] as? Bool ?? true)
iscard = resultmap["iscard"] as? Bool ?? false
os_log("收到函数调用结果! broadcast: %{public}@ reply: %{public}@ %{iscard}@",
log: agentService.logger,
type: .info,
"\(broadcast)", "\(reply)", "\(iscard)")
agentService._log("INFO", "[Agent] AI onFunctionCallResult: name=\(name), broadcast=\(broadcast), reply=\(reply), iscard=\(iscard)")
if (iscard){
metadata = resultmap["meta"] as? String ?? ""
agentService.sendEvent(name: "function_call_result", data: [
@ -1984,7 +1999,7 @@ class ChatApiStreamCallback: StreamCallback {
}
}catch{
print("liwei--------------- AI Call onFunctionCallResult 异常: \(error)")
agentService?._log("ERROR", "[Agent] AI onFunctionCallResult error: \(error)")
}
}
@ -2504,7 +2519,7 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
let userDefaults = UserDefaults.standard
let cachedValue = userDefaults.bool(forKey: KEY_IS_INTERRUPT) // 默认值为 false
isInterrupt = cachedValue
os_log("从缓存加载 isInterrupt 状态: %{public}@", log: logger, type: .info, String(isInterrupt))
_log("INFO", "[Agent] loaded isInterrupt from cache: \(isInterrupt)")
}
/**
@ -2513,19 +2528,19 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
func saveIsInterruptToCache(_ isInterrupt: Bool) {
let userDefaults = UserDefaults.standard
userDefaults.set(isInterrupt, forKey: KEY_IS_INTERRUPT)
os_log("保存 isInterrupt 状态到缓存: %{public}@", log: logger, type: .info, String(isInterrupt))
_log("INFO", "[Agent] saved isInterrupt to cache: \(isInterrupt)")
}
private func loadTtsRateFromCache() {
let userDefaults = UserDefaults.standard
let cachedValue = userDefaults.object(forKey: KEY_TTS_RATE) as? Int ?? 25
ttsRatePercent = max(-100, min(100, cachedValue))
os_log("从缓存加载 ttsRatePercent: %{public}@", log: logger, type: .info, String(ttsRatePercent))
_log("INFO", "[Agent] loaded ttsRatePercent from cache: \(ttsRatePercent)")
}
private func saveTtsRateToCache(_ rate: Int) {
let userDefaults = UserDefaults.standard
userDefaults.set(rate, forKey: KEY_TTS_RATE)
os_log("保存 ttsRatePercent 到缓存: %{public}@", log: logger, type: .info, String(rate))
_log("INFO", "[Agent] saved ttsRatePercent to cache: \(rate)")
}
}

15
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift

@ -226,6 +226,21 @@ class AgentServiceEventHandler: NSObject, FlutterStreamHandler {
// 添加实现的事件监听器
impl.addListener(self)
// 设置原生日志回调,通过 EventChannel 回传 Dart
impl.logCallback = { [weak self] level, message in
DispatchQueue.main.async {
guard let self = self else { return }
let eventData: [String: Any] = [
"event": "nativeLog",
"data": ["level": level, "message": message]
]
if let jsonData = try? JSONSerialization.data(withJSONObject: eventData),
let jsonString = String(data: jsonData, encoding: .utf8) {
self.eventSink?(jsonString)
}
}
}
return nil
}

19
local_plugins/agent_service/lib/agent_service.dart

@ -110,9 +110,26 @@ class AgentService {
static Stream<AgentServiceEvent>? _eventStream;
/// 原生日志回调,由主 app 设置,用于将原生日志写入文件
static void Function(String level, String message)? nativeLogCallback;
/// 获取事件流
static Stream<AgentServiceEvent> get events {
_eventStream ??= _eventChannel.receiveBroadcastStream().map((event) {
_eventStream ??= _eventChannel.receiveBroadcastStream().where((event) {
// 拦截原生日志事件,不传入业务流
try {
final Map<String, dynamic> eventMap = jsonDecode(event);
if (eventMap['event'] == 'nativeLog') {
final data = eventMap['data'] as Map<String, dynamic>?;
nativeLogCallback?.call(
data?['level'] as String? ?? 'INFO',
data?['message'] as String? ?? '',
);
return false;
}
} catch (_) {}
return true;
}).map((event) {
try {
final Map<String, dynamic> eventMap = jsonDecode(event);
final String eventName = eventMap['event'];

19
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt

@ -44,6 +44,9 @@ class AzureAsrHelper(private val context: Context) {
private var useXunfei = false
private var xunFeiAsrHelper: XunFeiAsrHelper? = null
/// 原生日志回调,由 Plugin 层设置,通过 EventChannel 回传 Dart
var nativeLogCallback: ((String, String) -> Unit)? = null
private var xunfeiAppId: String = ""
private var xunfeiAccessKeyId: String = ""
private var xunfeiAccessKeySecret: String = ""
@ -127,10 +130,9 @@ class AzureAsrHelper(private val context: Context) {
this.supportedLanguages = supportedLanguages
}
Log.i(
tag,
"lxm---ASR initialize: supportedLanguages=${supportedLanguages.joinToString(",")}, audioSourceType=$audioSourceType"
)
val initMsg = "[XunFei_ASR] ASR initialize: supportedLanguages=${supportedLanguages.joinToString(",")}, audioSourceType=$audioSourceType"
Log.i(tag, initMsg)
nativeLogCallback?.invoke("INFO", initMsg)
val xunfeiConfigReady = this.xunfeiAppId.isNotBlank() &&
this.xunfeiAccessKeyId.isNotBlank() &&
@ -154,10 +156,9 @@ class AzureAsrHelper(private val context: Context) {
this.currentLanguage = supportedLanguages[0]
}
Log.i(
tag,
"lxm---ASR initialize derived: isAutoDetectLanguage=$isAutoDetectLanguage, currentLanguage=$currentLanguage, provider=${getAsrProvider()}, xunfeiConfigured=$xunfeiConfigReady"
)
val derivedMsg = "[XunFei_ASR] ASR config: provider=${getAsrProvider()}, xunfeiConfigured=$xunfeiConfigReady, onlyXunfeiLangs=$onlySupportedByXunfei, multiChinese=$hasMultipleChineseVariants, autoDetect=$isAutoDetectLanguage, currentLang=$currentLanguage"
Log.i(tag, derivedMsg)
nativeLogCallback?.invoke("INFO", derivedMsg)
// 创建语音配置
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region).apply {
@ -324,6 +325,8 @@ class AzureAsrHelper(private val context: Context) {
accessKeyId = xunfeiAccessKeyId,
accessKeySecret = xunfeiAccessKeySecret
)
xunFeiAsrHelper?.logCallback = nativeLogCallback
nativeLogCallback?.invoke("INFO", "[XunFei_ASR] Config init (callback ready): appId=${xunfeiAppId.take(6)}***, keyId=${xunfeiAccessKeyId.take(6)}***, secret=${if (xunfeiAccessKeySecret.isNotBlank()) "SET" else "EMPTY"}")
} else {
xunFeiAsrHelper?.updateConfig(
appId = xunfeiAppId,

3
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -253,6 +253,9 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
// 初始化Azure语音服务
azureTtsHelper = AzureTtsHelper(context)
azureAsrHelper = AzureAsrHelper(context)
azureAsrHelper.nativeLogCallback = { level, message ->
sendAsrEvent(mapOf("type" to "nativeLog", "level" to level, "message" to message))
}
azureAstHelper = IntegratedSpeechTranslationService(context)
// 2. 初始化BleService并注册回调
if (BleService.initialize(context)) {

101
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt

@ -20,7 +20,7 @@ import javax.crypto.spec.SecretKeySpec
// 参考文档:https://www.xfyun.cn/doc/spark/asr_llm/rtasr_llm.html
class XunFeiAsrHelper(private val context: Context) {
private val tag = "XunFeiAsrHelper"
private val tag = "XunFei_ASR"
private var appId: String = ""
private var accessKeyId: String = ""
@ -40,6 +40,39 @@ class XunFeiAsrHelper(private val context: Context) {
// 是否第去除一个文字的标点符号
private var removeFirstPunctuation = true
// 音频帧计数,用于日志节流
private var audioFrameCount = 0
private var connectStartTime = 0L
/// 日志回调:(level, message) -> Unit,由 Plugin 层设置,用于将原生日志回传 Dart
var logCallback: ((String, String) -> Unit)? = null
private val LOG_CHUNK_SIZE = 800
private fun _log(level: String, message: String) {
if (message.length <= LOG_CHUNK_SIZE) {
_logSingle(level, message)
} else {
val totalParts = (message.length + LOG_CHUNK_SIZE - 1) / LOG_CHUNK_SIZE
for (i in 0 until totalParts) {
val start = i * LOG_CHUNK_SIZE
val end = minOf(start + LOG_CHUNK_SIZE, message.length)
val chunk = message.substring(start, end)
_logSingle(level, "[${i + 1}/$totalParts] $chunk")
}
}
}
private fun _logSingle(level: String, message: String) {
when (level) {
"DEBUG" -> Log.d(tag, message)
"INFO" -> Log.i(tag, message)
"WARNING" -> Log.w(tag, message)
"ERROR" -> Log.e(tag, message)
}
logCallback?.invoke(level, message)
}
constructor(
context: Context,
appId: String,
@ -53,6 +86,7 @@ class XunFeiAsrHelper(private val context: Context) {
this.appId = appId
this.accessKeyId = accessKeyId
this.accessKeySecret = accessKeySecret
_log("INFO", "[XunFei_ASR] Config updated: appId=${appId.take(6)}***, keyId=${accessKeyId.take(6)}***, secret=${if (accessKeySecret.isNotBlank()) "SET" else "EMPTY"}")
}
private fun isConfigValid(): Boolean {
@ -66,15 +100,18 @@ class XunFeiAsrHelper(private val context: Context) {
isAutoDetect: Boolean,
isRemoveFirstPunctuation: Boolean
) {
_log("INFO", "[XunFei_ASR] start() called: lang=$language, supportedLangs=${supportedLanguages.joinToString(",")}, autoDetect=$isAutoDetect")
try {
webSocket?.cancel()
webSocket = null
} catch (e: Exception) {
Log.e(tag, "Clear previous websocket failed: ${e.message}")
_log("ERROR", "[XunFei_ASR] Clear previous websocket failed: ${e.message}")
}
currentSessionId = UUID.randomUUID().toString()
lastIntermediateResult = ""
audioFrameCount = 0
currentLanguage = language
this.supportedLanguages = supportedLanguages
@ -82,16 +119,21 @@ class XunFeiAsrHelper(private val context: Context) {
removeFirstPunctuation = isRemoveFirstPunctuation
if (!isConfigValid()) {
callback.onError(currentSessionId, 1007, "Xunfei config missing")
_log("ERROR", "[XunFei_ASR] Config invalid: appId=${appId.isNotBlank()}, keyId=${accessKeyId.isNotBlank()}, secret=${accessKeySecret.isNotBlank()}")
callback.onError(currentSessionId, 1007, "Xunfei config missing: appId=${appId.isNotBlank()}, keyId=${accessKeyId.isNotBlank()}, secret=${accessKeySecret.isNotBlank()}")
return
}
val url = getWebSocketUrl()
_log("INFO", "[XunFei_ASR] WebSocket connecting to: ${url.substringBefore("?")}")
connectStartTime = System.currentTimeMillis()
val request = Request.Builder().url(url).build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(tag, "WebSocket Opened")
val elapsed = System.currentTimeMillis() - connectStartTime
_log("INFO", "[XunFei_ASR] WebSocket Opened (${elapsed}ms), responseCode=${response.code}")
callback.onSessionStarted(currentSessionId)
}
@ -100,12 +142,12 @@ class XunFeiAsrHelper(private val context: Context) {
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.d(tag, "WebSocket Closing: $code / $reason")
_log("INFO", "[XunFei_ASR] WebSocket Closing: code=$code, reason=$reason, audioFrames=$audioFrameCount")
webSocket.close(1000, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(tag, "WebSocket Closed: $code / $reason")
_log("INFO", "[XunFei_ASR] WebSocket Closed: code=$code, reason=$reason, audioFrames=$audioFrameCount")
if (currentSessionId.isNotEmpty() && lastIntermediateResult.isNotEmpty()) {
val detectedLanguage = selectDetectedLanguage(lastIntermediateResult)
callback.onResult(currentSessionId, lastIntermediateResult, detectedLanguage)
@ -114,8 +156,12 @@ class XunFeiAsrHelper(private val context: Context) {
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(tag, "WebSocket Error", t)
callback.onError(currentSessionId, 1004, "Xunfei connection failed: ${t.message}")
val elapsed = System.currentTimeMillis() - connectStartTime
val respCode = response?.code ?: -1
val respMsg = response?.message ?: "N/A"
_log("ERROR", "[XunFei_ASR] WebSocket Failure (${elapsed}ms): respCode=$respCode, respMsg=$respMsg, error=${t.javaClass.simpleName}: ${t.message}")
callback.onError(currentSessionId, 1004,
"Xunfei connection failed (${elapsed}ms): respCode=$respCode, respMsg=$respMsg, ${t.javaClass.simpleName}: ${t.message}")
}
})
}
@ -123,10 +169,16 @@ class XunFeiAsrHelper(private val context: Context) {
fun sendAudio(frameBuffer: ByteArray) {
webSocket?.let { ws: WebSocket ->
ws.send(frameBuffer.toByteString(0, frameBuffer.size))
audioFrameCount++
// 每 200 帧打一条日志(约 4 秒 @50fps)
if (audioFrameCount % 200 == 0) {
_log("DEBUG", "[XunFei_ASR] Audio sent: $audioFrameCount frames, ${frameBuffer.size} bytes/frame")
}
}
}
fun stop() {
_log("INFO", "[XunFei_ASR] stop() called: audioFrames=$audioFrameCount, sessionId=$sessionId")
try {
webSocket?.let { ws: WebSocket ->
ws.send("{\"end\": true, \"sessionId\": \"$sessionId\"}")
@ -134,12 +186,12 @@ class XunFeiAsrHelper(private val context: Context) {
try {
ws.close(1000, "User stopped")
} catch (e: Exception) {
Log.e(tag, "Close Xunfei failed: ${e.message}")
_log("ERROR", "[XunFei_ASR] Close Xunfei failed: ${e.message}")
}
}, 1000)
}
} catch (e: Exception) {
Log.e(tag, "Stop Xunfei failed: ${e.message}")
_log("ERROR", "[XunFei_ASR] Stop Xunfei failed: ${e.message}")
} finally {
webSocket = null
}
@ -151,8 +203,8 @@ class XunFeiAsrHelper(private val context: Context) {
) {
try {
val jsonData = JSONObject(resultData)
Log.d(tag, "-------------------$jsonData")
val msgType = jsonData.optString("msg_type")
_log("DEBUG", "[XunFei_ASR] Received msg_type=$msgType")
when (msgType) {
"action" -> {
@ -160,7 +212,7 @@ class XunFeiAsrHelper(private val context: Context) {
if (!sid.isNullOrEmpty()) {
sessionId = sid
}
Log.d(tag, "Handshake success")
_log("INFO", "[XunFei_ASR] Handshake success, serverSessionId=$sid")
}
"result" -> {
@ -220,8 +272,10 @@ class XunFeiAsrHelper(private val context: Context) {
}
val detectedLanguage = selectDetectedLanguage(resultTextTemp)
val type = st.optInt("type")
_log("DEBUG", "[XunFei_ASR] Result: type=$type, ls=$ls, words=$wordCount, text=$resultTextTemp")
if (st.optInt("type") == 0) {
if (type == 0) {
callback.onResult(currentSessionId, resultTextTemp, detectedLanguage)
currentSessionId = UUID.randomUUID().toString()
lastIntermediateResult = ""
@ -232,17 +286,18 @@ class XunFeiAsrHelper(private val context: Context) {
}
"error" -> {
callback.onError(currentSessionId, 1005, "Xunfei error: $resultData")
Log.e(tag, "Error: $resultData")
_log("ERROR", "[XunFei_ASR] Server error: $resultData")
callback.onError(currentSessionId, 1005, "Xunfei server error: $resultData")
}
else -> {
callback.onError(currentSessionId, 1006, "Xunfei unknown msg_type: $msgType")
Log.w(tag, "Unknown msg_type: $msgType")
_log("WARNING", "[XunFei_ASR] Unknown msg_type=$msgType, data=$resultData")
callback.onError(currentSessionId, 1006, "Xunfei unknown msg_type=$msgType, data=$resultData")
}
}
} catch (e: Exception) {
Log.e(tag, "Parse error", e)
_log("ERROR", "[XunFei_ASR] Parse error: ${e.message}, raw=$resultData")
callback.onError(currentSessionId, 1008, "Xunfei parse error: ${e.message}")
}
}
@ -266,7 +321,7 @@ class XunFeiAsrHelper(private val context: Context) {
return supportedLanguages.firstOrNull { normalizeLanguageCode(it).startsWith(p) }
}
private fun pickPreferredChineseLanguage(): String {
return findSupportedExact("zh-CN")
?: findSupportedExact("zh-HK")
@ -294,7 +349,7 @@ class XunFeiAsrHelper(private val context: Context) {
private fun getWebSocketUrl(): String {
val baseWsUrl = "wss://office-api-ast-dx.iflyaisol.com/ast/communicate/v1"
val params = java.util.TreeMap<String, String>()
params["audio_encode"] = "pcm_s16le"
params["lang"] = "autodialect"
@ -302,7 +357,7 @@ class XunFeiAsrHelper(private val context: Context) {
params["accessKeyId"] = accessKeyId
params["appId"] = appId
params["utc"] = getUtcTime()
val signature = calculateSignature(params)
params["signature"] = signature
@ -331,7 +386,7 @@ class XunFeiAsrHelper(private val context: Context) {
for ((key, value) in params) {
if ("signature" == key) continue
if (value.isEmpty()) continue
if (!first) {
baseStr.append("&")
}
@ -343,7 +398,7 @@ class XunFeiAsrHelper(private val context: Context) {
val keySpec = SecretKeySpec(accessKeySecret.toByteArray(StandardCharsets.UTF_8), "HmacSHA1")
mac.init(keySpec)
val signBytes = mac.doFinal(baseStr.toString().toByteArray(StandardCharsets.UTF_8))
return Base64.encodeToString(signBytes, Base64.NO_WRAP)
}
}

21
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift

@ -57,6 +57,9 @@ public class AzureAsrHelper: NSObject {
private var useXunfei = false
private var xunFeiAsrHelper: XunFeiAsrHelper?
/// 原生日志回调,由 Plugin 层设置,通过 EventChannel 回传 Dart
var nativeLogCallback: ((String, String) -> Void)?
private var xunfeiAppId: String = ""
private var xunfeiAccessKeyId: String = ""
private var xunfeiAccessKeySecret: String = ""
@ -143,15 +146,13 @@ public class AzureAsrHelper: NSObject {
self.useXunfei =
onlySupportedByXunfei && xunfeiConfigReady && !hasMultipleChineseVariants
os_log(
"ASR helper initialize: supportedLanguages=%{public}@, audioSourceType=%{public}@, xunfeiConfigured=%{public}@, provider=%{public}@",
log: log,
type: .info,
supportedLanguages.joined(separator: ","),
String(describing: audioSourceType),
String(xunfeiConfigReady),
asrProvider()
)
let initMsg = "[XunFei_ASR] ASR initialize: supportedLanguages=\(supportedLanguages.joined(separator: ",")), audioSourceType=\(audioSourceType)"
os_log("%{public}@", log: log, type: .info, initMsg)
nativeLogCallback?("INFO", initMsg)
let configMsg = "[XunFei_ASR] ASR config: provider=\(asrProvider()), xunfeiConfigured=\(xunfeiConfigReady), onlyXunfeiLangs=\(onlySupportedByXunfei), multiChinese=\(hasMultipleChineseVariants)"
os_log("%{public}@", log: log, type: .info, configMsg)
nativeLogCallback?("INFO", configMsg)
// 根据支持的语言数量决定是否启用自动语言检测
self.isAutoDetectLanguage = supportedLanguages.count >= 2
@ -487,6 +488,8 @@ public class AzureAsrHelper: NSObject {
accessKeyId: xunfeiAccessKeyId,
accessKeySecret: xunfeiAccessKeySecret
)
xunFeiAsrHelper?.logCallback = nativeLogCallback
nativeLogCallback?("INFO", "[XunFei_ASR] Config init (callback ready): appId=\(String(xunfeiAppId.prefix(6)))***, keyId=\(String(xunfeiAccessKeyId.prefix(6)))***, secret=\(xunfeiAccessKeySecret.isEmpty ? "EMPTY" : "SET")")
} else {
xunFeiAsrHelper?.updateConfig(
appId: xunfeiAppId,

5
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -217,6 +217,11 @@ private func sendEvent(_ event: [String: Any], type: EventType) {
let audioDataEventChannel = FlutterEventChannel(name: "azure_speech/audio_data_events", binaryMessenger: registrar.messenger())
audioDataEventChannel.setStreamHandler(BaseEventStreamHandler(instance: instance, eventType: .audioData))
instance.audioDataEventChannel = audioDataEventChannel
// 设置讯飞原生日志回调,通过 EventChannel 回传 Dart
instance.azureAsrHelper.nativeLogCallback = { [weak instance] level, message in
instance?.sendAsrEvent(["type": "nativeLog", "level": level, "message": message])
}
}
// 简化的事件发送方法

239
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift

@ -1,56 +1,99 @@
import Foundation
import CryptoKit
import os
// 讯飞语音识别助手
//调用讯飞开放平台的实时语音转写大模型接口
// 参考文档:https://www.xfyun.cn/doc/spark/asr_llm/rtasr_llm.html
class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
private let tag = "XunFeiAsrHelper"
private let tag = "XunFei_ASR"
private let log = OSLog(subsystem: "com.azure.speech", category: "XunFei_ASR")
private var appId: String
private var accessKeyId: String
private var accessKeySecret: String
private var webSocketTask: URLSessionWebSocketTask?
private lazy var session: URLSession = {
let configuration = URLSessionConfiguration.default
return URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue())
}()
private var currentSessionId = ""
private var sessionId = ""
private var lastIntermediateResult = ""
private var currentLanguage = "zh-CN"
private var supportedLanguages: [String] = []
private var isAutoDetectLanguage = false
// 是否第去除一个文字的标点符号
private var removeFirstPunctuation = true
private var callback: AzureAsrHelper.ContinuousRecognizeCallback?
// 音频帧计数和连接时间
private var audioFrameCount = 0
private var connectStartTime: Date?
/// 日志回调:(level, message) -> Void,由 Plugin 层设置,用于将原生日志回传 Dart
var logCallback: ((String, String) -> Void)?
private let logChunkSize = 800
private func _log(_ level: String, _ message: String) {
if message.count <= logChunkSize {
_logSingle(level, message)
} else {
let totalParts = (message.count + logChunkSize - 1) / logChunkSize
for i in 0..<totalParts {
let start = message.index(message.startIndex, offsetBy: i * logChunkSize)
let end = message.index(start, offsetBy: min(logChunkSize, message.distance(from: start, to: message.endIndex)))
let chunk = String(message[start..<end])
_logSingle(level, "[\(i + 1)/\(totalParts)] \(chunk)")
}
}
}
private func _logSingle(_ level: String, _ message: String) {
switch level {
case "ERROR":
os_log("%{public}@", log: log, type: .error, message)
case "WARNING":
os_log("%{public}@", log: log, type: .default, message)
case "DEBUG":
os_log("%{public}@", log: log, type: .debug, message)
default:
os_log("%{public}@", log: log, type: .info, message)
}
logCallback?(level, message)
}
init(appId: String, accessKeyId: String, accessKeySecret: String) {
self.appId = appId
self.accessKeyId = accessKeyId
self.accessKeySecret = accessKeySecret
super.init()
_log("INFO", "[XunFei_ASR] Config init: appId=\(String(appId.prefix(6)))***, keyId=\(String(accessKeyId.prefix(6)))***, secret=\(accessKeySecret.isEmpty ? "EMPTY" : "SET")")
}
func updateConfig(appId: String, accessKeyId: String, accessKeySecret: String) {
self.appId = appId
self.accessKeyId = accessKeyId
self.accessKeySecret = accessKeySecret
_log("INFO", "[XunFei_ASR] Config updated: appId=\(String(appId.prefix(6)))***, keyId=\(String(accessKeyId.prefix(6)))***, secret=\(accessKeySecret.isEmpty ? "EMPTY" : "SET")")
}
private func isConfigValid() -> Bool {
return !appId.isEmpty && !accessKeyId.isEmpty && !accessKeySecret.isEmpty
}
func start(callback: AzureAsrHelper.ContinuousRecognizeCallback, language: String, supportedLanguages: [String], isAutoDetect: Bool, isRemoveFirstPunctuation: Bool) {
_log("INFO", "[XunFei_ASR] start() called: lang=\(language), supportedLangs=\(supportedLanguages.joined(separator: ",")), autoDetect=\(isAutoDetect)")
stop()
self.callback = callback
self.currentLanguage = language
self.supportedLanguages = supportedLanguages
@ -59,58 +102,75 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
self.currentSessionId = UUID().uuidString
self.lastIntermediateResult = ""
self.sessionId = ""
self.audioFrameCount = 0
if !isConfigValid() {
callback.onError(sessionid: self.currentSessionId, 1007, "Xunfei config missing")
_log("ERROR", "[XunFei_ASR] Config invalid: appId=\(!appId.isEmpty), keyId=\(!accessKeyId.isEmpty), secret=\(!accessKeySecret.isEmpty)")
callback.onError(sessionid: self.currentSessionId, 1007,
"Xunfei config missing: appId=\(!appId.isEmpty), keyId=\(!accessKeyId.isEmpty), secret=\(!accessKeySecret.isEmpty)")
return
}
let urlString = getWebSocketUrl()
guard let url = URL(string: urlString) else {
print("\(tag): Invalid URL")
_log("ERROR", "[XunFei_ASR] Invalid WebSocket URL")
callback.onError(sessionid: self.currentSessionId, 1009, "Xunfei invalid WebSocket URL")
return
}
_log("INFO", "[XunFei_ASR] WebSocket connecting to: \(url.host ?? "unknown")")
connectStartTime = Date()
let request = URLRequest(url: url)
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
print("\(tag): WebSocket Connecting...")
receiveMessage()
}
func sendAudio(frameBuffer: Data) {
guard let webSocketTask = webSocketTask else { return }
let message = URLSessionWebSocketTask.Message.data(frameBuffer)
webSocketTask.send(message) { error in
webSocketTask.send(message) { [weak self] error in
guard let self = self else { return }
if let error = error {
print("\(self.tag): Send audio failed: \(error)")
self._log("ERROR", "[XunFei_ASR] Send audio failed (frame #\(self.audioFrameCount)): \(error.localizedDescription)")
}
}
audioFrameCount += 1
// 每 200 帧打一条日志
if audioFrameCount % 200 == 0 {
_log("DEBUG", "[XunFei_ASR] Audio sent: \(audioFrameCount) frames, \(frameBuffer.count) bytes/frame")
}
}
func stop() {
_log("INFO", "[XunFei_ASR] stop() called: audioFrames=\(audioFrameCount), sessionId=\(sessionId)")
if let webSocketTask = webSocketTask {
let endJson = "{\"end\": true, \"sessionId\": \"\(sessionId)\"}"
let message = URLSessionWebSocketTask.Message.string(endJson)
webSocketTask.send(message) { _ in }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
webSocketTask.cancel(with: .normalClosure, reason: "User stopped".data(using: .utf8))
}
}
webSocketTask = nil
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let error):
print("\(self.tag): WebSocket Receive Error: \(error)")
self.callback?.onError(sessionid: self.currentSessionId, 1004, "Xunfei connection failed: \(error.localizedDescription)")
let elapsed = self.connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1
let nsError = error as NSError
self._log("ERROR", "[XunFei_ASR] WebSocket Receive Error (\(elapsed)ms): domain=\(nsError.domain), code=\(nsError.code), \(error.localizedDescription)")
self.callback?.onError(sessionid: self.currentSessionId, 1004,
"Xunfei connection failed (\(elapsed)ms): [\(nsError.domain):\(nsError.code)] \(error.localizedDescription)")
case .success(let message):
switch message {
case .string(let text):
@ -122,77 +182,90 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
@unknown default:
break
}
// Continue receiving messages
self.receiveMessage()
}
}
}
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
print("\(tag): WebSocket Opened")
let elapsed = connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1
_log("INFO", "[XunFei_ASR] WebSocket Opened (\(elapsed)ms)")
callback?.onSessionStarted(sessionid: currentSessionId)
}
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown"
print("\(tag): WebSocket Closed: \(closeCode) / \(reasonString)")
_log("INFO", "[XunFei_ASR] WebSocket Closed: code=\(closeCode.rawValue), reason=\(reasonString), audioFrames=\(audioFrameCount)")
if !currentSessionId.isEmpty && !lastIntermediateResult.isEmpty {
let detectedLanguage = detectLanguage(from: lastIntermediateResult)
callback?.onResult(sessionid: currentSessionId, lastIntermediateResult, detectedLanguage)
}
callback?.onSessionStopped(sessionid: currentSessionId)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
let elapsed = connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1
let nsError = error as NSError
_log("ERROR", "[XunFei_ASR] URLSession task failed (\(elapsed)ms): domain=\(nsError.domain), code=\(nsError.code), \(error.localizedDescription)")
callback?.onError(sessionid: currentSessionId, 1004,
"Xunfei URLSession failed (\(elapsed)ms): [\(nsError.domain):\(nsError.code)] \(error.localizedDescription)")
}
}
private func processMessage(_ text: String) {
guard let data = text.data(using: .utf8) else { return }
do {
guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return }
let msgType = json["msg_type"] as? String
_log("DEBUG", "[XunFei_ASR] Received msg_type=\(msgType ?? "nil")")
switch msgType {
case "action":
if let sid = json["sessionId"] as? String, !sid.isEmpty {
self.sessionId = sid
}
print("\(tag): Handshake success")
_log("INFO", "[XunFei_ASR] Handshake success, serverSessionId=\(self.sessionId)")
case "result":
processResult(json)
case "error":
let errorMsg = "\(json)"
print("\(tag): Error: \(errorMsg)")
callback?.onError(sessionid: currentSessionId, 1005, "Xunfei error: \(errorMsg)")
_log("ERROR", "[XunFei_ASR] Server error: \(errorMsg)")
callback?.onError(sessionid: currentSessionId, 1005, "Xunfei server error: \(errorMsg)")
default:
print("\(tag): Unknown msg_type: \(msgType ?? "nil")")
callback?.onError(sessionid: currentSessionId, 1006, "Xunfei unknown msg_type: \(msgType ?? "nil")")
_log("ERROR", "[XunFei_ASR] Unknown msg_type=\(msgType ?? "nil"), data=\(text)")
callback?.onError(sessionid: currentSessionId, 1006, "Xunfei unknown msg_type=\(msgType ?? "nil"), data=\(text)")
}
} catch {
print("\(tag): Parse error: \(error)")
_log("ERROR", "[XunFei_ASR] Parse error: \(error.localizedDescription), raw=\(text)")
callback?.onError(sessionid: currentSessionId, 1008, "Xunfei parse error: \(error.localizedDescription)")
}
}
private func processResult(_ json: [String: Any]) {
var data: [String: Any]? = nil
if let dataDict = json["data"] as? [String: Any] {
data = dataDict
} else if let dataStr = json["data"] as? String,
let dataData = dataStr.data(using: .utf8) {
data = try? JSONSerialization.jsonObject(with: dataData, options: []) as? [String: Any]
}
guard let data = data else {
print("\(tag): Failed to parse data field from result")
_log("ERROR", "[XunFei_ASR] Failed to parse data field from result")
return
}
var ls = false
if let lsBool = data["ls"] as? Bool {
ls = lsBool
@ -207,22 +280,22 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
let rt = st["rt"] as? [[String: Any]] else {
return
}
var resultTextTemp = ""
var punctuationCount = 0
var wordCount = 0
for j in rt {
guard let ws = j["ws"] as? [[String: Any]] else { continue }
for kObj in ws {
guard let cw = kObj["cw"] as? [[String: Any]] else { continue }
if(removeFirstPunctuation) {
// 如果resultTextTemp中第一个文字是标点符号,则去除第一个文字的标点符号
for lObj in cw {
let w = lObj["w"] as? String ?? ""
let wp = lObj["wp"] as? String ?? ""
if wp == "p" {
punctuationCount += 1
if resultTextTemp.isEmpty {
@ -238,7 +311,7 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
for lObj in cw {
let w = lObj["w"] as? String ?? ""
let wp = lObj["wp"] as? String ?? ""
if wp == "p" {
punctuationCount += 1
} else {
@ -249,17 +322,19 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
}
}
}
if ls && wordCount == 0 && punctuationCount == 1 {
callback?.onSessionStopped(sessionid: currentSessionId)
return
}
let detectedLanguage = detectLanguage(from: resultTextTemp)
let type = st["type"] as? String
let typeInt = st["type"] as? Int ?? Int(type ?? "") ?? -1
_log("DEBUG", "[XunFei_ASR] Result: type=\(typeInt), ls=\(ls), words=\(wordCount), text=\(String(resultTextTemp))")
if typeInt == 0 {
callback?.onResult(sessionid: currentSessionId, resultTextTemp, detectedLanguage)
currentSessionId = UUID().uuidString
@ -269,7 +344,7 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
callback?.onRecognizing(sessionid: currentSessionId, resultTextTemp, detectedLanguage)
}
}
private func normalizeLanguageCode(_ code: String) -> String {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return trimmed }
@ -308,10 +383,10 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
let hasChinese = text.range(of: "[\\u4e00-\\u9fa5]", options: .regularExpression) != nil
return hasChinese ? pickPreferredChineseLanguage() : pickPreferredEnglishLanguage()
}
private func getWebSocketUrl() -> String {
let baseWsUrl = "wss://office-api-ast-dx.iflyaisol.com/ast/communicate/v1"
var params: [String: String] = [:]
params["audio_encode"] = "pcm_s16le"
params["lang"] = "autodialect"
@ -319,69 +394,69 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
params["accessKeyId"] = accessKeyId
params["appId"] = appId
params["utc"] = getUtcTime()
let signature = calculateSignature(params: params)
params["signature"] = signature
// 手动构建 URL 查询字符串,确保编码一致性
var queryString = ""
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
// 按照参数名的字母顺序排序,虽然 HTTP 协议不强制,但通常是个好习惯,也方便调试
let sortedParams = params.sorted { $0.key < $1.key }
for (index, (key, value)) in sortedParams.enumerated() {
if index > 0 {
queryString += "&"
}
let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
let encodedValue = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
queryString += "\(encodedKey)=\(encodedValue)"
}
return "\(baseWsUrl)?\(queryString)"
}
private func getUtcTime() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 8 * 3600) // GMT+8
return dateFormatter.string(from: Date())
}
private func calculateSignature(params: [String: String]) -> String {
let sortedKeys = params.keys.sorted()
var baseStr = ""
var first = true
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
for key in sortedKeys {
if key == "signature" { continue }
guard let value = params[key], !value.isEmpty else { continue }
if !first {
baseStr += "&"
}
// 使用严格的编码规则
let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
let encodedValue = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
baseStr += "\(encodedKey)=\(encodedValue)"
first = false
}
let keyData = accessKeySecret.data(using: .utf8)!
let msgData = baseStr.data(using: .utf8)!
if #available(iOS 13.0, *) {
let authentication = HMAC<Insecure.SHA1>.authenticationCode(for: msgData, using: SymmetricKey(data: keyData))
return Data(authentication).base64EncodedString()
} else {
return ""
return ""
}
}
}

2
pubspec.yaml

@ -1,7 +1,7 @@
name: voitrans
description: "Voitrans - AI Voice Assistant."
publish_to: "none"
version: 1.0.25+99
version: 1.0.26+101
environment:
sdk: ">=3.3.0 <4.0.0"

Loading…
Cancel
Save