Browse Source

Merge branch 'new_dev' of https://github.com/deepcloud2048/deep_voice into liwei

newdev_shunjiawei
liwei1dao 6 months ago
parent
commit
f3625f1e43
  1. 3
      .gitignore
  2. 25
      .metadata
  3. 166
      AUDIO_PLAYBACK_FIX.md
  4. 519
      build_channels.sh
  5. 185
      lib/core/utils/logger.dart
  6. 17
      lib/data/services/ble_manager.dart
  7. 107
      lib/data/services/network/dio_manager.dart
  8. 31
      lib/data/services/speech_impl/azure_asr_service.dart
  9. 3
      lib/main.dart
  10. 9
      lib/modules/log_export/bindings/log_export_binding.dart
  11. 130
      lib/modules/log_export/controllers/log_export_controller.dart
  12. 271
      lib/modules/log_export/views/log_export_view.dart
  13. 16
      lib/modules/settings/controllers/settings_controller.dart
  14. 20
      lib/modules/settings/views/settings_view.dart
  15. 2
      lib/modules/translation/controllers/translation_controller.dart
  16. 9
      lib/routes/app_pages.dart
  17. 1
      lib/routes/app_routes.dart
  18. 210
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  19. 14
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  20. 233
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  21. 15
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift
  22. 19
      local_plugins/agent_service/lib/agent_service.dart
  23. 19
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  24. 3
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  25. 91
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt
  26. 21
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift
  27. 5
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  28. 109
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift
  29. 76
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt
  30. 96
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift
  31. 85
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt
  32. 97
      local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift
  33. 97
      local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift
  34. 2
      pubspec.yaml

3
.gitignore

@ -1,5 +1,8 @@
# See https://www.dartlang.org/guides/libraries/private-files # See https://www.dartlang.org/guides/libraries/private-files
# Claude Code
.claude/
# Files and directories created by pub # Files and directories created by pub
.dart_tool/ .dart_tool/
.packages .packages

25
.metadata

@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: "8defaa71a77c16e8547abdbfad2053ce3a6e2d5b" revision: "30ade5e937e6afbd2d2c38ec53175b89b556447e"
channel: "stable" channel: "stable"
project_type: app project_type: app
@ -13,26 +13,11 @@ project_type: app
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
- platform: android
create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
- platform: ios
create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
- platform: linux
create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
- platform: macos - platform: macos
create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
- platform: web
create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
- platform: windows
create_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
base_revision: 8defaa71a77c16e8547abdbfad2053ce3a6e2d5b
# User provided section # User provided section

166
AUDIO_PLAYBACK_FIX.md

@ -1,166 +0,0 @@
# 音频播放Bug修复报告
## 问题描述
在会议详情页面(meeting detail page)中,点击播放按钮(▶️)时没有任何反应,音频无法播放。
影响两种类型的音频:
1. 录音的音频(recorded audio)
2. 导入的音频(imported audio)
## 根本原因分析
### 1. 缺少播放器准备状态检查
**问题:** `MeetingDetailsController` 中的 `_loadPlayerController()` 方法没有错误处理和状态跟踪。
- 播放器初始化失败时没有任何提示
- UI层无法知道播放器是否准备好
- 文件路径错误或文件不存在时静默失败
### 2. UI层缺少状态验证
**问题:** `meeting_details_view.dart` 中的播放按钮点击事件没有检查播放器状态。
- 直接调用 `startPlayer()` 而不验证播放器是否已初始化
- 没有异常捕获机制
- 用户点击后没有任何反馈
### 3. 缺少日志记录
**问题:** 没有足够的日志来追踪播放器初始化和播放过程。
- 无法诊断失败原因
- 难以定位问题发生的具体环节
## 修复方案
### 修改1: MeetingDetailsController - 添加播放器准备状态
**文件:** `lib/modules/meeting/controllers/meeting_details_controller.dart`
**变更:**
1. 添加 `isPlayerReady` 状态变量来跟踪播放器是否准备好
2. 重写 `_loadPlayerController()` 方法,添加完整的错误处理
3. 添加文件路径验证和文件存在性检查
4. 添加详细的日志记录
5. 添加用户友好的错误提示
**关键代码:**
```dart
// 新增状态变量
RxBool isPlayerReady = false.obs;
// 改进的播放器加载方法
Future<void> _loadPlayerController() async {
try {
final filepath = meetingData.value.filepath;
// 验证文件路径
if (filepath.isEmpty) {
Logger.error('音频文件路径为空,无法加载播放器');
isPlayerReady.value = false;
return;
}
// 验证文件是否存在
final file = File(filepath);
if (!await file.exists()) {
Logger.error('音频文件不存在: $filepath');
isPlayerReady.value = false;
Get.snackbar('error'.tr, '音频文件不存在', ...);
return;
}
Logger.d('MeetingDetails', '开始加载音频播放器: $filepath');
// 准备播放器
await playerController.preparePlayer(...);
// 标记播放器已准备好
isPlayerReady.value = true;
} catch (e, stackTrace) {
Logger.error('加载音频播放器失败: $e');
isPlayerReady.value = false;
Get.snackbar('error'.tr, '音频播放器加载失败', ...);
}
}
```
### 修改2: MeetingDetailsView - 添加播放前验证
**文件:** `lib/modules/meeting/views/meeting_details_view.dart`
**变更:**
1. 在播放按钮点击事件中添加播放器准备状态检查
2. 添加异常捕获和用户提示
3. 使用 async/await 确保异步操作正确执行
**关键代码:**
```dart
GestureDetector(
onTap: () async {
if (controller.meetingData.value.filepath.isEmpty) {
controller.downloadAudio();
} else {
// 检查播放器是否已准备好
if (!controller.isPlayerReady.value) {
Get.snackbar('error'.tr, '音频播放器未准备好,请稍候', ...);
return;
}
try {
if (controller.isPlay.value) {
await controller.playerController.pausePlayer();
} else {
await controller.playerController.startPlayer();
}
} catch (e) {
Get.snackbar('error'.tr, '播放失败: ${e.toString()}', ...);
}
}
},
...
)
```
## 修复效果
### 修复前
- ❌ 点击播放按钮无响应
- ❌ 没有错误提示
- ❌ 无法诊断问题
- ❌ 用户体验差
### 修复后
- ✅ 播放器初始化失败时有明确提示
- ✅ 文件不存在时提示用户
- ✅ 播放器未准备好时阻止播放并提示
- ✅ 播放失败时显示具体错误信息
- ✅ 完整的日志记录便于调试
- ✅ 录音音频和导入音频都能正常播放
## 测试建议
1. **测试录音音频播放**
- 创建新的录音
- 保存录音
- 进入会议详情页面
- 点击播放按钮验证播放功能
2. **测试导入音频播放**
- 导入外部音频文件
- 进入会议详情页面
- 点击播放按钮验证播放功能
3. **测试错误场景**
- 删除音频文件后尝试播放(应提示文件不存在)
- 在播放器未准备好时点击播放(应提示等待)
## 技术细节
### 使用的技术
- **audio_waveforms**: Flutter音频播放库
- **GetX**: 状态管理和依赖注入
- **Logger**: 日志记录工具
### 关键改进点
1. **状态管理**: 使用 `RxBool isPlayerReady` 响应式状态
2. **错误处理**: try-catch 包裹所有异步操作
3. **用户反馈**: Get.snackbar 提供即时反馈
4. **日志记录**: Logger 记录关键操作和错误
## 总结
此次修复通过添加完善的状态检查、错误处理和用户反馈机制,彻底解决了音频播放按钮无响应的问题。修复后的代码更加健壮,用户体验显著提升。

519
build_channels.sh

@ -0,0 +1,519 @@
#!/bin/bash
# =================================================================
# Voitrans 多渠道 Android 打包脚本
# 用法:
# ./build_channels.sh # 打包所有渠道
# ./build_channels.sh huawei google # 只打包指定渠道
# ./build_channels.sh --clean # 清理后再打包所有渠道
# ./build_channels.sh --list # 列出所有支持的渠道
# =================================================================
set -euo pipefail
# ---------- 常量定义 ----------
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/.env"
PUBSPEC_FILE="${SCRIPT_DIR}/pubspec.yaml"
OUTPUT_DIR="/Users/liuxiamin/work/build_outputs"
APP_NAME="Voitrans"
BUILD_DATE=$(date +"%m%d")
# ---------- 渠道配置 ----------
# 渠道名列表(保证顺序)
CHANNEL_NAMES="huawei xiaomi oppo vivo honor google"
# 根据渠道名获取 .env 中的 Channel 值
get_channel_env_value() {
case "$1" in
huawei) echo "CHINA_HUAWEI" ;;
xiaomi) echo "CHINA_XIAOMI" ;;
oppo) echo "CHINA_OPPO" ;;
vivo) echo "CHINA_VIVO" ;;
honor) echo "CHINA_HONOR" ;;
google) echo "GLOBAL_GOOGLE" ;;
*) echo "" ;;
esac
}
# 根据渠道名获取构建类型
get_build_type() {
case "$1" in
google) echo "aab" ;;
*) echo "apk" ;;
esac
}
# 校验渠道名是否有效
is_valid_channel() {
case "$1" in
huawei|xiaomi|oppo|vivo|honor|google) return 0 ;;
*) return 1 ;;
esac
}
# 构建结果追踪
SUCCESS_CHANNELS=""
FAILED_CHANNELS=""
# ---------- 颜色输出 ----------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# ---------- 辅助函数 ----------
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 提取版本号(从 pubspec.yaml 的 version 字段,去掉构建号)
get_version() {
grep '^version:' "$PUBSPEC_FILE" | sed 's/version: *//' | cut -d'+' -f1
}
# 备份 .env
backup_env() {
cp "$ENV_FILE" "${ENV_FILE}.bak"
log_info "已备份 .env -> .env.bak"
}
# 恢复 .env
restore_env() {
if [ -f "${ENV_FILE}.bak" ]; then
mv "${ENV_FILE}.bak" "$ENV_FILE"
log_info "已恢复 .env 文件"
fi
}
# 修改 .env 中的 Channel 值(兼容 macOS sed)
set_channel() {
local channel_value="$1"
if [ "$(uname)" = "Darwin" ]; then
sed -i '' "s/^Channel=.*/Channel=${channel_value}/" "$ENV_FILE"
else
sed -i "s/^Channel=.*/Channel=${channel_value}/" "$ENV_FILE"
fi
}
# 列出所有渠道
list_channels() {
echo ""
echo "支持的渠道列表:"
echo " 序号 渠道名 .env值 构建格式"
echo " ---- -------- ---------------- --------"
local idx=1
for ch in $CHANNEL_NAMES; do
local env_val
env_val=$(get_channel_env_value "$ch")
local build_type
build_type=$(get_build_type "$ch")
printf " [%d] %-10s %-18s %s\n" "$idx" "$ch" "$env_val" "$build_type"
idx=$((idx + 1))
done
echo ""
}
# 交互式选择渠道(上下键移动,空格勾选,Enter确认)
interactive_select() {
# 构建菜单项数组
local items="全部打包"
for ch in $CHANNEL_NAMES; do
local bt
bt=$(get_build_type "$ch")
items="${items}|${ch} (${bt})"
done
local total_items=7 # 全部打包 + 6个渠道
local cursor=0 # 当前光标位置
# 选中状态:0=未选 1=已选(用空格分隔的列表,索引0-6)
local selected="0 0 0 0 0 0 0"
# 获取第n项的选中状态
get_selected() {
echo "$selected" | cut -d' ' -f$(($1 + 1))
}
# 切换第n项的选中状态
toggle_selected() {
local idx=$1
local new_selected=""
local i=0
for val in $selected; do
if [ $i -eq $idx ]; then
if [ "$val" = "0" ]; then
# 如果选中"全部打包",取消其他所有
if [ $idx -eq 0 ]; then
selected="1 0 0 0 0 0 0"
return
fi
val="1"
else
val="0"
fi
fi
# 选中单个渠道时,取消"全部打包"
if [ $idx -ne 0 ] && [ $i -eq 0 ]; then
val="0"
fi
if [ -z "$new_selected" ]; then
new_selected="$val"
else
new_selected="$new_selected $val"
fi
i=$((i + 1))
done
selected="$new_selected"
}
# 绘制菜单
draw_menu() {
# 移动光标到菜单起始位置
local i=0
printf "\033[${total_items}A" # 向上移动
local item_idx=0
# 保存IFS并用|分割
local OLD_IFS="$IFS"
IFS='|'
for item in $items; do
local marker=" "
local sel
sel=$(get_selected $item_idx)
if [ "$sel" = "1" ]; then
marker="✓"
fi
if [ $item_idx -eq $cursor ]; then
# 高亮当前行
printf "\033[2K ${GREEN}▸ [${marker}] ${item}${NC}\n"
else
printf "\033[2K [${marker}] ${item}\n"
fi
item_idx=$((item_idx + 1))
done
IFS="$OLD_IFS"
}
# 显示标题
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Voitrans 多渠道打包工具 ║"
echo "╚══════════════════════════════════════════╝"
echo ""
echo " ↑/↓ 移动 空格 勾选 Enter 确认 q 退出"
echo ""
# 首次绘制菜单(先输出占位行)
local i=0
while [ $i -lt $total_items ]; do
echo ""
i=$((i + 1))
done
draw_menu
# 读取键盘输入
while true; do
# 读取单个字符(静默模式)
IFS= read -rsn1 key
case "$key" in
$'\x1b')
# 读取转义序列的剩余部分
read -rsn2 seq
case "$seq" in
'[A') # 上箭头
if [ $cursor -gt 0 ]; then
cursor=$((cursor - 1))
else
cursor=$((total_items - 1))
fi
;;
'[B') # 下箭头
if [ $cursor -lt $((total_items - 1)) ]; then
cursor=$((cursor + 1))
else
cursor=0
fi
;;
esac
;;
' ') # 空格:切换选中
toggle_selected $cursor
;;
'') # Enter:确认
break
;;
'q'|'Q') # 退出
echo ""
echo "已取消"
exit 0
;;
esac
draw_menu
done
# 根据选中状态构建渠道列表
if [ "$(get_selected 0)" = "1" ]; then
SELECTED_CHANNELS="$CHANNEL_NAMES"
else
SELECTED_CHANNELS=""
local idx=1
for ch in $CHANNEL_NAMES; do
if [ "$(get_selected $idx)" = "1" ]; then
SELECTED_CHANNELS="${SELECTED_CHANNELS} ${ch}"
fi
idx=$((idx + 1))
done
SELECTED_CHANNELS=$(echo "$SELECTED_CHANNELS" | sed 's/^ *//')
fi
if [ -z "$SELECTED_CHANNELS" ]; then
log_error "未选择任何渠道"
exit 1
fi
echo ""
log_info "已选择: ${SELECTED_CHANNELS}"
}
# ---------- 核心构建函数 ----------
build_channel() {
local channel_name="$1"
local env_value
env_value=$(get_channel_env_value "$channel_name")
local build_type
build_type=$(get_build_type "$channel_name")
local version
version=$(get_version)
local output_filename="${APP_NAME}_${BUILD_DATE}_${version}_${channel_name}.${build_type}"
echo ""
echo "========================================"
log_info "开始构建渠道: ${channel_name} (${env_value})"
log_info "构建类型: ${build_type}"
log_info "输出文件: ${output_filename}"
echo "========================================"
# 1. 修改 .env 中的 Channel 值
set_channel "$env_value"
log_info "已设置 Channel=${env_value}"
# 2. 执行 flutter build
local build_start
build_start=$(date +%s)
local build_result=0
local source_file=""
if [ "$build_type" = "apk" ]; then
flutter build apk \
--obfuscate \
--split-debug-info=./build/app/outputs/symbols \
2>&1 | tee "${OUTPUT_DIR}/build_${channel_name}.log" || build_result=$?
source_file="${SCRIPT_DIR}/build/app/outputs/flutter-apk/app-release.apk"
else
flutter build aab \
--obfuscate \
--split-debug-info=./build/app/outputs/symbols \
2>&1 | tee "${OUTPUT_DIR}/build_${channel_name}.log" || build_result=$?
source_file="${SCRIPT_DIR}/build/app/outputs/bundle/release/app-release.aab"
fi
local build_end
build_end=$(date +%s)
local build_duration=$((build_end - build_start))
# 3. 检查构建结果
if [ $build_result -ne 0 ]; then
log_error "渠道 ${channel_name} 构建失败!(耗时 ${build_duration}秒)"
FAILED_CHANNELS="${FAILED_CHANNELS} ${channel_name}"
return 1
fi
# 4. 复制并重命名输出文件
if [ -f "$source_file" ]; then
cp "$source_file" "${OUTPUT_DIR}/${output_filename}"
local file_size
file_size=$(du -h "${OUTPUT_DIR}/${output_filename}" | cut -f1)
# 构建成功,删除日志文件
rm -f "${OUTPUT_DIR}/build_${channel_name}.log"
log_success "渠道 ${channel_name} 构建成功!(耗时 ${build_duration}秒, 大小 ${file_size})"
log_success "输出: ${OUTPUT_DIR}/${output_filename}"
SUCCESS_CHANNELS="${SUCCESS_CHANNELS} ${channel_name}:${build_duration}"
else
log_error "构建产物未找到: ${source_file}"
FAILED_CHANNELS="${FAILED_CHANNELS} ${channel_name}"
return 1
fi
}
# ---------- 主流程 ----------
main() {
local total_start
total_start=$(date +%s)
# 参数解析
local do_clean=false
local channels_to_build=""
for arg in "$@"; do
case "$arg" in
--list|-l)
list_channels
exit 0
;;
--clean|-c)
do_clean=true
;;
--help|-h)
echo "用法: $0 [选项] [渠道名...]"
echo ""
echo "选项:"
echo " --list, -l 列出所有支持的渠道"
echo " --clean, -c 构建前先执行 flutter clean"
echo " --help, -h 显示帮助信息"
echo ""
echo "示例:"
echo " $0 # 打包所有渠道"
echo " $0 huawei google # 只打包华为和谷歌"
echo " $0 --clean # 清理后打包所有渠道"
exit 0
;;
*)
if ! is_valid_channel "$arg"; then
log_error "未知渠道: $arg"
list_channels
exit 1
fi
channels_to_build="${channels_to_build} ${arg}"
;;
esac
done
# 如果没有指定渠道,进入交互选择模式
if [ -z "$channels_to_build" ]; then
interactive_select
channels_to_build="$SELECTED_CHANNELS"
fi
# 去掉前导空格
channels_to_build=$(echo "$channels_to_build" | sed 's/^ *//')
local version
version=$(get_version)
# 计算渠道总数
local total=0
for _ in $channels_to_build; do
total=$((total + 1))
done
# 显示构建计划
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Voitrans 多渠道打包工具 ║"
echo "╚══════════════════════════════════════════╝"
echo ""
log_info "构建渠道: ${channels_to_build}"
log_info "版本号: ${version}"
log_info "构建日期: ${BUILD_DATE}"
log_info "输出目录: ${OUTPUT_DIR}"
log_info "共 ${total} 个渠道"
# 前置检查
if ! command -v flutter &> /dev/null; then
log_error "flutter 命令未找到,请确保 Flutter SDK 已安装并配置 PATH"
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
log_error ".env 文件不存在: $ENV_FILE"
exit 1
fi
# 创建输出目录
mkdir -p "$OUTPUT_DIR"
# 可选:flutter clean
if [ "$do_clean" = true ]; then
log_info "执行 flutter clean..."
flutter clean
log_info "执行 flutter pub get..."
flutter pub get
fi
# 备份 .env 并注册 trap(确保异常退出也能恢复)
backup_env
trap restore_env EXIT
# 遍历构建
local current=0
for channel in $channels_to_build; do
current=$((current + 1))
echo ""
echo "████████████████████████████████████████"
echo " 进度: [${current}/${total}] 渠道: ${channel}"
echo "████████████████████████████████████████"
build_channel "$channel" || true
done
# ---------- 构建结果汇总 ----------
local total_end
total_end=$(date +%s)
local total_duration=$((total_end - total_start))
local total_min=$((total_duration / 60))
local total_sec=$((total_duration % 60))
# 统计成功/失败数量
local success_count=0
local fail_count=0
for _ in $SUCCESS_CHANNELS; do
success_count=$((success_count + 1))
done
for _ in $FAILED_CHANNELS; do
fail_count=$((fail_count + 1))
done
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ 构 建 结 果 汇 总 ║"
echo "╠══════════════════════════════════════════╣"
if [ $success_count -gt 0 ]; then
echo "║ 成功: ║"
for item in $SUCCESS_CHANNELS; do
local name="${item%%:*}"
local dur="${item##*:}"
printf "║ ✅ %-12s (%s秒) ║\n" "$name" "$dur"
done
fi
if [ $fail_count -gt 0 ]; then
echo "║ 失败: ║"
for name in $FAILED_CHANNELS; do
printf "║ ❌ %-12s ║\n" "$name"
done
fi
echo "╠══════════════════════════════════════════╣"
printf "║ 成功: %d/%d 总耗时: %d分%d秒 ║\n" \
"$success_count" "$total" "$total_min" "$total_sec"
echo "╚══════════════════════════════════════════╝"
# 列出输出目录中的安装包文件
if [ $success_count -gt 0 ]; then
echo ""
log_info "输出文件列表:"
ls -lh "${OUTPUT_DIR}/${APP_NAME}_${BUILD_DATE}"_* 2>/dev/null || true
fi
# 如果有失败的渠道,退出码为 1
if [ $fail_count -gt 0 ]; then
exit 1
fi
}
main "$@"

