From d499fa7492d176910d9bd716f883cf0456d6c8a3 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 30 Mar 2026 09:36:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=BA=95=E5=B1=82=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E9=87=87=E9=9B=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/utils/logger.dart | 209 +++++++++++++- lib/data/services/ble_manager.dart | 17 ++ lib/data/services/network/dio_manager.dart | 82 +++++- .../speech_impl/azure_asr_service.dart | 31 +- lib/main.dart | 3 + .../bindings/log_export_binding.dart | 9 + .../controllers/log_export_controller.dart | 130 +++++++++ .../log_export/views/log_export_view.dart | 271 ++++++++++++++++++ .../controllers/settings_controller.dart | 16 +- lib/modules/settings/views/settings_view.dart | 20 ++ lib/routes/app_pages.dart | 9 + lib/routes/app_routes.dart | 1 + .../agent_service/AgentService.kt | 153 +++++----- .../agent_service/AgentServicePlugin.kt | 14 + .../agent_service/AgentServiceImpl.swift | 187 ++++++------ .../agent_service/AgentServicePlugin.swift | 15 + .../agent_service/lib/agent_service.dart | 19 +- .../azure_speech/AzureAsrHelper.kt | 19 +- .../azure_speech/AzureSpeechPlugin.kt | 3 + .../azure_speech/XunFeiAsrHelper.kt | 101 +++++-- .../Sources/azure_speech/AzureAsrHelper.swift | 21 +- .../azure_speech/AzureSpeechPlugin.swift | 5 + .../azure_speech/XunFeiAsrHelper.swift | 239 +++++++++------ pubspec.yaml | 2 +- 24 files changed, 1268 insertions(+), 308 deletions(-) create mode 100644 lib/modules/log_export/bindings/log_export_binding.dart create mode 100644 lib/modules/log_export/controllers/log_export_controller.dart create mode 100644 lib/modules/log_export/views/log_export_view.dart diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index eba8701f5..239bdd0dc 100644 --- a/lib/core/utils/logger.dart +++ b/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 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 _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 _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 _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() + .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> 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() + .toList(); + files.sort((a, b) => b.path.compareTo(a.path)); // 最新在前 + return files; + } + + /// 刷新缓冲区(导出前调用) + static Future 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); } } diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index a98989bdb..24964658c 100644 --- a/lib/data/services/ble_manager.dart +++ b/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'); diff --git a/lib/data/services/network/dio_manager.dart b/lib/data/services/network/dio_manager.dart index f4a2c9574..77bdf8814 100644 --- a/lib/data/services/network/dio_manager.dart +++ b/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); + } +} diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index bdbc6100e..f75fe01e5 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/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, diff --git a/lib/main.dart b/lib/main.dart index 8f6bbe8ca..98d00e3da 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,6 +36,9 @@ void main() async { await dotenv.load(fileName: ".env"); Logger.i("Main", "环境变量加载成功"); + // 初始化文件日志 + await Logger.initFileLog(); + // 初始化登录服务 Get.put(LoginService()); diff --git a/lib/modules/log_export/bindings/log_export_binding.dart b/lib/modules/log_export/bindings/log_export_binding.dart new file mode 100644 index 000000000..71b3b3d33 --- /dev/null +++ b/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()); + } +} diff --git a/lib/modules/log_export/controllers/log_export_controller.dart b/lib/modules/log_export/controllers/log_export_controller.dart new file mode 100644 index 000000000..b65ce56a7 --- /dev/null +++ b/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 logFiles = [].obs; + final RxBool isLoading = false.obs; + final RxString totalSize = ''.obs; + + @override + void onInit() { + super.onInit(); + loadLogFiles(); + } + + /// 加载日志文件列表 + Future 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 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 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 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 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, + }); +} diff --git a/lib/modules/log_export/views/log_export_view.dart b/lib/modules/log_export/views/log_export_view.dart new file mode 100644 index 000000000..21d29d9bf --- /dev/null +++ b/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 { + 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(); + }, + ); + } +} diff --git a/lib/modules/settings/controllers/settings_controller.dart b/lib/modules/settings/controllers/settings_controller.dart index fa2fa1121..7b10aaf1b 100644 --- a/lib/modules/settings/controllers/settings_controller.dart +++ b/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(); - 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'; } diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 6459967a0..ac15973d3 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -574,6 +574,26 @@ class SettingsView extends GetView { ) : 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 diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index de6142ab3..5cc429486 100644 --- a/lib/routes/app_pages.dart +++ b/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(), diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index be5b03701..e36f89565 100644 --- a/lib/routes/app_routes.dart +++ b/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'; // 综合语音测试 diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 3a1dddbd6..c5d38c78c 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -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): 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) { // 参数验证 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}") } } } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt index 4e17d7172..c3f37c7fe 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt +++ b/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, "插件已加载") } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index fd135db40..836a69fb0 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/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.. 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)") } } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift index 8963009e1..82f53326b 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift +++ b/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 } diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 92dd7cafa..bb2c58425 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -110,9 +110,26 @@ class AgentService { static Stream? _eventStream; + /// 原生日志回调,由主 app 设置,用于将原生日志写入文件 + static void Function(String level, String message)? nativeLogCallback; + /// 获取事件流 static Stream get events { - _eventStream ??= _eventChannel.receiveBroadcastStream().map((event) { + _eventStream ??= _eventChannel.receiveBroadcastStream().where((event) { + // 拦截原生日志事件,不传入业务流 + try { + final Map eventMap = jsonDecode(event); + if (eventMap['event'] == 'nativeLog') { + final data = eventMap['data'] as Map?; + nativeLogCallback?.call( + data?['level'] as String? ?? 'INFO', + data?['message'] as String? ?? '', + ); + return false; + } + } catch (_) {} + return true; + }).map((event) { try { final Map eventMap = jsonDecode(event); final String eventName = eventMap['event']; diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index d10835762..9a4297cfe 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/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, diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 344d6fba1..7685d59f6 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/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)) { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt index 039a2bafd..c58007dcf 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt +++ b/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() 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) } } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index 5cc8fda70..a5e261755 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/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, diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index a27ec5faa..0029c5771 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/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]) + } } // 简化的事件发送方法 diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift index f17d356ad..44154ae2a 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift +++ b/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.. 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.authenticationCode(for: msgData, using: SymmetricKey(data: keyData)) return Data(authentication).base64EncodedString() } else { - return "" + return "" } } } diff --git a/pubspec.yaml b/pubspec.yaml index abe87d33e..ebcca5d36 100644 --- a/pubspec.yaml +++ b/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"