185
lib/core/utils/logger.dart

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

17
lib/data/services/ble_manager.dart

@ -1080,6 +1080,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服务'); Logger.info('lxm--准备调用AgentService.startAgentService启动BLE服务');
final result = await AgentService.startAgentService(config); final result = await AgentService.startAgentService(config);
Logger.info('lxm--AgentService.startAgentService调用结果: $result'); Logger.info('lxm--AgentService.startAgentService调用结果: $result');

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

@ -1,7 +1,13 @@
import 'dart:io';
import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.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'; import 'auth_interceptor.dart';
import 'nw_method.dart'; import 'nw_method.dart';
@ -10,12 +16,29 @@ class DioManager {
factory DioManager() => _shared; factory DioManager() => _shared;
late Dio dio; late Dio dio;
// 获取当前渠道信息,优先从版本服务获取,降级到平台判断和.env
UserChannel get _currentChannel {
try {
if (Get.isRegistered<VersionUpdateService>()) {
// print("lxm---${VersionUpdateService.to.cachedChannel}");
return VersionUpdateService.to.cachedChannel;
}
} catch (_) {}
// 版本服务未就绪时,根据平台判断
if (Platform.isIOS) {
return UserChannel.GLOBAL_APPLE;
}
final channelName = dotenv.env['Channel'] ?? 'OFFICIAL';
return UserChannel.fromName(channelName);
}
// 根据地区获取服务器URL // 根据地区获取服务器URL
String get baseUrl { String get baseUrl {
final countryCode = Get.deviceLocale?.countryCode?.toUpperCase(); final countryCode = Get.deviceLocale?.countryCode?.toUpperCase();
final isChinaMainland = countryCode == 'CN'; final isChinaMainland = countryCode == 'CN';
final channel = dotenv.env['Channel'] ?? 'OFFICIAL'; final channel = _currentChannel;
if (!isChinaMainland || channel == "GLOBAL_GOOGLE") { if (!isChinaMainland || channel == UserChannel.GLOBAL_GOOGLE) {
return dotenv.env['HW_SERVER_URL'] ?? ''; return dotenv.env['HW_SERVER_URL'] ?? '';
} else { } else {
return dotenv.env['SERVER_URL'] ?? ''; return dotenv.env['SERVER_URL'] ?? '';
@ -33,17 +56,10 @@ class DioManager {
dio = Dio(options); dio = Dio(options);
// 日志拦截器必须在 AuthInterceptor 前面,
// 否则 AuthInterceptor 的 handler.resolve() 会跳过后续拦截器
dio.interceptors.add(_ApiLogInterceptor());
dio.interceptors.add(AuthInterceptor()); 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 // 请求,返回参数为 T
@ -65,3 +81,68 @@ class DioManager {
return response.data; return response.data;
} }
} }
/// API 请求/响应日志拦截器,写入文件日志
/// 必须放在 AuthInterceptor 之前,才能完整记录原始响应
class _ApiLogInterceptor extends Interceptor {
static const String _tag = 'API';
String _formatBody(dynamic data) {
if (data == null) return 'null';
try {
if (data is FormData) {
final fields = data.fields.map((e) => '${e.key}=${e.value}').join(', ');
final files = data.files.map((e) => '${e.key}=${e.value.filename}').join(', ');
return 'FormData{fields: [$fields], files: [$files]}';
}
if (data is Map || data is List) {
return const JsonEncoder().convert(data);
}
return data.toString();
} catch (_) {
return data.toString();
}
}
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final method = options.method;
final path = options.path;
final buf = StringBuffer();
buf.writeln('>>> $method $path');
if (options.queryParameters.isNotEmpty) {
buf.writeln(' Query: ${_formatBody(options.queryParameters)}');
}
if (options.data != null) {
buf.writeln(' Body: ${_formatBody(options.data)}');
}
Logger.i(_tag, buf.toString().trimRight());
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final method = response.requestOptions.method;
final path = response.requestOptions.path;
final statusCode = response.statusCode;
final buf = StringBuffer();
buf.writeln('<<< $method $path [$statusCode]');
buf.writeln(' Response: ${_formatBody(response.data)}');
Logger.i(_tag, buf.toString().trimRight());
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final method = err.requestOptions.method;
final path = err.requestOptions.path;
final statusCode = err.response?.statusCode ?? 'N/A';
final buf = StringBuffer();
buf.writeln('<<< $method $path [$statusCode] ${err.type}: ${err.message}');
if (err.response?.data != null) {
buf.writeln(' Error Body: ${_formatBody(err.response?.data)}');
}
Logger.e(_tag, buf.toString().trimRight());
handler.next(err);
}
}

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

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

3
lib/main.dart

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

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

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

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

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

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

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

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

@ -15,6 +15,7 @@ import '../../../routes/app_routes.dart';
import '../models/language_model.dart'; import '../models/language_model.dart';
import '../models/app_supported_languages.dart'; import '../models/app_supported_languages.dart';
import '../../../data/services/language_manager.dart'; import '../../../data/services/language_manager.dart';
import '../../../data/models/appconfig.dart';
import '../../../data/services/version_update_service.dart'; import '../../../data/services/version_update_service.dart';
import '../../../data/services/ble_manager.dart'; import '../../../data/services/ble_manager.dart';
import '../../common/widgets/version_update_dialog.dart'; import '../../common/widgets/version_update_dialog.dart';
@ -466,7 +467,7 @@ class SettingsController extends GetxController {
String getSomeAppInfo() { String getSomeAppInfo() {
try { try {
final versionService = Get.find<VersionUpdateService>(); final versionService = Get.find<VersionUpdateService>();
return ''' var info = '''
应用版本: ${versionService.cachedAppVersion} 应用版本: ${versionService.cachedAppVersion}
构建号: ${versionService.cachedBuildNumber} 构建号: ${versionService.cachedBuildNumber}
平台: ${versionService.cachedPlatform} 平台: ${versionService.cachedPlatform}
@ -482,6 +483,19 @@ class SettingsController extends GetxController {
语言编码: ${versionService.cachedLanguageCode} 语言编码: ${versionService.cachedLanguageCode}
服务器地址: ${DioManager().dio.options.baseUrl} 服务器地址: ${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) { } catch (e) {
return '获取版本服务数据失败: $e'; return '获取版本服务数据失败: $e';
} }

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

@ -607,6 +607,26 @@ class SettingsView extends GetView<SettingsController> {
) )
: const SizedBox.shrink()), : 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( // Divider(
// height: 1, // height: 1,
// color: isDarkMode // color: isDarkMode

2
lib/modules/translation/controllers/translation_controller.dart

@ -386,7 +386,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
if (mode == 'call') { if (mode == 'call') {
await _initializeCallModeTranslationService(); await _initializeCallModeTranslationService();
} else { } else {
_reinitializeAsrService(); await _reinitializeAsrService();
} }
} }

9
lib/routes/app_pages.dart

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

1
lib/routes/app_routes.dart

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

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

@ -65,6 +65,37 @@ interface AgentServiceListener {
object AgentService : CoroutineScope { object AgentService : CoroutineScope {
private val TAG = "AgentService" 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 相关常量 // SharedPreferences 相关常量
private const val PREF_NAME = "agent_service_prefs" private const val PREF_NAME = "agent_service_prefs"
private const val KEY_IS_INTERRUPT = "is_interrupt" private const val KEY_IS_INTERRUPT = "is_interrupt"
@ -220,7 +251,7 @@ object AgentService : CoroutineScope {
callingToolText = config["callingToolText"]?.toString() ?: ""; callingToolText = config["callingToolText"]?.toString() ?: "";
insufficientIntegralText = config["insufficientIntegralText"]?.toString() ?: ""; insufficientIntegralText = config["insufficientIntegralText"]?.toString() ?: "";
vipExptime = config["vipexptime"]?.toString()?.toIntOrNull() ?: 0; vipExptime = config["vipexptime"]?.toString()?.toIntOrNull() ?: 0;
Log.d(TAG, "iwei------ vipexptime ${vipExptime}") _log("INFO", "[Agent] vipExptime=$vipExptime")
// 初始化上下文和服务 // 初始化上下文和服务
this.context = appContext.applicationContext this.context = appContext.applicationContext
@ -278,7 +309,7 @@ object AgentService : CoroutineScope {
recognizeCallback() recognizeCallback()
} }
} else { } else {
Log.w(TAG, "Azure ASR初始化失败") _log("WARNING", "[Agent] Azure ASR初始化失败")
} }
} }
} }
@ -297,7 +328,7 @@ object AgentService : CoroutineScope {
config["musicServiceType"]?.toString() ?: "", config["musicServiceType"]?.toString() ?: "",
) )
Log.e(TAG, "liwei -----------读取服务地址和用户token: $serverurl $usertoken") _log("INFO", "[Agent] 读取服务配置: serverurl=$serverurl, tokenEmpty=${usertoken.isEmpty()}")
// 加载最近的聊天记录 // 加载最近的聊天记录
loadChatHistory() loadChatHistory()
@ -305,7 +336,7 @@ object AgentService : CoroutineScope {
_isInitialized.set(true) _isInitialized.set(true)
return true return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "初始化失败: ${e.message}") _log("ERROR", "[Agent] 初始化失败: ${e.message}")
return false return false
} }
} }
@ -331,7 +362,7 @@ object AgentService : CoroutineScope {
config["musicServiceType"]?.toString() ?: "" config["musicServiceType"]?.toString() ?: ""
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "应用配置更新失败 config: $config Exception:${e.message}") _log("ERROR", "[Agent] 应用配置更新失败: ${e.message}")
} }
} }
@ -366,7 +397,7 @@ object AgentService : CoroutineScope {
clearListeners() clearListeners()
_isInitialized.set(false) _isInitialized.set(false)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "释放资源异常: ${e.message}") _log("ERROR", "[Agent] 释放资源异常: ${e.message}")
} }
} }
@ -402,7 +433,7 @@ object AgentService : CoroutineScope {
) )
if (!success) { if (!success) {
Log.e(TAG, "Bytedance TTS服务初始化失败") _log("ERROR", "[Agent] TTS服务初始化失败")
} else { } else {
applyTtsSpeechParams() applyTtsSpeechParams()
val voiceName = getTtsVoiceNameByAsrCode(ttsLanguage) val voiceName = getTtsVoiceNameByAsrCode(ttsLanguage)
@ -503,7 +534,7 @@ object AgentService : CoroutineScope {
} }
}) })
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "初始化TTS引擎失败: ${e.message}") _log("ERROR", "[Agent] 初始化TTS引擎失败: ${e.message}")
} }
} }
@ -566,28 +597,26 @@ object AgentService : CoroutineScope {
idleCheckJob = launch { idleCheckJob = launch {
try { try {
delay(idleSeconds * 1000L) delay(idleSeconds * 1000L)
Log.d(TAG, "空闲检测触发自动停止,isActive: $isActive, isRecognitionActive: $isRecognitionActive, isTtsSpeaking: $isTtsSpeaking, isAiStreaming: $isAiStreaming, mode: $mode") Log.d(TAG, "空闲检测触发,isActive: $isActive, isRecognitionActive: $isRecognitionActive, isTtsSpeaking: $isTtsSpeaking, isAiStreaming: $isAiStreaming, mode: $mode")
if (isActive && isRecognitionActive && !isTtsSpeaking && !isAiStreaming) { if (isActive && isRecognitionActive) {
Log.d(TAG, "空闲检测触发自动停止,模式: $currentRecognitionMode") if (!isTtsSpeaking && !isAiStreaming) {
BleService.closeCodec() // 条件满足:TTS未播放且AI未流式输出,执行自动停止
if (currentRecognitionMode != "phone_call"){ Log.d(TAG, "空闲检测触发自动停止,模式: $currentRecognitionMode")
stopRecognition() BleService.closeCodec()
} if (currentRecognitionMode != "phone_call"){
sendEvent( stopRecognition()
"recognition_result", mapOf( }
"sessionid" to currsessionAsrId, sendEvent(
"text" to recognitionResult, "recognition_result", mapOf(
// "language" to detectedLanguage "sessionid" to currsessionAsrId,
"text" to recognitionResult,
)
) )
) } else {
// sendEvent( // 条件不满足:TTS或AI仍在工作中,重新调度计时器等待下一轮检查
// "auto_stop", Log.d(TAG, "空闲检测条件不满足(tts=$isTtsSpeaking, ai=$isAiStreaming),重新调度")
// mapOf( startIdleCheck(mode, idleSeconds)
// "reason" to "idle_timeout", }
// "seconds" to idleSeconds,
// "mode" to currentRecognitionMode
// )
// )
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
// 协程被取消,正常行为 // 协程被取消,正常行为
@ -649,10 +678,10 @@ object AgentService : CoroutineScope {
BleService.openEncoder()//不发指令。只是录制opus BleService.openEncoder()//不发指令。只是录制opus
} }
if (!isInitialized) { if (!isInitialized) {
Log.d(TAG, "服务未初始化") _log("WARNING", "[Agent] ASR服务未初始化")
return false return false
} }
Log.d("TAG", "开始语音识别startRecognition?") _log("INFO", "[Agent] 开始语音识别")
// 检查录音权限 // 检查录音权限
if (!isExternalActive && !checkRecordAudioPermission()) { if (!isExternalActive && !checkRecordAudioPermission()) {
@ -682,11 +711,11 @@ object AgentService : CoroutineScope {
startIdleCheckForMode(mode) startIdleCheckForMode(mode)
// 设置当前识别模式 // 设置当前识别模式
currentRecognitionMode = mode currentRecognitionMode = mode
Log.d(TAG, "设置语音识别模式: $mode") _log("INFO", "[Agent] 设置语音识别模式: $mode")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
_isRecognitionActive.set(false) _isRecognitionActive.set(false)
Log.e(TAG, "启动语音识别失败: ${e.message}") _log("ERROR", "[Agent] 启动语音识别失败: ${e.message}")
sendEvent( sendEvent(
"error", mapOf( "error", mapOf(
"code" to "RECOGNITION_START_ERROR", "code" to "RECOGNITION_START_ERROR",
@ -744,16 +773,16 @@ object AgentService : CoroutineScope {
return return
} }
// 执行打断 // 执行打断:同时停止TTS和AI流,避免isAiStreaming残留导致idle check失效
lastInterruptTime = currentTime lastInterruptTime = currentTime
stopTts() stopTts()
// interruptCurrentResponse() stopAiStream()
} }
} }
} }
override fun onResult(sessionid:String,result: String, detectedLanguage: String) { 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 (result.isNotEmpty()&&isKeepResult==true) {//加入是否结束要输出最后的结果判读
// 保存识别结果 // 保存识别结果
if (currsessionAsrId.isEmpty()) { if (currsessionAsrId.isEmpty()) {
@ -806,7 +835,7 @@ object AgentService : CoroutineScope {
} }
else else
{ {
Log.d(TAG, "liwei-------- onResult: $sessionid $result $currentRecognitionMode 空字符串") _log("WARNING", "[Agent] ASR onResult 空字符串")
} }
} }
@ -816,7 +845,7 @@ object AgentService : CoroutineScope {
} }
override fun onSessionStarted(sessionid:String) { 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")) sendEvent("recognition_started", mapOf("sessionid" to sessionid, "status" to "started"))
// 使用模式感知的空闲检测启动方法 // 使用模式感知的空闲检测启动方法
recognitionResult = "" recognitionResult = ""
@ -829,7 +858,7 @@ object AgentService : CoroutineScope {
override fun onSessionStopped(sessionid:String) { 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 (currentRecognitionMode == "push_to_talk") { //按住说话
if (recognitionResult.isEmpty()){ if (recognitionResult.isEmpty()){
@ -859,7 +888,7 @@ object AgentService : CoroutineScope {
} }
override fun onCanceled(sessionid: String,reason: String, errorDetails: String) { 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) _isRecognitionActive.set(false)
stopIdleCheck() stopIdleCheck()
BleService.closeCodec() BleService.closeCodec()
@ -873,7 +902,7 @@ object AgentService : CoroutineScope {
} }
override fun onError(sessionid:String, code: Int, error: String) { 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) _isRecognitionActive.set(false)
stopIdleCheck() stopIdleCheck()
BleService.closeCodec() BleService.closeCodec()
@ -904,7 +933,7 @@ object AgentService : CoroutineScope {
fun stopRecognition() { fun stopRecognition() {
if (!isRecognitionActive) return if (!isRecognitionActive) return
Log.d(TAG, "停止语音识别,当前模式: $currentRecognitionMode") _log("INFO", "[Agent] 停止语音识别,模式: $currentRecognitionMode")
try { try {
_isRecognitionActive.set(false) _isRecognitionActive.set(false)
@ -914,7 +943,7 @@ object AgentService : CoroutineScope {
// currentRecognitionMode = "normal" // currentRecognitionMode = "normal"
return return
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "停止语音识别异常: ${e.message}") _log("ERROR", "[Agent] 停止语音识别异常: ${e.message}")
_isRecognitionActive.set(false) _isRecognitionActive.set(false)
stopIdleCheck() stopIdleCheck()
// 重置识别模式 // 重置识别模式
@ -971,11 +1000,15 @@ object AgentService : CoroutineScope {
//结束等待音 //结束等待音
audioPlayer?.stopAudio() audioPlayer?.stopAudio()
// AI流结束是重要的状态变化,重启idle检测避免识别状态卡住
restartIdleCheck()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "停止AI流输出异常", e) _log("ERROR", "[Agent] 停止AI流异常: ${e.message}")
// 确保状态被重置,即使发生异常 // 确保状态被重置,即使发生异常
_isAiStreaming.set(false) _isAiStreaming.set(false)
currentAiJob = null currentAiJob = null
restartIdleCheck()
} }
} }
} }
@ -986,13 +1019,13 @@ object AgentService : CoroutineScope {
*/ */
fun processTextInput(sessionid: String,text: String, speakResponse: Boolean = false): Boolean { fun processTextInput(sessionid: String,text: String, speakResponse: Boolean = false): Boolean {
if (!isInitialized) { if (!isInitialized) {
Log.e(TAG, "服务未初始化") _log("WARNING", "[Agent] AI服务未初始化")
sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化")) sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化"))
return false return false
} }
if (text.isEmpty()) { if (text.isEmpty()) {
Log.e(TAG, "文本输入不能为空") _log("WARNING", "[Agent] 文本输入为空")
sendEvent("error", mapOf("code" to "EMPTY_TEXT", "message" to "文本输入不能为空")) sendEvent("error", mapOf("code" to "EMPTY_TEXT", "message" to "文本输入不能为空"))
return false return false
} }
@ -1019,7 +1052,7 @@ object AgentService : CoroutineScope {
text: String, text: String,
speakResponse: Boolean = true speakResponse: Boolean = true
) { ) {
Log.d(TAG, "用户问题: $text") _log("INFO", "[Agent] AI 用户问题: $text")
// 创建用户文本消息并处理 // 创建用户文本消息并处理
val userMessage = createUserMessage(text) val userMessage = createUserMessage(text)
@ -1075,7 +1108,7 @@ object AgentService : CoroutineScope {
try { try {
// 设置状态为正在流式输出 // 设置状态为正在流式输出
_isAiStreaming.set(true) _isAiStreaming.set(true)
Log.d(TAG, "processWithChatApiInternal") _log("INFO", "[Agent] AI 开始处理")
audioPlayer?.playAudio(R.raw.await, true, 0.3f,true) audioPlayer?.playAudio(R.raw.await, true, 0.3f,true)
// 使用历史记录作为上下文发送到OpenAI // 使用历史记录作为上下文发送到OpenAI
val responseBuilder = StringBuilder() val responseBuilder = StringBuilder()
@ -1099,8 +1132,15 @@ object AgentService : CoroutineScope {
if (systemPrompt.isNotEmpty()) { if (systemPrompt.isNotEmpty()) {
val mapdata = UserPortrait.getAll().toMutableMap() val mapdata = UserPortrait.getAll().toMutableMap()
mapdata["language"] = language mapdata["language"] = language
// 添加当前时间(格式化字符串) // 添加当前时间(TTS友好的自然语言格式)
val currentTimeFormatted = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) val lang = Locale.getDefault().language
val now = Date()
val currentTimeFormatted = when (lang) {
"zh" -> SimpleDateFormat("yyyy年M月d日 H时mm分ss秒", java.util.Locale.CHINESE).format(now)
"ja" -> SimpleDateFormat("yyyy年M月d日 H時mm分ss秒", java.util.Locale.JAPANESE).format(now)
"ko" -> SimpleDateFormat("yyyy년 M월 d일 H시 mm분 ss초", java.util.Locale.KOREAN).format(now)
else -> SimpleDateFormat("MMMM d, yyyy, h:mm:ss a", Locale.getDefault()).format(now)
}
mapdata["current_time"] = currentTimeFormatted mapdata["current_time"] = currentTimeFormatted
// Log.d(TAG, "liwei--------- 用户画像: ${mapdata}") // Log.d(TAG, "liwei--------- 用户画像: ${mapdata}")
val _systemPrompt = Utils.fillTemplate(systemPrompt, mapdata) val _systemPrompt = Utils.fillTemplate(systemPrompt, mapdata)
@ -1165,7 +1205,7 @@ object AgentService : CoroutineScope {
} }
// sendStatisticsEvent(actions) // sendStatisticsEvent(actions)
}catch (e: Exception) { }catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onUsage 异常", e) _log("ERROR", "[Agent] AI onUsage异常: ${e.message}")
} }
} }
@ -1184,7 +1224,7 @@ object AgentService : CoroutineScope {
// audioPlayer?.stopAudio() // audioPlayer?.stopAudio()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onToken 异常", e) _log("ERROR", "[Agent] AI onToken异常: ${e.message}")
} }
} }
@ -1210,7 +1250,7 @@ object AgentService : CoroutineScope {
if (reply) { if (reply) {
sendEvent("assistant_response", responseData) sendEvent("assistant_response", responseData)
} }
Log.d(TAG, "liwei--------------- AI Call onComplete:$response") _log("INFO", "[Agent] AI onComplete: $response")
if (response.isNotEmpty()) { if (response.isNotEmpty()) {
// 添加AI回复到历史记录 // 添加AI回复到历史记录
addToHistoryMessages(createAssistantMessage(response)) addToHistoryMessages(createAssistantMessage(response))
@ -1227,7 +1267,7 @@ object AgentService : CoroutineScope {
_isAiStreaming.set(false) _isAiStreaming.set(false)
currentAiJob = null currentAiJob = null
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onComplete 异常", e) _log("ERROR", "[Agent] AI onComplete异常: ${e.message}")
} }
} }
@ -1235,7 +1275,7 @@ object AgentService : CoroutineScope {
try { try {
if (sessionid == currsessionId) { if (sessionid == currsessionId) {
audioPlayer?.stopAudio() audioPlayer?.stopAudio()
Log.e(TAG, "AI处理出错 $message") _log("ERROR", "[Agent] AI error: code=$code $message")
// 优先使用 ChatApiException.code;否则根据底层异常类型推断错误码 // 优先使用 ChatApiException.code;否则根据底层异常类型推断错误码
sendEvent( sendEvent(
"error", mapOf( "error", mapOf(
@ -1253,7 +1293,7 @@ object AgentService : CoroutineScope {
currentAiJob = null currentAiJob = null
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onError 异常", e) _log("ERROR", "[Agent] AI onError异常: ${e.message}")
} }
} }
@ -1275,13 +1315,13 @@ object AgentService : CoroutineScope {
}else if (name == "qqmusic_pause" || name == "qqmusic_stop" || name == "music_pause" || name == "music_close"){ }else if (name == "qqmusic_pause" || name == "qqmusic_stop" || name == "music_pause" || name == "music_close"){
isplaymusic = false isplaymusic = false
} }
Log.d(TAG, "liwei--------------- AI Call onFunctionCall iscallingTool:$iscallingTool callingToolText:$callingToolText") _log("INFO", "[Agent] AI onFunctionCall: callingTool=$iscallingTool")
if (iscallingTool) { if (iscallingTool) {
ttsService?.speakStream(callingToolText) ttsService?.speakStream(callingToolText)
iscallingTool = false iscallingTool = false
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "liwei--------------- AI Call onFunctionCall 异常", e) _log("ERROR", "[Agent] AI onFunctionCall异常: ${e.message}")
} }
} }
@ -1291,9 +1331,7 @@ object AgentService : CoroutineScope {
functionCallResult: JSONObject functionCallResult: JSONObject
) { ) {
try { try {
Log.d( _log("INFO", "[Agent] AI onFunctionCallResult")
TAG,
"liwei--------------- AI Call onFunctionCallResult ${sessionid}:${functionCallResult.toString()}",)
audioPlayer?.stopAudio() audioPlayer?.stopAudio()
val name = functionCall.get("name") as String; val name = functionCall.get("name") as String;
val resultmap = autoHandleFcunCallResult(name, functionCallResult); val resultmap = autoHandleFcunCallResult(name, functionCallResult);
@ -1322,11 +1360,7 @@ object AgentService : CoroutineScope {
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e( _log("ERROR", "[Agent] AI onFunctionCallResult异常: ${e.message}")
TAG,
"liwei--------------- AI Call onFunctionCallResult 异常",
e
)
} }
} }
} }
@ -1334,7 +1368,7 @@ object AgentService : CoroutineScope {
} catch (e: Exception) { } catch (e: Exception) {
val errorType = if (hasImage) "AI_IMAGE_PROCESS_ERROR" else "AI_PROCESS_ERROR" 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( sendEvent(
"error", mapOf( "error", mapOf(
"sessionid" to sessionid, "sessionid" to sessionid,
@ -1380,7 +1414,7 @@ object AgentService : CoroutineScope {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "加载聊天历史失败: ${e.message}") _log("ERROR", "[Agent] 加载聊天历史失败: ${e.message}")
} }
} }
@ -1434,7 +1468,7 @@ object AgentService : CoroutineScope {
*/ */
fun setSupportedLanguages(languages: List<String>): Boolean { fun setSupportedLanguages(languages: List<String>): Boolean {
if (!isInitialized) { if (!isInitialized) {
Log.e(TAG, "服务未初始化") _log("WARNING", "[Agent] 语言设置-服务未初始化")
sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化")) sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化"))
return false return false
} }
@ -1446,7 +1480,7 @@ object AgentService : CoroutineScope {
} }
language = languages[0] language = languages[0]
return try { return try {
Log.i(TAG, "设置支持的语言: ${languages.joinToString(", ")}") _log("INFO", "[Agent] 设置语言: ${languages.joinToString(", ")}")
val asrSuccess = azureAsrHelper?.initialize( val asrSuccess = azureAsrHelper?.initialize(
subscriptionKey = this.azureSpeechKey, subscriptionKey = this.azureSpeechKey,
region = this.azureSpeechRegion, region = this.azureSpeechRegion,
@ -1459,7 +1493,7 @@ object AgentService : CoroutineScope {
if (asrSuccess) { if (asrSuccess) {
recognizeCallback() recognizeCallback()
} else { } else {
Log.w(TAG, "Azure ASR初始化失败") _log("WARNING", "[Agent] 语言更新-ASR初始化失败")
} }
// 同时更新TTS语言设置 // 同时更新TTS语言设置
val ttsSuccess = updateTtsLanguage(languages.first()) // 使用第一个语言作为TTS语言 val ttsSuccess = updateTtsLanguage(languages.first()) // 使用第一个语言作为TTS语言
@ -1467,7 +1501,7 @@ object AgentService : CoroutineScope {
val overallSuccess = asrSuccess && ttsSuccess val overallSuccess = asrSuccess && ttsSuccess
if (overallSuccess) { if (overallSuccess) {
Log.i(TAG, "ASR和TTS语言设置更新成功") _log("INFO", "[Agent] ASR和TTS语言设置更新成功")
sendEvent("languages_updated", mapOf( sendEvent("languages_updated", mapOf(
"languages" to languages, "languages" to languages,
"status" to "success", "status" to "success",
@ -1475,7 +1509,7 @@ object AgentService : CoroutineScope {
"tts_success" to ttsSuccess "tts_success" to ttsSuccess
)) ))
} else { } else {
Log.e(TAG, "语言设置更新失败 - ASR: $asrSuccess, TTS: $ttsSuccess") _log("ERROR", "[Agent] 语言设置更新失败 ASR:$asrSuccess TTS:$ttsSuccess")
sendEvent("error", mapOf( sendEvent("error", mapOf(
"code" to "LANGUAGE_UPDATE_FAILED", "code" to "LANGUAGE_UPDATE_FAILED",
"message" to "语言设置更新失败 - ASR: $asrSuccess, TTS: $ttsSuccess" "message" to "语言设置更新失败 - ASR: $asrSuccess, TTS: $ttsSuccess"
@ -1484,7 +1518,7 @@ object AgentService : CoroutineScope {
overallSuccess overallSuccess
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "设置支持语言异常: ${e.message}") _log("ERROR", "[Agent] 设置语言异常: ${e.message}")
sendEvent("error", mapOf( sendEvent("error", mapOf(
"code" to "LANGUAGE_UPDATE_ERROR", "code" to "LANGUAGE_UPDATE_ERROR",
"message" to e.message.toString() "message" to e.message.toString()
@ -1502,7 +1536,7 @@ object AgentService : CoroutineScope {
*/ */
private fun updateTtsLanguage(languageCode: String): Boolean { private fun updateTtsLanguage(languageCode: String): Boolean {
return try { return try {
Log.i(TAG, "更新TTS语言设置: $languageCode") _log("INFO", "[Agent] 更新TTS语言: $languageCode")
// 根据ASR语言代码映射到对应的TTS语音角色 // 根据ASR语言代码映射到对应的TTS语音角色
val voiceName = getTtsVoiceNameByAsrCode(languageCode) val voiceName = getTtsVoiceNameByAsrCode(languageCode)
@ -1512,9 +1546,9 @@ object AgentService : CoroutineScope {
val success = ttsService?.setVoice(voiceName) ?: false val success = ttsService?.setVoice(voiceName) ?: false
if (success) { if (success) {
Log.i(TAG, "TTS语音角色设置成功: $voiceName") _log("INFO", "[Agent] TTS语音角色设置成功: $voiceName")
} else { } else {
Log.w(TAG, "TTS语音角色设置失败: $voiceName") _log("WARNING", "[Agent] TTS语音角色设置失败: $voiceName")
} }
success success
@ -1649,10 +1683,10 @@ object AgentService : CoroutineScope {
// Log.e(TAG, "保存助手消息失败") // Log.e(TAG, "保存助手消息失败")
// } // }
} else { } else {
Log.e(TAG, "保存用户消息失败") _log("ERROR", "[Agent] 保存用户消息失败")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "保存聊天记录异常", e) _log("ERROR", "[Agent] 保存聊天记录异常: ${e.message}")
} }
} }
} }
@ -1672,7 +1706,7 @@ object AgentService : CoroutineScope {
} }
} }
} else { } else {
Log.e(TAG, "清除聊天历史失败") _log("ERROR", "[Agent] 清除聊天历史失败")
} }
// 由于launch已在Main上下文中,可以直接调用回调 // 由于launch已在Main上下文中,可以直接调用回调
@ -1702,7 +1736,7 @@ object AgentService : CoroutineScope {
try { try {
listener.onEvent(eventName, finalData) listener.onEvent(eventName, finalData)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "向监听器发送事件失败", e) _log("ERROR", "[Agent] 发送事件失败: ${e.message}")
} }
} }
} }
@ -2675,18 +2709,18 @@ object AgentService : CoroutineScope {
fun sendStatisticsEvent(actions: Map<String, Int>) { fun sendStatisticsEvent(actions: Map<String, Int>) {
// 参数验证 // 参数验证
if (actions.isEmpty()) { if (actions.isEmpty()) {
Log.w(TAG, "统计事件数据为空,跳过发送") _log("WARNING", "[Agent] 统计事件数据为空,跳过发送")
return return
} }
// 检查服务器配置 // 检查服务器配置
if (serverurl.isEmpty()) { if (serverurl.isEmpty()) {
Log.w(TAG, "服务器地址未配置,跳过统计事件发送") _log("WARNING", "[Agent] 服务器地址未配置,跳过统计事件发送")
return return
} }
if (usertoken.isEmpty()) { if (usertoken.isEmpty()) {
Log.w(TAG, "用户令牌未配置,跳过统计事件发送") _log("WARNING", "[Agent] 用户令牌未配置,跳过统计事件发送")
return return
} }
@ -2712,7 +2746,7 @@ object AgentService : CoroutineScope {
jsonObject.put("usages", actionsObject) jsonObject.put("usages", actionsObject)
val json = jsonObject.toString() val json = jsonObject.toString()
Log.d(TAG, "统计事件发送成功,请求: $json") _log("INFO", "[Agent] 统计请求发送")
val mediaType = "application/json".toMediaType() val mediaType = "application/json".toMediaType()
val body = json.toRequestBody(mediaType) val body = json.toRequestBody(mediaType)
@ -2736,7 +2770,7 @@ object AgentService : CoroutineScope {
// 仅读取一次响应体,避免重复读取导致异常,同时统一后续处理使用 // 仅读取一次响应体,避免重复读取导致异常,同时统一后续处理使用
val bodyStr = response.body?.string() ?: "" val bodyStr = response.body?.string() ?: ""
if (response.isSuccessful) { if (response.isSuccessful) {
Log.d(TAG, "统计事件发送成功,响应: $bodyStr") _log("INFO", "[Agent] 统计响应: $bodyStr")
try { try {
val json = JSONObject(bodyStr) val json = JSONObject(bodyStr)
val code = json.optInt("code", -1) val code = json.optInt("code", -1)
@ -2770,7 +2804,7 @@ object AgentService : CoroutineScope {
Log.w(TAG, "解析统计响应失败: ${e.message}") Log.w(TAG, "解析统计响应失败: ${e.message}")
} }
} else { } else {
Log.w(TAG, "统计事件发送失败,HTTP状态码: ${response.code}") _log("WARNING", "[Agent] 统计HTTP失败: ${response.code}")
} }
} }
} }
@ -2778,7 +2812,7 @@ object AgentService : CoroutineScope {
} catch (e: Exception) { } catch (e: Exception) {
// 捕获所有异常,防止崩溃 // 捕获所有异常,防止崩溃
Log.e(TAG, "发送统计事件时发生异常: ${e.message}", e) _log("ERROR", "[Agent] 统计异常: ${e.message}")
} }
} }
} }

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

@ -40,6 +40,20 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
// 添加监听器 // 添加监听器
AgentService.addListener(this) 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, "插件已加载") Log.d(TAG, "插件已加载")
} }

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

@ -25,6 +25,41 @@ extension Notification.Name {
class AgentServiceImpl: NSObject { class AgentServiceImpl: NSObject {
private let TAG = "AgentServiceImpl" private let TAG = "AgentServiceImpl"
/// 日志回调:(level, message) -> Void,由 Plugin 层设置,用于将原生日志回传 Dart
var logCallback: ((String, String) -> Void)?
private let osLog = OSLog(subsystem: "com.agent.service", category: "Agent")
private let logChunkSize = 800
internal func _log(_ level: String, _ message: String) {
if message.count <= logChunkSize {
_logSingle(level, message)
} else {
let totalParts = (message.count + logChunkSize - 1) / logChunkSize
for i in 0..<totalParts {
let start = message.index(message.startIndex, offsetBy: i * logChunkSize)
let end = message.index(start, offsetBy: min(logChunkSize, message.distance(from: start, to: message.endIndex)))
let chunk = String(message[start..<end])
_logSingle(level, "[\(i + 1)/\(totalParts)] \(chunk)")
}
}
}
private func _logSingle(_ level: String, _ message: String) {
switch level {
case "ERROR":
os_log("%{public}@", log: osLog, type: .error, message)
case "WARNING":
os_log("%{public}@", log: osLog, type: .default, message)
case "DEBUG":
os_log("%{public}@", log: osLog, type: .debug, message)
default:
os_log("%{public}@", log: osLog, type: .info, message)
}
logCallback?(level, message)
}
static let shared = AgentServiceImpl() static let shared = AgentServiceImpl()
// UserDefaults 相关常量 // UserDefaults 相关常量
@ -208,7 +243,7 @@ class AgentServiceImpl: NSObject {
self.vipexptime = vipexptime self.vipexptime = vipexptime
} }
//打印日志输出vipexptime //打印日志输出vipexptime
os_log("Vip 过期时间 %d", log: logger, type: .info, vipexptime) _log("INFO", "[Agent] vipexptime=\(vipexptime)")
if let insufficientIntegralText = config["insufficientIntegralText"] as? String { if let insufficientIntegralText = config["insufficientIntegralText"] as? String {
self.insufficientIntegralText = insufficientIntegralText self.insufficientIntegralText = insufficientIntegralText
@ -361,17 +396,15 @@ class AgentServiceImpl: NSObject {
} }
private func initializeChatApiService() -> Bool { private func initializeChatApiService() -> Bool {
os_log("开始初始化ChatAPI服务", log: logger, type: .info) _log("INFO", "[Agent] ChatAPI service init start")
guard let chatApiService = chatApiService else { 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") sendError("ChatAPI服务未创建", code: "CHATAPI_INIT_ERROR")
return false return false
} }
os_log("ChatAPI初始化参数: apiKey=%{public}@, baseUrl=%{public}@, model=%{public}@, mcpServer=%{public}@", _log("INFO", "[Agent] ChatAPI init params: apiKey=\(apiKey.isEmpty ? "empty" : "set"), baseUrl=\(baseUrl), model=\(model)")
log: logger, type: .info,
apiKey.isEmpty ? "空" : "已设置", baseUrl, model, mcpServer)
let success = chatApiService.initialize( let success = chatApiService.initialize(
apiKey: apiKey, apiKey: apiKey,
@ -382,7 +415,7 @@ class AgentServiceImpl: NSObject {
musicServiceType:musicServiceType musicServiceType:musicServiceType
) )
os_log("ChatAPI服务初始化结果: %{public}@", log: logger, type: .info, success ? "成功" : "失败") _log("INFO", "[Agent] ChatAPI service init result: \(success ? "success" : "failed")")
if !success { if !success {
sendError("初始化ChatAPI服务失败", code: "CHATAPI_INIT_ERROR") sendError("初始化ChatAPI服务失败", code: "CHATAPI_INIT_ERROR")
@ -392,7 +425,7 @@ class AgentServiceImpl: NSObject {
} }
private func checkAdnSetAudioSession() { private func checkAdnSetAudioSession() {
os_log("liwei------ 检查并设置音频会话", log: logger, type: .info) _log("INFO", "[Agent] checkAndSetAudioSession")
let audioSourceType: AzureAsrHelper.AudioSourceType = BleService.shared.isConnected() ? .external : .microphone let audioSourceType: AzureAsrHelper.AudioSourceType = BleService.shared.isConnected() ? .external : .microphone
azureAsrHelper?.performAudioStart(mod: currentRecognitionMode, audioSourceType: audioSourceType, audioDataCallback: nil) azureAsrHelper?.performAudioStart(mod: currentRecognitionMode, audioSourceType: audioSourceType, audioDataCallback: nil)
} }
@ -405,7 +438,7 @@ class AgentServiceImpl: NSObject {
switch mode { switch mode {
case "phone_call": case "phone_call":
// 通话模式:禁用空闲检测,保持持续激活 // 通话模式:禁用空闲检测,保持持续激活
os_log("通话模式:禁用空闲检测", log: logger, type: .info) _log("INFO", "[Agent] phone_call mode: idle check disabled")
return return
case "ble_wakeup": case "ble_wakeup":
if !isSpeaking { if !isSpeaking {
@ -430,7 +463,7 @@ class AgentServiceImpl: NSObject {
azureAsrHelper?.disableBluetoothAudio() azureAsrHelper?.disableBluetoothAudio()
} }
// 未知模式:使用默认的空闲检测 // 未知模式:使用默认的空闲检测
os_log("未知识别模式: %{public}@,使用默认空闲检测", log: logger, type: .error, mode) _log("ERROR", "[Agent] unknown recognition mode: \(mode), using default idle check")
startIdleCheck(idleSeconds: maxIdleSeconds) startIdleCheck(idleSeconds: maxIdleSeconds)
} }
} }
@ -442,32 +475,31 @@ class AgentServiceImpl: NSObject {
self.idleTimer = nil self.idleTimer = nil
} }
guard self.isRecognizing else { return } guard self.isRecognizing else { return }
os_log("liwei--------启动空闲检测,超时时间: %.0f秒,模式: %{public}@", self._log("INFO", "[Agent] idle check started, timeout: \(Int(idleSeconds))s, mode: \(self.currentRecognitionMode)")
log: self.logger,
type: .info,
idleSeconds,
self.currentRecognitionMode)
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility)) let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
timer.schedule(deadline: .now() + idleSeconds) timer.schedule(deadline: .now() + idleSeconds)
timer.setEventHandler { [weak self] in timer.setEventHandler { [weak self] in
guard let self = self else { return } guard let self = self else { return }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
os_log("liwei--------空闲检测触发自动停止,模式: %{public}@", self._log("INFO", "[Agent] idle check triggered, mode: \(self.currentRecognitionMode), speaking: \(self.isSpeaking), aiStreaming: \(self.isAiStreaming)")
log: self.logger, if self.isRecognizing {
type: .info, if !self.isSpeaking && !self.isAiStreaming {
self.currentRecognitionMode) // 条件满足:TTS未播放且AI未流式输出,执行自动停止
//打电话模式下面 不能关闭识别器 BleService.shared.closeCodec()
if self.isRecognizing && !self.isSpeaking && !self.isAiStreaming{ if (self.currentRecognitionMode != "phone_call"){
BleService.shared.closeCodec() self.stopRecognition()
if (currentRecognitionMode != "phone_call"){ }
self.stopRecognition() self.sendEvent(name: "auto_stop", data: [
"reason": "idle_timeout",
"seconds": idleSeconds,
"mode": self.currentRecognitionMode
])
} else {
// 条件不满足:TTS或AI仍在工作中,重新调度计时器等待下一轮检查
self._log("INFO", "[Agent] idle check conditions not met (speaking=\(self.isSpeaking), ai=\(self.isAiStreaming)), rescheduling")
self.startIdleCheck(idleSeconds: idleSeconds)
} }
self.sendEvent(name: "auto_stop", data: [
"reason": "idle_timeout",
"seconds": idleSeconds,
"mode": self.currentRecognitionMode
])
} }
} }
} }
@ -496,13 +528,13 @@ class AgentServiceImpl: NSObject {
} }
if useBle, mode == "ble_wakeup", currentRecognitionMode == "ble_wakeup", (isRecognizing || isStartingRecognition) { 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() audioPlayer?.playStartSound()
return true return true
} }
// 接收到唤醒信号,打开编码器 (设备侧) // 接收到唤醒信号,打开编码器 (设备侧)
print("ai启动语音\(useBle))") _log("INFO", "[Agent] startRecognition useBle=\(useBle)")
var openResult = false // 添加openResult变量定义 var openResult = false // 添加openResult变量定义
if useBle{ if useBle{
isKeepResult = false isKeepResult = false
@ -526,7 +558,7 @@ class AgentServiceImpl: NSObject {
stopTts() stopTts()
os_log("🎙️ 尝试打开编码器结果: %@", log: logger, type: .info, openResult ? "成功" : "失败") _log("INFO", "[Agent] openEncoder result: \(openResult ? "success" : "failed")")
wasMusicPlayingBeforeRecognition = MusicService.shared.isPlaying wasMusicPlayingBeforeRecognition = MusicService.shared.isPlaying
// 如果音乐正在播放,则暂停 // 如果音乐正在播放,则暂停
@ -542,7 +574,7 @@ class AgentServiceImpl: NSObject {
// 新增:标记为"启动中",用于允许 stop 在启动未完成时也能生效 // 新增:标记为"启动中",用于允许 stop 在启动未完成时也能生效
isStartingRecognition = true isStartingRecognition = true
print("ai启动语音=\(audioSourceType)=\(useBle)") _log("INFO", "[Agent] startRecognition audioSource=\(audioSourceType), useBle=\(useBle)")
guard let success = azureAsrHelper?.startContinuousRecognition( guard let success = azureAsrHelper?.startContinuousRecognition(
mod:self.currentRecognitionMode, mod:self.currentRecognitionMode,
audioSourceType: audioSourceType, audioSourceType: audioSourceType,
@ -551,20 +583,20 @@ class AgentServiceImpl: NSObject {
// 新增:启动失败时复位"启动中"状态 // 新增:启动失败时复位"启动中"状态
isStartingRecognition = false isStartingRecognition = false
sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR") sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR")
os_log("liwei--------------- 启动语音识别失败: %{public}@", log: logger, type: .info, mode) _log("ERROR", "[Agent] startRecognition failed, mode: \(mode)")
return false return false
} }
if(useBle){ if(useBle){
isKeepResult = true isKeepResult = true
} }
os_log("liwei--------------- 启动语音识别: %{public}@", log: logger, type: .info, mode) _log("INFO", "[Agent] startRecognition success, mode: \(mode)")
// 根据模式启动相应的空闲检测(此处 guard isRecognizing,会在 onSessionStarted 中再次启动) // 根据模式启动相应的空闲检测(此处 guard isRecognizing,会在 onSessionStarted 中再次启动)
startIdleCheckForMode(mode: mode) startIdleCheckForMode(mode: mode)
return true return true
} }
func recognizeCallback() -> Bool { func recognizeCallback() -> Bool {
print("重新设置识别回调=") _log("INFO", "[Agent] recognizeCallback re-setup")
guard let asrsetupSuccess = azureAsrHelper?.setupEventListeners( guard let asrsetupSuccess = azureAsrHelper?.setupEventListeners(
callback: self callback: self
), asrsetupSuccess else { ), asrsetupSuccess else {
@ -579,7 +611,7 @@ class AgentServiceImpl: NSObject {
/// 特性: /// 特性:
/// 1) 即便处于"启动中"(isStartingRecognition)也会打印日志并尝试停止 /// 1) 即便处于"启动中"(isStartingRecognition)也会打印日志并尝试停止
/// 2) 如果底层尚未完全进入可停止状态,记录一次待停止请求,待会话启动后立即停止 /// 2) 如果底层尚未完全进入可停止状态,记录一次待停止请求,待会话启动后立即停止
print("停止识别语音,isRecognizing=\(isRecognizing),isStartingRecognition=\(isStartingRecognition)") _log("INFO", "[Agent] stopRecognition, isRecognizing=\(isRecognizing), isStartingRecognition=\(isStartingRecognition)")
if !(isRecognizing || isStartingRecognition) { if !(isRecognizing || isStartingRecognition) {
// 既不在识别中,也不在启动中,视为幂等处理 // 既不在识别中,也不在启动中,视为幂等处理
return true return true
@ -587,13 +619,13 @@ class AgentServiceImpl: NSObject {
// 关键修复:如果正在结束的是通话模式,则立即将会话重置为 normal 状态 // 关键修复:如果正在结束的是通话模式,则立即将会话重置为 normal 状态
if self.currentRecognitionMode == "phone_call" { 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" self.currentRecognitionMode = "normal"
// 使用 normal 模式的配置来清理和重置音频会话 // 使用 normal 模式的配置来清理和重置音频会话
self.checkAdnSetAudioSession() self.checkAdnSetAudioSession()
} }
os_log("停止语音识别,当前模式: %{public}@", log: logger, type: .info, currentRecognitionMode) _log("INFO", "[Agent] stopRecognition, currentMode: \(currentRecognitionMode)")
let success = azureAsrHelper?.stopContinuousRecognition() ?? false let success = azureAsrHelper?.stopContinuousRecognition() ?? false
if success { if success {
@ -601,9 +633,9 @@ class AgentServiceImpl: NSObject {
// let audioSession = AVAudioSession.sharedInstance() // let audioSession = AVAudioSession.sharedInstance()
// try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .allowBluetoothA2DP]) // try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .allowBluetoothA2DP])
// try audioSession.setActive(true) // 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 { } 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 { } else if isStartingRecognition {
// 启动尚未完成,先记录一次待停止请求,onSessionStarted 到来后立即 stop // 启动尚未完成,先记录一次待停止请求,onSessionStarted 到来后立即 stop
@ -689,6 +721,8 @@ class AgentServiceImpl: NSObject {
isAiStreaming = false isAiStreaming = false
_ = chatApiService?.cancelCurrentStream() _ = chatApiService?.cancelCurrentStream()
interrupted = true interrupted = true
// AI流结束是重要的状态变化,重启idle检测避免识别状态卡住
restartIdleCheck()
} }
if interrupted { if interrupted {
@ -699,16 +733,16 @@ class AgentServiceImpl: NSObject {
} }
func processTextInput(sessionid:String, _ text: String, speakResponse: Bool) -> Bool { 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 { if !isInitialized {
os_log("服务未初始化,无法处理文本输入", log: logger, type: .error) _log("ERROR", "[Agent] processTextInput: service not initialized")
sendError("服务未初始化", code: "NOT_INITIALIZED") sendError("服务未初始化", code: "NOT_INITIALIZED")
return false return false
} }
if text.isEmpty { if text.isEmpty {
os_log("文本输入为空", log: logger, type: .error) _log("ERROR", "[Agent] processTextInput: empty text")
sendError("文本输入不能为空", code: "EMPTY_TEXT") sendError("文本输入不能为空", code: "EMPTY_TEXT")
return false return false
} }
@ -735,17 +769,16 @@ class AgentServiceImpl: NSObject {
} }
private func processWithChatApiServiceInternal(sessionid:String,userMessage: [String: Any], displayText: String, speakResponse: Bool = true, hasImage: Bool = false,imagePath:String = "") { 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("INFO", "[Agent] ChatAPI request start: text=\(displayText), speak=\(speakResponse), hasImage=\(hasImage)")
log: logger, type: .info, displayText, speakResponse ? "是" : "否", hasImage ? "是" : "否")
guard let chatApiService = chatApiService else { 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") sendError("ChatAPI服务未初始化", code: "CHATAPI_NOT_INITIALIZED")
return return
} }
if isAiStreaming { if isAiStreaming {
os_log("中断当前AI流式响应", log: logger, type: .info) _log("INFO", "[Agent] interrupting current AI stream response")
_ = interruptCurrentResponse() _ = interruptCurrentResponse()
} }
stopTts(); stopTts();
@ -759,10 +792,17 @@ class AgentServiceImpl: NSObject {
var info = UserPortrait.shared.getAll() var info = UserPortrait.shared.getAll()
info["language"] = self.language info["language"] = self.language
// Add current time (formatted string) // Add current time (TTS友好的自然语言格式)
let now = Date()
let lang = Locale.current.languageCode ?? "en"
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" switch lang {
info["current_time"] = dateFormatter.string(from: Date()) case "zh": dateFormatter.dateFormat = "yyyy年M月d日 H时mm分ss秒"; dateFormatter.locale = Locale(identifier: "zh_CN")
case "ja": dateFormatter.dateFormat = "yyyy年M月d日 H時mm分ss秒"; dateFormatter.locale = Locale(identifier: "ja_JP")
case "ko": dateFormatter.dateFormat = "yyyy년 M월 d일 H시 mm분 ss초"; dateFormatter.locale = Locale(identifier: "ko_KR")
default: dateFormatter.dateFormat = "MMMM d, yyyy, h:mm:ss a"; dateFormatter.locale = Locale.current
}
info["current_time"] = dateFormatter.string(from: now)
// 添加调试日志 // 添加调试日志
// print("系统提示词模板: \(systemPrompt)") // print("系统提示词模板: \(systemPrompt)")
// print("用户画像数据: \(info)") // print("用户画像数据: \(info)")
@ -799,11 +839,11 @@ class AgentServiceImpl: NSObject {
imagePath: imagePath imagePath: imagePath
) )
azureTtsHelper?.startspeak(sessionid: sessionid) azureTtsHelper?.startspeak(sessionid: sessionid)
os_log("设置回调并调用sendMessageStream", log: logger, type: .info) _log("INFO", "[Agent] sendMessageStream start, sessionId=\(sessionid)")
chatApiService.setStreamCallback(sessionid,callback) chatApiService.setStreamCallback(sessionid,callback)
chatApiService.sendMessageStream(sessionid,vipexptime: vipexptime,messages: messages) chatApiService.sendMessageStream(sessionid,vipexptime: vipexptime,messages: messages)
audioPlayer?.playAwaitSound() 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 { func processImageInput(sessionid: String,imagePath: String,imageUrl: String, text: String, speakResponse: Bool) -> Bool {
@ -905,7 +945,7 @@ class AgentServiceImpl: NSObject {
} }
} }
os_log("已加载%d条历史记录", log: logger, type: .info, recentMessages.count) _log("INFO", "[Agent] loaded \(recentMessages.count) history messages")
} }
/** /**
@ -926,7 +966,7 @@ class AgentServiceImpl: NSObject {
metadata: metadata.isEmpty ? nil : metadata metadata: metadata.isEmpty ? nil : metadata
) )
if assistantMessageId == -1 { if assistantMessageId == -1 {
os_log("保存助手消息失败", log: self.logger, type: .error) self._log("ERROR", "[Agent] save assistant message failed")
} }
} }
// } else { // } else {
@ -1058,7 +1098,7 @@ private func jsonToString(_ json: [String: Any]) -> String? {
let jsonData = try JSONSerialization.data(withJSONObject: json, options: []) let jsonData = try JSONSerialization.data(withJSONObject: json, options: [])
return String(data: jsonData, encoding: .utf8) return String(data: jsonData, encoding: .utf8)
} catch { } catch {
print("JSON序列化失败: \(error)") _log("ERROR", "[Agent] JSON serialization failed: \(error)")
return nil return nil
} }
} }
@ -1495,7 +1535,7 @@ private func jsonToString(_ json: [String: Any]) -> String? {
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
let success = self.chatStorageHelper.deleteMessages(agentId:self.agentId, messageIds: nil as [Int]?) let success = self.chatStorageHelper.deleteMessages(agentId:self.agentId, messageIds: nil as [Int]?)
if !success { if !success {
os_log("清除聊天历史失败", log: self.logger, type: .error) self._log("ERROR", "[Agent] clear chat history failed")
} }
} }
@ -1514,16 +1554,16 @@ private func jsonToString(_ json: [String: Any]) -> String? {
* @return 是否设置成功 * @return 是否设置成功
*/ */
func setSupportedLanguages(_ languages: [String]) -> Bool { func setSupportedLanguages(_ languages: [String]) -> Bool {
os_log("设置支持的语言: %{public}@", log: logger, type: .info, languages.joined(separator: ", ")) _log("INFO", "[Agent] setSupportedLanguages: \(languages.joined(separator: ", "))")
if !isInitialized { if !isInitialized {
os_log("服务未初始化,无法设置语言", log: logger, type: .error) _log("ERROR", "[Agent] setSupportedLanguages: service not initialized")
sendError("服务未初始化", code: "NOT_INITIALIZED") sendError("服务未初始化", code: "NOT_INITIALIZED")
return false return false
} }
if languages.isEmpty { if languages.isEmpty {
os_log("语言列表为空,使用默认语言", log: logger, type: .info) _log("INFO", "[Agent] setSupportedLanguages: empty list, using default")
return false return false
} }
@ -1563,9 +1603,9 @@ private func jsonToString(_ json: [String: Any]) -> String? {
let overallSuccess = asrSuccess && ttsSuccess let overallSuccess = asrSuccess && ttsSuccess
if overallSuccess { 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 { } 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 +1623,7 @@ private func jsonToString(_ json: [String: Any]) -> String? {
* @return 是否更新成功 * @return 是否更新成功
*/ */
private func updateTtsLanguage(_ languageCode: String) -> Bool { private func updateTtsLanguage(_ languageCode: String) -> Bool {
os_log("更新TTS语言设置: %{public}@", log: logger, type: .info, languageCode) _log("INFO", "[Agent] updateTtsLanguage: \(languageCode)")
// 根据ASR语言代码映射到对应的TTS语音角色 // 根据ASR语言代码映射到对应的TTS语音角色
let voiceName = getTtsVoiceNameByAsrCode(languageCode) let voiceName = getTtsVoiceNameByAsrCode(languageCode)
@ -1593,14 +1633,14 @@ private func jsonToString(_ json: [String: Any]) -> String? {
let success = azureTtsHelper?.setVoice(voiceName) ?? false let success = azureTtsHelper?.setVoice(voiceName) ?? false
if success { if success {
os_log("TTS语音角色设置成功: %{public}@", log: logger, type: .info, voiceName) _log("INFO", "[Agent] TTS voice set success: \(voiceName)")
} else { } else {
os_log("TTS语音角色设置失败: %{public}@", log: logger, type: .error, voiceName) _log("ERROR", "[Agent] TTS voice set failed: \(voiceName)")
} }
return success return success
} else { } else {
os_log("未找到语言代码 %{public}@ 对应的TTS语音角色", log: logger, type: .error, languageCode) _log("ERROR", "[Agent] TTS voice not found for language: \(languageCode)")
return false return false
} }
} }
@ -1731,13 +1771,13 @@ private func jsonToString(_ json: [String: Any]) -> String? {
return "fil-PH-BlessicaNeural" return "fil-PH-BlessicaNeural"
default: 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" return "en-US-AriaNeural"
} }
} }
func dispose() -> Bool { func dispose() -> Bool {
print("ai释放资源") _log("INFO", "[Agent] dispose")
if isRecognizing { if isRecognizing {
// 关闭编码器 (设备侧) // 关闭编码器 (设备侧)
BleService.shared.closeCodec() BleService.shared.closeCodec()
@ -1823,7 +1863,7 @@ class ChatApiStreamCallback: StreamCallback {
try agentService.sendEvent(name: "assistant_token", data: ["sessionid":sessionid,"token": token]) try agentService.sendEvent(name: "assistant_token", data: ["sessionid":sessionid,"token": token])
} }
}catch { }catch {
print("liwei--------------- AI Call onToken 异常: \(error)") agentService?._log("ERROR", "[Agent] AI onToken error: \(error)")
} }
} }
@ -1835,7 +1875,7 @@ class ChatApiStreamCallback: StreamCallback {
// 停止等待音效 // 停止等待音效
agentService.audioPlayer?.stopAwaitSound() 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){ if(agentService.isInterrupt){
BleService.shared.closeCodec() BleService.shared.closeCodec()
} }
@ -1870,9 +1910,9 @@ class ChatApiStreamCallback: StreamCallback {
try agentService.saveChatMessage(sessionId: sessionid,userMessage: displayText, assistantMessage: response, metadata: metadata,userMetadata:toJSONString(userMetadata)) try agentService.saveChatMessage(sessionId: sessionid,userMessage: displayText, assistantMessage: response, metadata: metadata,userMetadata:toJSONString(userMetadata))
agentService.isAiStreaming = false agentService.isAiStreaming = false
iscallingTool = true iscallingTool = true
os_log("设置AI流式状态为false", log: agentService.logger, type: .info) agentService._log("INFO", "[Agent] AI stream completed, response=\(response)")
}catch{ }catch{
print("liwei--------------- AI Call onComplete 异常: \(error)") agentService?._log("ERROR", "[Agent] AI onComplete error: \(error)")
} }
} }
@ -1897,15 +1937,9 @@ class ChatApiStreamCallback: StreamCallback {
agentService.azureTtsHelper?.speakStream(sessionid: sessionid,agentService.insufficientIntegralText) agentService.azureTtsHelper?.speakStream(sessionid: sessionid,agentService.insufficientIntegralText)
} }
agentService.isAiStreaming = false agentService.isAiStreaming = false
os_log( agentService._log("ERROR", "[Agent] AI onError code=\(code), message=\(message)")
"liwei--------------- AI Call onError code: %{public}d message: %{public}@ ",
log: agentService.logger,
type: .info,
code, // 直接传 Int 类型
message // 假设 message 是 String 类型
)
}catch{ }catch{
print("liwei--------------- AI Call onError 异常: \(error)") agentService?._log("ERROR", "[Agent] AI onError exception: \(error)")
} }
} }
@ -1918,8 +1952,7 @@ class ChatApiStreamCallback: StreamCallback {
} }
let name = functionCall["name"] as? String ?? "" let name = functionCall["name"] as? String ?? ""
let arguments = functionCall["arguments"] as? String ?? "" let arguments = functionCall["arguments"] as? String ?? ""
os_log("收到函数调用:%{public}@", log: agentService.logger, type: .info, functionCall) agentService._log("INFO", "[Agent] AI onFunctionCall: name=\(name), args=\(arguments)")
print("onFunctionCall=functionCall\(functionCall)")
if (name == "exit_interaction") { if (name == "exit_interaction") {
// 关闭编码器 (设备侧) // 关闭编码器 (设备侧)
@ -1936,7 +1969,7 @@ class ChatApiStreamCallback: StreamCallback {
]) ])
if (iscallingTool) { 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?.setTtsMode(mod: agentService.currentRecognitionMode)
agentService.azureTtsHelper?.speakStream(sessionid: sessionid,agentService.callingToolText) agentService.azureTtsHelper?.speakStream(sessionid: sessionid,agentService.callingToolText)
iscallingTool = false iscallingTool = false
@ -1944,7 +1977,7 @@ class ChatApiStreamCallback: StreamCallback {
}catch{ }catch{
print("liwei--------------- AI Call onFunctionCall 异常: \(error)") agentService?._log("ERROR", "[Agent] AI onFunctionCall error: \(error)")
} }
} }
@ -1961,10 +1994,7 @@ class ChatApiStreamCallback: StreamCallback {
broadcast = resultmap["broadcast"] as? Bool ?? true broadcast = resultmap["broadcast"] as? Bool ?? true
reply = !(resultmap["notreply"] as? Bool ?? true) reply = !(resultmap["notreply"] as? Bool ?? true)
iscard = resultmap["iscard"] as? Bool ?? false iscard = resultmap["iscard"] as? Bool ?? false
os_log("收到函数调用结果! broadcast: %{public}@ reply: %{public}@ %{iscard}@", agentService._log("INFO", "[Agent] AI onFunctionCallResult: name=\(name), broadcast=\(broadcast), reply=\(reply), iscard=\(iscard)")
log: agentService.logger,
type: .info,
"\(broadcast)", "\(reply)", "\(iscard)")
if (iscard){ if (iscard){
metadata = resultmap["meta"] as? String ?? "" metadata = resultmap["meta"] as? String ?? ""
agentService.sendEvent(name: "function_call_result", data: [ agentService.sendEvent(name: "function_call_result", data: [
@ -1984,7 +2014,7 @@ class ChatApiStreamCallback: StreamCallback {
} }
}catch{ }catch{
print("liwei--------------- AI Call onFunctionCallResult 异常: \(error)") agentService?._log("ERROR", "[Agent] AI onFunctionCallResult error: \(error)")
} }
} }
@ -2024,8 +2054,8 @@ extension AgentServiceImpl: TtsEventListener {
case .playbackCompleted: case .playbackCompleted:
isSpeaking = false isSpeaking = false
restartIdleCheck() restartIdleCheck()
if(isInterrupt){//播报结束。打开解码器 if(currentRecognitionMode == "ble_wakeup" && isInterrupt){//播报结束。打开解码器
BleService.shared.openB1Encoder() BleService.shared.openB1Encoder()
} }
sendEvent(name: "playback_completed", data: ["status": "playback_completed"]) sendEvent(name: "playback_completed", data: ["status": "playback_completed"])
MusicService.shared.restoreVolume() MusicService.shared.restoreVolume()
@ -2377,13 +2407,10 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
func onRecognizing(sessionid:String ,_ recognizing: String, _ detectedLanguage: String) { func onRecognizing(sessionid:String ,_ recognizing: String, _ detectedLanguage: String) {
pendingProcessWorkItem?.cancel() pendingProcessWorkItem?.cancel()
if !recognizing.isEmpty && isKeepResult { if !recognizing.isEmpty && isKeepResult {
let previousHasSpeech = hasSpeechDetected
hasSpeechDetected = true hasSpeechDetected = true
// 每次识别到语音都重启idle计时器,避免用户持续说话时误触超时
restartIdleCheck()
if !previousHasSpeech {
print("onRecognizing restartIdleCheck")
restartIdleCheck()
}
if currsessionAsrId.isEmpty { if currsessionAsrId.isEmpty {
currsessionAsrId = sessionid currsessionAsrId = sessionid
} }
@ -2397,9 +2424,13 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
} }
sendEvent(name: "recognizing", data: data) sendEvent(name: "recognizing", data: data)
// 执行打断:同时停止TTS和AI流,避免isAiStreaming残留导致idle check失效
if isSpeaking || isAiStreaming { if isSpeaking || isAiStreaming {
stopTts() stopTts()
// interruptCurrentResponse() if isAiStreaming {
isAiStreaming = false
_ = chatApiService?.cancelCurrentStream()
}
} }
} }
} }
@ -2504,7 +2535,7 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
let userDefaults = UserDefaults.standard let userDefaults = UserDefaults.standard
let cachedValue = userDefaults.bool(forKey: KEY_IS_INTERRUPT) // 默认值为 false let cachedValue = userDefaults.bool(forKey: KEY_IS_INTERRUPT) // 默认值为 false
isInterrupt = cachedValue isInterrupt = cachedValue
os_log("从缓存加载 isInterrupt 状态: %{public}@", log: logger, type: .info, String(isInterrupt)) _log("INFO", "[Agent] loaded isInterrupt from cache: \(isInterrupt)")
} }
/** /**
@ -2513,19 +2544,19 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
func saveIsInterruptToCache(_ isInterrupt: Bool) { func saveIsInterruptToCache(_ isInterrupt: Bool) {
let userDefaults = UserDefaults.standard let userDefaults = UserDefaults.standard
userDefaults.set(isInterrupt, forKey: KEY_IS_INTERRUPT) 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() { private func loadTtsRateFromCache() {
let userDefaults = UserDefaults.standard let userDefaults = UserDefaults.standard
let cachedValue = userDefaults.object(forKey: KEY_TTS_RATE) as? Int ?? 25 let cachedValue = userDefaults.object(forKey: KEY_TTS_RATE) as? Int ?? 25
ttsRatePercent = max(-100, min(100, cachedValue)) 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) { private func saveTtsRateToCache(_ rate: Int) {
let userDefaults = UserDefaults.standard let userDefaults = UserDefaults.standard
userDefaults.set(rate, forKey: KEY_TTS_RATE) userDefaults.set(rate, forKey: KEY_TTS_RATE)
os_log("保存 ttsRatePercent 到缓存: %{public}@", log: logger, type: .info, String(rate)) _log("INFO", "[Agent] saved ttsRatePercent to cache: \(rate)")
} }
} }

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

@ -226,6 +226,21 @@ class AgentServiceEventHandler: NSObject, FlutterStreamHandler {
// 添加实现的事件监听器 // 添加实现的事件监听器
impl.addListener(self) 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 return nil
} }

19
local_plugins/agent_service/lib/agent_service.dart

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

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

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

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

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

91
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 // 参考文档:https://www.xfyun.cn/doc/spark/asr_llm/rtasr_llm.html
class XunFeiAsrHelper(private val context: Context) { class XunFeiAsrHelper(private val context: Context) {
private val tag = "XunFeiAsrHelper" private val tag = "XunFei_ASR"
private var appId: String = "" private var appId: String = ""
private var accessKeyId: String = "" private var accessKeyId: String = ""
@ -40,6 +40,39 @@ class XunFeiAsrHelper(private val context: Context) {
// 是否第去除一个文字的标点符号 // 是否第去除一个文字的标点符号
private var removeFirstPunctuation = true 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( constructor(
context: Context, context: Context,
appId: String, appId: String,
@ -53,6 +86,7 @@ class XunFeiAsrHelper(private val context: Context) {
this.appId = appId this.appId = appId
this.accessKeyId = accessKeyId this.accessKeyId = accessKeyId
this.accessKeySecret = accessKeySecret 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 { private fun isConfigValid(): Boolean {
@ -66,15 +100,18 @@ class XunFeiAsrHelper(private val context: Context) {
isAutoDetect: Boolean, isAutoDetect: Boolean,
isRemoveFirstPunctuation: Boolean isRemoveFirstPunctuation: Boolean
) { ) {
_log("INFO", "[XunFei_ASR] start() called: lang=$language, supportedLangs=${supportedLanguages.joinToString(",")}, autoDetect=$isAutoDetect")
try { try {
webSocket?.cancel() webSocket?.cancel()
webSocket = null webSocket = null
} catch (e: Exception) { } 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() currentSessionId = UUID.randomUUID().toString()
lastIntermediateResult = "" lastIntermediateResult = ""
audioFrameCount = 0
currentLanguage = language currentLanguage = language
this.supportedLanguages = supportedLanguages this.supportedLanguages = supportedLanguages
@ -82,16 +119,21 @@ class XunFeiAsrHelper(private val context: Context) {
removeFirstPunctuation = isRemoveFirstPunctuation removeFirstPunctuation = isRemoveFirstPunctuation
if (!isConfigValid()) { 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 return
} }
val url = getWebSocketUrl() val url = getWebSocketUrl()
_log("INFO", "[XunFei_ASR] WebSocket connecting to: ${url.substringBefore("?")}")
connectStartTime = System.currentTimeMillis()
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
webSocket = client.newWebSocket(request, object : WebSocketListener() { webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { 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) callback.onSessionStarted(currentSessionId)
} }
@ -100,12 +142,12 @@ class XunFeiAsrHelper(private val context: Context) {
} }
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { 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) webSocket.close(1000, null)
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { 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()) { if (currentSessionId.isNotEmpty() && lastIntermediateResult.isNotEmpty()) {
val detectedLanguage = selectDetectedLanguage(lastIntermediateResult) val detectedLanguage = selectDetectedLanguage(lastIntermediateResult)
callback.onResult(currentSessionId, lastIntermediateResult, detectedLanguage) callback.onResult(currentSessionId, lastIntermediateResult, detectedLanguage)
@ -114,8 +156,12 @@ class XunFeiAsrHelper(private val context: Context) {
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(tag, "WebSocket Error", t) val elapsed = System.currentTimeMillis() - connectStartTime
callback.onError(currentSessionId, 1004, "Xunfei connection failed: ${t.message}") 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) { fun sendAudio(frameBuffer: ByteArray) {
webSocket?.let { ws: WebSocket -> webSocket?.let { ws: WebSocket ->
ws.send(frameBuffer.toByteString(0, frameBuffer.size)) 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() { fun stop() {
_log("INFO", "[XunFei_ASR] stop() called: audioFrames=$audioFrameCount, sessionId=$sessionId")
try { try {
webSocket?.let { ws: WebSocket -> webSocket?.let { ws: WebSocket ->
ws.send("{\"end\": true, \"sessionId\": \"$sessionId\"}") ws.send("{\"end\": true, \"sessionId\": \"$sessionId\"}")
@ -134,12 +186,12 @@ class XunFeiAsrHelper(private val context: Context) {
try { try {
ws.close(1000, "User stopped") ws.close(1000, "User stopped")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "Close Xunfei failed: ${e.message}") _log("ERROR", "[XunFei_ASR] Close Xunfei failed: ${e.message}")
} }
}, 1000) }, 1000)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "Stop Xunfei failed: ${e.message}") _log("ERROR", "[XunFei_ASR] Stop Xunfei failed: ${e.message}")
} finally { } finally {
webSocket = null webSocket = null
} }
@ -151,8 +203,8 @@ class XunFeiAsrHelper(private val context: Context) {
) { ) {
try { try {
val jsonData = JSONObject(resultData) val jsonData = JSONObject(resultData)
Log.d(tag, "-------------------$jsonData")
val msgType = jsonData.optString("msg_type") val msgType = jsonData.optString("msg_type")
_log("DEBUG", "[XunFei_ASR] Received msg_type=$msgType")
when (msgType) { when (msgType) {
"action" -> { "action" -> {
@ -160,7 +212,7 @@ class XunFeiAsrHelper(private val context: Context) {
if (!sid.isNullOrEmpty()) { if (!sid.isNullOrEmpty()) {
sessionId = sid sessionId = sid
} }
Log.d(tag, "Handshake success") _log("INFO", "[XunFei_ASR] Handshake success, serverSessionId=$sid")
} }
"result" -> { "result" -> {
@ -220,8 +272,10 @@ class XunFeiAsrHelper(private val context: Context) {
} }
val detectedLanguage = selectDetectedLanguage(resultTextTemp) 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) callback.onResult(currentSessionId, resultTextTemp, detectedLanguage)
currentSessionId = UUID.randomUUID().toString() currentSessionId = UUID.randomUUID().toString()
lastIntermediateResult = "" lastIntermediateResult = ""
@ -232,17 +286,18 @@ class XunFeiAsrHelper(private val context: Context) {
} }
"error" -> { "error" -> {
callback.onError(currentSessionId, 1005, "Xunfei error: $resultData") _log("ERROR", "[XunFei_ASR] Server error: $resultData")
Log.e(tag, "Error: $resultData") callback.onError(currentSessionId, 1005, "Xunfei server error: $resultData")
} }
else -> { else -> {
callback.onError(currentSessionId, 1006, "Xunfei unknown msg_type: $msgType") _log("WARNING", "[XunFei_ASR] Unknown msg_type=$msgType, data=$resultData")
Log.w(tag, "Unknown msg_type: $msgType") callback.onError(currentSessionId, 1006, "Xunfei unknown msg_type=$msgType, data=$resultData")
} }
} }
} catch (e: Exception) { } 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}")
} }
} }

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

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

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

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

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

@ -1,12 +1,14 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import os
// 讯飞语音识别助手 // 讯飞语音识别助手
//调用讯飞开放平台的实时语音转写大模型接口 //调用讯飞开放平台的实时语音转写大模型接口
// 参考文档:https://www.xfyun.cn/doc/spark/asr_llm/rtasr_llm.html // 参考文档:https://www.xfyun.cn/doc/spark/asr_llm/rtasr_llm.html
class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate { 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 appId: String
private var accessKeyId: String private var accessKeyId: String
@ -31,17 +33,56 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
private var callback: AzureAsrHelper.ContinuousRecognizeCallback? private var callback: AzureAsrHelper.ContinuousRecognizeCallback?
// 音频帧计数和连接时间
private var audioFrameCount = 0
private var connectStartTime: Date?
/// 日志回调:(level, message) -> Void,由 Plugin 层设置,用于将原生日志回传 Dart
var logCallback: ((String, String) -> Void)?
private let logChunkSize = 800
private func _log(_ level: String, _ message: String) {
if message.count <= logChunkSize {
_logSingle(level, message)
} else {
let totalParts = (message.count + logChunkSize - 1) / logChunkSize
for i in 0..<totalParts {
let start = message.index(message.startIndex, offsetBy: i * logChunkSize)
let end = message.index(start, offsetBy: min(logChunkSize, message.distance(from: start, to: message.endIndex)))
let chunk = String(message[start..<end])
_logSingle(level, "[\(i + 1)/\(totalParts)] \(chunk)")
}
}
}
private func _logSingle(_ level: String, _ message: String) {
switch level {
case "ERROR":
os_log("%{public}@", log: log, type: .error, message)
case "WARNING":
os_log("%{public}@", log: log, type: .default, message)
case "DEBUG":
os_log("%{public}@", log: log, type: .debug, message)
default:
os_log("%{public}@", log: log, type: .info, message)
}
logCallback?(level, message)
}
init(appId: String, accessKeyId: String, accessKeySecret: String) { init(appId: String, accessKeyId: String, accessKeySecret: String) {
self.appId = appId self.appId = appId
self.accessKeyId = accessKeyId self.accessKeyId = accessKeyId
self.accessKeySecret = accessKeySecret self.accessKeySecret = accessKeySecret
super.init() super.init()
_log("INFO", "[XunFei_ASR] Config init: appId=\(String(appId.prefix(6)))***, keyId=\(String(accessKeyId.prefix(6)))***, secret=\(accessKeySecret.isEmpty ? "EMPTY" : "SET")")
} }
func updateConfig(appId: String, accessKeyId: String, accessKeySecret: String) { func updateConfig(appId: String, accessKeyId: String, accessKeySecret: String) {
self.appId = appId self.appId = appId
self.accessKeyId = accessKeyId self.accessKeyId = accessKeyId
self.accessKeySecret = accessKeySecret self.accessKeySecret = accessKeySecret
_log("INFO", "[XunFei_ASR] Config updated: appId=\(String(appId.prefix(6)))***, keyId=\(String(accessKeyId.prefix(6)))***, secret=\(accessKeySecret.isEmpty ? "EMPTY" : "SET")")
} }
private func isConfigValid() -> Bool { private func isConfigValid() -> Bool {
@ -49,6 +90,8 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
} }
func start(callback: AzureAsrHelper.ContinuousRecognizeCallback, language: String, supportedLanguages: [String], isAutoDetect: Bool, isRemoveFirstPunctuation: Bool) { 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() stop()
self.callback = callback self.callback = callback
@ -59,37 +102,51 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
self.currentSessionId = UUID().uuidString self.currentSessionId = UUID().uuidString
self.lastIntermediateResult = "" self.lastIntermediateResult = ""
self.sessionId = "" self.sessionId = ""
self.audioFrameCount = 0
if !isConfigValid() { 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 return
} }
let urlString = getWebSocketUrl() let urlString = getWebSocketUrl()
guard let url = URL(string: urlString) else { 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 return
} }
_log("INFO", "[XunFei_ASR] WebSocket connecting to: \(url.host ?? "unknown")")
connectStartTime = Date()
let request = URLRequest(url: url) let request = URLRequest(url: url)
webSocketTask = session.webSocketTask(with: request) webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume() webSocketTask?.resume()
print("\(tag): WebSocket Connecting...")
receiveMessage() receiveMessage()
} }
func sendAudio(frameBuffer: Data) { func sendAudio(frameBuffer: Data) {
guard let webSocketTask = webSocketTask else { return } guard let webSocketTask = webSocketTask else { return }
let message = URLSessionWebSocketTask.Message.data(frameBuffer) 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 { 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() { func stop() {
_log("INFO", "[XunFei_ASR] stop() called: audioFrames=\(audioFrameCount), sessionId=\(sessionId)")
if let webSocketTask = webSocketTask { if let webSocketTask = webSocketTask {
let endJson = "{\"end\": true, \"sessionId\": \"\(sessionId)\"}" let endJson = "{\"end\": true, \"sessionId\": \"\(sessionId)\"}"
let message = URLSessionWebSocketTask.Message.string(endJson) let message = URLSessionWebSocketTask.Message.string(endJson)
@ -108,8 +165,11 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
switch result { switch result {
case .failure(let error): case .failure(let error):
print("\(self.tag): WebSocket Receive Error: \(error)") let elapsed = self.connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1
self.callback?.onError(sessionid: self.currentSessionId, 1004, "Xunfei connection failed: \(error.localizedDescription)") 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): case .success(let message):
switch message { switch message {
@ -130,13 +190,14 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
} }
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { 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) callback?.onSessionStarted(sessionid: currentSessionId)
} }
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown" 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 { if !currentSessionId.isEmpty && !lastIntermediateResult.isEmpty {
let detectedLanguage = detectLanguage(from: lastIntermediateResult) let detectedLanguage = detectLanguage(from: lastIntermediateResult)
@ -145,6 +206,16 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
callback?.onSessionStopped(sessionid: currentSessionId) 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) { private func processMessage(_ text: String) {
guard let data = text.data(using: .utf8) else { return } guard let data = text.data(using: .utf8) else { return }
@ -152,29 +223,31 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return } guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return }
let msgType = json["msg_type"] as? String let msgType = json["msg_type"] as? String
_log("DEBUG", "[XunFei_ASR] Received msg_type=\(msgType ?? "nil")")
switch msgType { switch msgType {
case "action": case "action":
if let sid = json["sessionId"] as? String, !sid.isEmpty { if let sid = json["sessionId"] as? String, !sid.isEmpty {
self.sessionId = sid self.sessionId = sid
} }
print("\(tag): Handshake success") _log("INFO", "[XunFei_ASR] Handshake success, serverSessionId=\(self.sessionId)")
case "result": case "result":
processResult(json) processResult(json)
case "error": case "error":
let errorMsg = "\(json)" let errorMsg = "\(json)"
print("\(tag): Error: \(errorMsg)") _log("ERROR", "[XunFei_ASR] Server error: \(errorMsg)")
callback?.onError(sessionid: currentSessionId, 1005, "Xunfei error: \(errorMsg)") callback?.onError(sessionid: currentSessionId, 1005, "Xunfei server error: \(errorMsg)")
default: default:
print("\(tag): 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")") callback?.onError(sessionid: currentSessionId, 1006, "Xunfei unknown msg_type=\(msgType ?? "nil"), data=\(text)")
} }
} catch { } 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)")
} }
} }
@ -189,7 +262,7 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
} }
guard let data = data else { 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 return
} }
@ -260,6 +333,8 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
let type = st["type"] as? String let type = st["type"] as? String
let typeInt = st["type"] as? Int ?? Int(type ?? "") ?? -1 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 { if typeInt == 0 {
callback?.onResult(sessionid: currentSessionId, resultTextTemp, detectedLanguage) callback?.onResult(sessionid: currentSessionId, resultTextTemp, detectedLanguage)
currentSessionId = UUID().uuidString currentSessionId = UUID().uuidString

76
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt

@ -1014,43 +1014,23 @@ private class GetCurrentTimeHandler : FunctionHandler {
return try { return try {
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
val date = calendar.time val date = calendar.time
val lang = Locale.getDefault().language
val timeStr = when (format?.lowercase(Locale.getDefault())) { val timeStr = when (format?.lowercase(Locale.getDefault())) {
"short" -> { "short" -> formatShortTime(date, lang)
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) "date" -> formatDate(date, lang)
} "time" -> formatTime(date, lang)
"date" -> { "full" -> formatFull(date, lang)
SimpleDateFormat("yyyy年MM月dd日", Locale.getDefault()).format(date) "iso" -> SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()).format(date)
} else -> formatDefault(date, lang)
"time" -> {
SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(date)
}
"full" -> {
SimpleDateFormat("yyyy年MM月dd日 EEEE HH:mm:ss", Locale.getDefault()).format(date)
}
"iso" -> {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()).format(date)
}
else -> {
SimpleDateFormat("yyyy年MM月dd日 HH:mm", Locale.getDefault()).format(date)
}
} }
// 获取星期几 // 获取星期几(使用系统locale自动适配语言)
val weekDay = when (calendar.get(Calendar.DAY_OF_WEEK)) { val weekDay = SimpleDateFormat("EEEE", Locale.getDefault()).format(date)
Calendar.SUNDAY -> "星期日"
Calendar.MONDAY -> "星期一"
Calendar.TUESDAY -> "星期二"
Calendar.WEDNESDAY -> "星期三"
Calendar.THURSDAY -> "星期四"
Calendar.FRIDAY -> "星期五"
Calendar.SATURDAY -> "星期六"
else -> ""
}
// 根据格式决定是否添加星期几 // 根据格式决定是否添加星期几
val resultTime = when { val resultTime = when {
format == "short" || format == "time" -> timeStr format == "short" || format == "time" || format == "iso" -> timeStr
format == "full" -> timeStr // full格式已经包含了星期 format == "full" -> timeStr // full格式已经包含了星期
else -> "$timeStr $weekDay" else -> "$timeStr $weekDay"
} }
@ -1069,6 +1049,42 @@ private class GetCurrentTimeHandler : FunctionHandler {
}.toString() }.toString()
} }
} }
// TTS友好的时间格式,避免AI将":"分隔的时间误读为数字
private fun formatShortTime(date: Date, lang: String): String = when (lang) {
"zh" -> SimpleDateFormat("H时mm分", Locale.CHINESE).format(date)
"ja" -> SimpleDateFormat("H時mm分", Locale.JAPANESE).format(date)
"ko" -> SimpleDateFormat("H시 mm분", Locale.KOREAN).format(date)
else -> SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)
}
private fun formatDate(date: Date, lang: String): String = when (lang) {
"zh" -> SimpleDateFormat("yyyy年M月d日", Locale.CHINESE).format(date)
"ja" -> SimpleDateFormat("yyyy年M月d日", Locale.JAPANESE).format(date)
"ko" -> SimpleDateFormat("yyyy년 M월 d일", Locale.KOREAN).format(date)
else -> SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()).format(date)
}
private fun formatTime(date: Date, lang: String): String = when (lang) {
"zh" -> SimpleDateFormat("H时mm分ss秒", Locale.CHINESE).format(date)
"ja" -> SimpleDateFormat("H時mm分ss秒", Locale.JAPANESE).format(date)
"ko" -> SimpleDateFormat("H시 mm분 ss초", Locale.KOREAN).format(date)
else -> SimpleDateFormat("h:mm:ss a", Locale.getDefault()).format(date)
}
private fun formatFull(date: Date, lang: String): String = when (lang) {
"zh" -> SimpleDateFormat("yyyy年M月d日 EEEE H时mm分ss秒", Locale.CHINESE).format(date)
"ja" -> SimpleDateFormat("yyyy年M月d日 EEEE H時mm分ss秒", Locale.JAPANESE).format(date)
"ko" -> SimpleDateFormat("yyyy년 M월 d일 EEEE H시 mm분 ss초", Locale.KOREAN).format(date)
else -> SimpleDateFormat("MMMM d, yyyy, EEEE, h:mm:ss a", Locale.getDefault()).format(date)
}
private fun formatDefault(date: Date, lang: String): String = when (lang) {
"zh" -> SimpleDateFormat("yyyy年M月d日 H时mm分", Locale.CHINESE).format(date)
"ja" -> SimpleDateFormat("yyyy年M月d日 H時mm分", Locale.JAPANESE).format(date)
"ko" -> SimpleDateFormat("yyyy년 M월 d일 H시 mm분", Locale.KOREAN).format(date)
else -> SimpleDateFormat("MMMM d, yyyy, h:mm a", Locale.getDefault()).format(date)
}
} }
/** /**

96
local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift

@ -868,18 +868,98 @@ private class GetCurrentTimeHandler: FunctionHandler {
} }
private func getCurrentTime(format: String?) -> Result { private func getCurrentTime(format: String?) -> Result {
let formatter = DateFormatter() let now = Date()
let lang = Locale.current.languageCode ?? "en"
let timeStr: String
switch format?.lowercased() {
case "short":
timeStr = formatShortTime(now, lang: lang)
case "date":
timeStr = formatDate(now, lang: lang)
case "time":
timeStr = formatTime(now, lang: lang)
case "full":
timeStr = formatFull(now, lang: lang)
case "iso":
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
timeStr = f.string(from: now)
default:
timeStr = formatDefault(now, lang: lang)
}
if let customFormat = format, !customFormat.isEmpty { // 根据格式决定是否添加星期几
formatter.dateFormat = customFormat let resultTime: String
} else { switch format?.lowercased() {
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" case "short", "time", "iso":
resultTime = timeStr
case "full":
resultTime = timeStr // full格式已包含星期
default:
let weekFormatter = DateFormatter()
weekFormatter.dateFormat = "EEEE"
weekFormatter.locale = Locale.current
resultTime = "\(timeStr) \(weekFormatter.string(from: now))"
} }
formatter.locale = Locale(identifier: "zh_CN") return Result(success: true, message: resultTime)
let timeString = formatter.string(from: Date()) }
return Result(success: true, message: timeString) // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字
private func formatShortTime(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "H时mm分"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "H時mm分"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "H시 mm분"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "h:mm a"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatDate(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "yyyy年M月d日"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "yyyy年M月d日"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "yyyy년 M월 d일"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "MMMM d, yyyy"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatTime(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "h:mm:ss a"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatFull(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "MMMM d, yyyy, EEEE, h:mm:ss a"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatDefault(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "yyyy年M月d日 H时mm分"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "yyyy年M月d日 H時mm分"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "yyyy년 M월 d일 H시 mm분"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "MMMM d, yyyy, h:mm a"; f.locale = Locale.current
}
return f.string(from: date)
} }
} }

85
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt

@ -1060,49 +1060,24 @@ class SystemFunctionHandler(private val context: Context) {
try { try {
val calendar = java.util.Calendar.getInstance() val calendar = java.util.Calendar.getInstance()
val date = calendar.time val date = calendar.time
val lang = java.util.Locale.getDefault().language
// TTS友好的时间格式,避免AI将":"分隔的时间误读为数字
val timeStr = when (format?.lowercase(java.util.Locale.getDefault())) { val timeStr = when (format?.lowercase(java.util.Locale.getDefault())) {
"short" -> { "short" -> formatShortTime(date, lang)
val timeFormat = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) "date" -> formatDate(date, lang)
timeFormat.format(date) "time" -> formatTime(date, lang)
} "full" -> formatFull(date, lang)
"date" -> { "iso" -> java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.getDefault()).format(date)
val dateFormat = java.text.SimpleDateFormat("yyyy年MM月dd日", java.util.Locale.getDefault()) else -> formatDefaultTime(date, lang)
dateFormat.format(date)
}
"time" -> {
val timeFormat = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
timeFormat.format(date)
}
"full" -> {
val fullFormat = java.text.SimpleDateFormat("yyyy年MM月dd日 EEEE HH:mm:ss", java.util.Locale.getDefault())
fullFormat.format(date)
}
"iso" -> {
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.getDefault())
isoFormat.format(date)
}
else -> {
val defaultFormat = java.text.SimpleDateFormat("yyyy年MM月dd日 HH:mm", java.util.Locale.getDefault())
defaultFormat.format(date)
}
} }
// 获取星期几 // 获取星期几(使用系统locale自动适配语言)
val weekDay = when (calendar.get(java.util.Calendar.DAY_OF_WEEK)) { val weekDay = java.text.SimpleDateFormat("EEEE", java.util.Locale.getDefault()).format(date)
java.util.Calendar.SUNDAY -> "星期日"
java.util.Calendar.MONDAY -> "星期一"
java.util.Calendar.TUESDAY -> "星期二"
java.util.Calendar.WEDNESDAY -> "星期三"
java.util.Calendar.THURSDAY -> "星期四"
java.util.Calendar.FRIDAY -> "星期五"
java.util.Calendar.SATURDAY -> "星期六"
else -> ""
}
// 如果不是返回短格式,则添加星期几(除非format已经包含星期) // 根据格式决定是否添加星期几
val resultTime = when { val resultTime = when {
format == "short" || format == "time" -> timeStr format == "short" || format == "time" || format == "iso" -> timeStr
format == "full" -> timeStr // full格式已经包含了星期 format == "full" -> timeStr // full格式已经包含了星期
else -> "$timeStr $weekDay" else -> "$timeStr $weekDay"
} }
@ -1121,6 +1096,42 @@ class SystemFunctionHandler(private val context: Context) {
} }
} }
// TTS友好的时间格式辅助方法
private fun formatShortTime(date: java.util.Date, lang: String): String = when (lang) {
"zh" -> java.text.SimpleDateFormat("H时mm分", java.util.Locale.CHINESE).format(date)
"ja" -> java.text.SimpleDateFormat("H時mm分", java.util.Locale.JAPANESE).format(date)
"ko" -> java.text.SimpleDateFormat("H시 mm분", java.util.Locale.KOREAN).format(date)
else -> java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).format(date)
}
private fun formatDate(date: java.util.Date, lang: String): String = when (lang) {
"zh" -> java.text.SimpleDateFormat("yyyy年M月d日", java.util.Locale.CHINESE).format(date)
"ja" -> java.text.SimpleDateFormat("yyyy年M月d日", java.util.Locale.JAPANESE).format(date)
"ko" -> java.text.SimpleDateFormat("yyyy년 M월 d일", java.util.Locale.KOREAN).format(date)
else -> java.text.SimpleDateFormat("MMMM d, yyyy", java.util.Locale.getDefault()).format(date)
}
private fun formatTime(date: java.util.Date, lang: String): String = when (lang) {
"zh" -> java.text.SimpleDateFormat("H时mm分ss秒", java.util.Locale.CHINESE).format(date)
"ja" -> java.text.SimpleDateFormat("H時mm分ss秒", java.util.Locale.JAPANESE).format(date)
"ko" -> java.text.SimpleDateFormat("H시 mm분 ss초", java.util.Locale.KOREAN).format(date)
else -> java.text.SimpleDateFormat("h:mm:ss a", java.util.Locale.getDefault()).format(date)
}
private fun formatFull(date: java.util.Date, lang: String): String = when (lang) {
"zh" -> java.text.SimpleDateFormat("yyyy年M月d日 EEEE H时mm分ss秒", java.util.Locale.CHINESE).format(date)
"ja" -> java.text.SimpleDateFormat("yyyy年M月d日 EEEE H時mm分ss秒", java.util.Locale.JAPANESE).format(date)
"ko" -> java.text.SimpleDateFormat("yyyy년 M월 d일 EEEE H시 mm분 ss초", java.util.Locale.KOREAN).format(date)
else -> java.text.SimpleDateFormat("MMMM d, yyyy, EEEE, h:mm:ss a", java.util.Locale.getDefault()).format(date)
}
private fun formatDefaultTime(date: java.util.Date, lang: String): String = when (lang) {
"zh" -> java.text.SimpleDateFormat("yyyy年M月d日 H时mm分", java.util.Locale.CHINESE).format(date)
"ja" -> java.text.SimpleDateFormat("yyyy年M月d日 H時mm分", java.util.Locale.JAPANESE).format(date)
"ko" -> java.text.SimpleDateFormat("yyyy년 M월 d일 H시 mm분", java.util.Locale.KOREAN).format(date)
else -> java.text.SimpleDateFormat("MMMM d, yyyy, h:mm a", java.util.Locale.getDefault()).format(date)
}
/** /**
* 获取当前位置(简化版,只获取最后已知位置) * 获取当前位置(简化版,只获取最后已知位置)
* @return 操作结果 * @return 操作结果

97
local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift

@ -572,18 +572,97 @@ private class GetCurrentTimeHandler: FunctionHandler {
} }
private func getCurrentTime(format: String?) -> Result { private func getCurrentTime(format: String?) -> Result {
let formatter = DateFormatter() let now = Date()
let lang = Locale.current.languageCode ?? "en"
let timeStr: String
switch format?.lowercased() {
case "short":
timeStr = formatShortTime(now, lang: lang)
case "date":
timeStr = formatDate(now, lang: lang)
case "time":
timeStr = formatTime(now, lang: lang)
case "full":
timeStr = formatFull(now, lang: lang)
case "iso":
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
timeStr = f.string(from: now)
default:
timeStr = formatDefault(now, lang: lang)
}
let resultTime: String
switch format?.lowercased() {
case "short", "time", "iso":
resultTime = timeStr
case "full":
resultTime = timeStr
default:
let weekFormatter = DateFormatter()
weekFormatter.dateFormat = "EEEE"
weekFormatter.locale = Locale.current
resultTime = "\(timeStr) \(weekFormatter.string(from: now))"
}
return Result(success: true, message: resultTime)
}
if let format = format, !format.isEmpty { // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字
formatter.dateFormat = format private func formatShortTime(_ date: Date, lang: String) -> String {
} else { let f = DateFormatter()
formatter.dateStyle = .full switch lang {
formatter.timeStyle = .full case "zh": f.dateFormat = "H时mm分"; f.locale = Locale(identifier: "zh_CN")
formatter.locale = Locale(identifier: "zh_CN") case "ja": f.dateFormat = "H時mm分"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "H시 mm분"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "h:mm a"; f.locale = Locale.current
} }
return f.string(from: date)
}
let currentTime = formatter.string(from: Date()) private func formatDate(_ date: Date, lang: String) -> String {
return Result(success: true, message: currentTime) let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "yyyy年M月d日"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "yyyy年M月d日"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "yyyy년 M월 d일"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "MMMM d, yyyy"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatTime(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "h:mm:ss a"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatFull(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "MMMM d, yyyy, EEEE, h:mm:ss a"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatDefault(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "yyyy年M月d日 H时mm分"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "yyyy年M月d日 H時mm分"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "yyyy년 M월 d일 H시 mm분"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "MMMM d, yyyy, h:mm a"; f.locale = Locale.current
}
return f.string(from: date)
} }
} }

97
local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift

@ -591,18 +591,97 @@ private class GetCurrentTimeHandler: FunctionHandler {
} }
private func getCurrentTime(format: String?) -> Result { private func getCurrentTime(format: String?) -> Result {
let formatter = DateFormatter() let now = Date()
let lang = Locale.current.languageCode ?? "en"
let timeStr: String
switch format?.lowercased() {
case "short":
timeStr = formatShortTime(now, lang: lang)
case "date":
timeStr = formatDate(now, lang: lang)
case "time":
timeStr = formatTime(now, lang: lang)
case "full":
timeStr = formatFull(now, lang: lang)
case "iso":
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
timeStr = f.string(from: now)
default:
timeStr = formatDefault(now, lang: lang)
}
let resultTime: String
switch format?.lowercased() {
case "short", "time", "iso":
resultTime = timeStr
case "full":
resultTime = timeStr
default:
let weekFormatter = DateFormatter()
weekFormatter.dateFormat = "EEEE"
weekFormatter.locale = Locale.current
resultTime = "\(timeStr) \(weekFormatter.string(from: now))"
}
return Result(success: true, message: resultTime)
}
if let format = format, !format.isEmpty { // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字
formatter.dateFormat = format private func formatShortTime(_ date: Date, lang: String) -> String {
} else { let f = DateFormatter()
formatter.dateStyle = .full switch lang {
formatter.timeStyle = .full case "zh": f.dateFormat = "H时mm分"; f.locale = Locale(identifier: "zh_CN")
formatter.locale = Locale(identifier: "zh_CN") case "ja": f.dateFormat = "H時mm分"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "H시 mm분"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "h:mm a"; f.locale = Locale.current
} }
return f.string(from: date)
}
let currentTime = formatter.string(from: Date()) private func formatDate(_ date: Date, lang: String) -> String {
return Result(success: true, message: currentTime) let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "yyyy年M月d日"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "yyyy年M月d日"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "yyyy년 M월 d일"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "MMMM d, yyyy"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatTime(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "h:mm:ss a"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatFull(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时mm分ss秒"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時mm分ss秒"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 mm분 ss초"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "MMMM d, yyyy, EEEE, h:mm:ss a"; f.locale = Locale.current
}
return f.string(from: date)
}
private func formatDefault(_ date: Date, lang: String) -> String {
let f = DateFormatter()
switch lang {
case "zh": f.dateFormat = "yyyy年M月d日 H时mm分"; f.locale = Locale(identifier: "zh_CN")
case "ja": f.dateFormat = "yyyy年M月d日 H時mm分"; f.locale = Locale(identifier: "ja_JP")
case "ko": f.dateFormat = "yyyy년 M월 d일 H시 mm분"; f.locale = Locale(identifier: "ko_KR")
default: f.dateFormat = "MMMM d, yyyy, h:mm a"; f.locale = Locale.current
}
return f.string(from: date)
} }
} }

2
pubspec.yaml

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

Loading…
Cancel
Save