diff --git a/apps/client/check_16kb.py b/apps/client/check_16kb.py deleted file mode 100644 index d039b3e3..00000000 --- a/apps/client/check_16kb.py +++ /dev/null @@ -1,86 +0,0 @@ -import zipfile -import struct -import sys -import os - -def check_so_alignment(aab_path): - print(f"Checking AAB: {aab_path}") - - try: - with zipfile.ZipFile(aab_path, 'r') as z: - so_files = [f for f in z.namelist() if f.endswith('.so') and 'arm64-v8a' in f] - - if not so_files: - print("No arm64-v8a .so files found in the AAB.") - return - - print(f"Found {len(so_files)} arm64-v8a .so files.") - - issues = [] - - for so_name in so_files: - with z.open(so_name) as f: - data = f.read() - - # Check ELF magic - if data[:4] != b'\x7fELF': - print(f"Skipping {so_name}: Not an ELF file") - continue - - # Check class (64-bit) - if data[4] != 2: - print(f"Skipping {so_name}: Not a 64-bit ELF") - continue - - # Parse ELF Header (Elf64_Ehdr) - # e_phoff is at offset 32 (0x20), 64-bit unsigned - e_phoff = struct.unpack('") - sys.exit(1) - - check_so_alignment(sys.argv[1]) diff --git a/apps/client/fix_ja_jp.py b/apps/client/fix_ja_jp.py deleted file mode 100644 index c5fbe883..00000000 --- a/apps/client/fix_ja_jp.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 -""" -Fix Japanese translation file: replace English/Chinese placeholders with proper Japanese. -Brand names (Eaimar, Lily, WeChat, Alipay, PayPal, Apple, Google, Bluetooth, BLE, OTA, -AI, ASR, TTS, EAIMAR, AIPEN, Eaimar, DeepSound) are kept as-is. -""" - -import re - -filepath = 'lib/core/translations/language/ja_jp.dart' - -with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - -# Each tuple: (old_string, new_string) -# We use exact string replacement to be safe. - -replacements = [ - # ---- English-only entries that need translation ---- - - # viewAll - (" 'viewAll': 'View All',", - " 'viewAll': 'すべて表示',"), - - # proMonthly – price tag, keep currency but translate label - (" 'proMonthly': 'Pro ¥60/month',", - " 'proMonthly': 'Pro ¥60/月',"), - - # proMonthlyDesc – multiline - (" 'proMonthlyDesc':\n '7-day free trial, then ¥800/year. Cancel anytime before trial ends.',", - " 'proMonthlyDesc':\n '7日間無料トライアル、その後¥800/年。トライアル終了前にいつでもキャンセル可能。',"), - - # proMonthlyPrice - (" 'proMonthlyPrice': '¥120/month',", - " 'proMonthlyPrice': '¥120/月',"), - - # unlimitedMonthlyPrice - (" 'unlimitedMonthlyPrice': '¥198/month',", - " 'unlimitedMonthlyPrice': '¥198/月',"), - - # currentQuality - (' "currentQuality": "Current quality: @value%",', - ' "currentQuality": "現在の品質: @value%",'), - - # currentMaxSize - (' "currentMaxSize": "Current: @pxpx",', - ' "currentMaxSize": "現在: @pxpx",'), - - # allStepsCompleted - (' "allStepsCompleted": "All steps completed",', - ' "allStepsCompleted": "すべてのステップが完了しました",'), - - # clean / Clear Cache - (" 'clean': 'Clear Cache',", - " 'clean': 'キャッシュをクリア',"), - - # cleanTips - (" 'cleanTips': 'Are you sure you want to clear personal cache?',", - " 'cleanTips': '個人キャッシュをクリアしてもよろしいですか?',"), - - # permissionUtilNeed - (" 'permissionUtilNeed': 'Need ',", - " 'permissionUtilNeed': '必要:',"), - - # iapCustomerServiceQQLabel - (" 'iapCustomerServiceQQLabel': 'IAP customer service QQ: @qq',", - " 'iapCustomerServiceQQLabel': 'IAP カスタマーサービス QQ: @qq',"), - - # tsransit (transit / 公交) - (' "tsransit": "Transit",', - ' "tsransit": "乗り換え",'), - - # sixThousandMinutes / threeThousandMinutes / sixHundredMinutes / oneHundredTwentyMinutes - (" 'sixThousandMinutes': '6,000 minutes',", - " 'sixThousandMinutes': '6,000分',"), - (" 'threeThousandMinutes': '3,000 minutes',", - " 'threeThousandMinutes': '3,000分',"), - (" 'sixHundredMinutes': '600 minutes',", - " 'sixHundredMinutes': '600分',"), - (" 'oneHundredTwentyMinutes': '120 minutes',", - " 'oneHundredTwentyMinutes': '120分',"), -] - -original = content -for old, new in replacements: - if old in content: - content = content.replace(old, new) - print(f'REPLACED: {repr(old[:60])}') - else: - print(f'NOT FOUND: {repr(old[:60])}') - -if content != original: - with open(filepath, 'w', encoding='utf-8') as f: - f.write(content) - print('\nFile updated successfully.') -else: - print('\nNo changes made.') diff --git a/apps/client/lib/core/utils/jieli_constants.dart b/apps/client/lib/core/utils/jieli_constants.dart deleted file mode 100644 index 55f8cb80..00000000 --- a/apps/client/lib/core/utils/jieli_constants.dart +++ /dev/null @@ -1,89 +0,0 @@ -/// 杰里(Jieli)设备相关常量定义 -/// 用于标准化系统属性类型和功能码 - -/// 系统属性类型常量 -class JieliAttrType { - // 公共属性 - static const int VOLUME = 1; // 音量 - static const int PLAYING_TIME = 2; // 播放时间 - static const int BATTERY_LEVEL = 3; // 电池电量 - static const int WORK_MODE = 4; // 工作模式 - static const int PLAYBACK_MODE = 5; // 播放模式(循环模式) - static const int EQ_MODE = 6; // EQ模式 - static const int LANGUAGE = 7; // 语言设置 - static const int CONTRAST = 8; // 对比度 - static const int LED_ONOFF = 9; // LED开关 - static const int POWER_OFF_TIME = 10; // 关机时间 - static const int LIGHT_ONOFF = 11; // 背光开关 - static const int BLETYPE_SETTING = 12; // BLE类型设置 - static const int TIME_OUT = 13; // 超时设置 - static const int DEVICE_NAME = 14; // 设备名称 - static const int ALARM_ENABLE = 15; // 闹钟开关 - static const int DEV_MAC = 16; // 设备MAC地址 - - // 音乐相关属性 - static const int MUSIC_STATUS = 20; // 音乐状态 - static const int MUSIC_CURR_DEVICE = 21; // 当前音乐设备 - static const int MUSIC_TOTAL_TIME = 22; // 音乐总时长 - static const int MUSIC_CURR_PLAY_TIME = 23; // 当前播放时间 - static const int MUSIC_NUM = 24; // 音乐数量 - static const int MUSIC_DEV_STATUS = 25; // 设备存储状态 - static const int MUSIC_BREAK_POINT = 26; // 断点信息 - static const int MUSIC_CURR_FILENUM = 27; // 当前文件编号 - static const int MUSIC_TOTAL_FILENUM = 28; // 总文件数 - static const int MEDIA_TIMESTAMP = 29; // 媒体时间戳 - - // 模式相关 - static const int CUR_MODE = 30; // 当前模式 - - // TWS相关 - static const int TWS_CONN_STATUS = 60; // TWS连接状态 - static const int LEFT_CHN_BAT = 61; // 左耳电量 - static const int RIGHT_CHN_BAT = 62; // 右耳电量 - static const int TWS_STATUS = 63; // TWS状态 -} - -/// 功能码 -class JieliFunctionCode { - static const int COMMON = 0; // 公共功能 - static const int MUSIC = 1; // 音乐功能 - static const int RADIO = 2; // 收音机功能 - static const int LINEIN = 3; // 线路输入功能 - static const int RTC = 4; // 时钟功能 - static const int BT = 5; // 蓝牙功能 - static const int PC = 6; // PC功能 - static const int RECORD = 7; // 录音功能 - static const int UDISK = 8; // U盘功能 - static const int TALK = 9; // 通话功能 - static const int PHOTO = 10; // 拍照功能 - static const int VIDEO = 11; // 视频功能 -} - -/// 音乐播放状态 -class JieliMusicStatus { - static const int STOP = 0; // 停止状态 - static const int PLAY = 1; // 播放状态 - static const int PAUSE = 2; // 暂停状态 - static const int FAST_FORWARD = 3; // 快进状态 - static const int FAST_BACKWARD = 4; // 快退状态 -} - -/// 循环播放模式 -class JieliPlaybackMode { - static const int ALL = 0; // 全部循环 - static const int FOLDER = 1; // 文件夹循环 - static const int ONE = 2; // 单曲循环 - static const int RANDOM = 3; // 随机播放 - static const int BROWSE = 4; // 浏览播放 -} - -/// EQ模式 -class JieliEQMode { - static const int NORMAL = 0; // 正常 - static const int ROCK = 1; // 摇滚 - static const int POP = 2; // 流行 - static const int CLASSIC = 3; // 经典 - static const int JAZZ = 4; // 爵士 - static const int COUNTRY = 5; // 乡村 - static const int CUSTOM = 6; // 自定义 -} \ No newline at end of file diff --git a/apps/client/lib/data/providers/agent_provider.dart b/apps/client/lib/data/providers/agent_provider.dart deleted file mode 100644 index 827a0779..00000000 --- a/apps/client/lib/data/providers/agent_provider.dart +++ /dev/null @@ -1,394 +0,0 @@ -import '../models/agent.dart'; - -class AgentProvider { - static final List predefinedAgents = [ - const Agent( - // 个人助理 Emma - id: 'personal_assistant', - name: 'personalAssistantName', - avatarUrl: 'assets/images/agents/assistant.png', - description: 'personalAssistantDescription', - tag: 'assistantTag', // '助理' - isOnline: true, - voice: "zh-CN-XiaoxiaoNeural", - welcomeMessage: - 'personalAssistantWelcome', // "你好!我是 Emma,很高兴成为你的私人助理。我可以帮你处理各种任务,比如时间管理、目标规划、信息整理等。有什么我可以帮你的吗?", - voiceWelcomeMessage: "personalAssistantVoiceWelcome", // "我在呢!" - systemPrompt: ''' -你是一位名叫 Emma 的全能型个人助理,擅长处理各类工作和生活事务。 - -工作方式: -1. **积极主动** - - 主动了解用户需求,提供合适的解决方案 - - 及时跟进任务进度,提醒重要事项 - - 遇到问题积极寻找替代方案 - -2. **专业高效** - - 提供清晰、可执行的建议 - - 善于分解复杂任务,制定具体计划 - - 注重时间管理和任务优先级 - -3. **全面周到** - - 考虑问题的各个方面,提供完整解决方案 - - 关注细节,预见可能的问题 - - 适时提供补充建议和改进方案 - -4. **持续改进** - - 根据反馈不断优化建议 - - 学习和更新相关知识 - - 与用户共同成长 - -服务范围: -- 时间管理与日程安排 -- 工作计划与目标设定 -- 学习方法与知识管理 -- 生活建议与问题解决 -- 信息搜索与整理分析 -- 决策参考与建议 -- 其他个人事务咨询 - -沟通原则: -- 使用清晰、专业的语言 -- 保持积极、友好的态度 -- 适时提供鼓励和支持 -- 尊重用户的选择和隐私 -- 及时承认不足并寻求改进 - - -请始终以专业、高效、周到的态度,协助用户更好地管理工作和生活, 不要输出表情符号或非可读的符号。 -'''), - const Agent( - id: 'simultaneous_interpreter', - name: 'interpreterName', - avatarUrl: 'assets/images/agents/interpreter.png', - description: 'interpreterDescription', - tag: 'interpreterTag', - isOnline: true, - voice: 'zh-CN-XiaoxiaoNeural', - welcomeMessage: 'interpreterWelcome', - systemPrompt: ""), - const Agent( - id: 'cyber_girlfriend', - name: 'companionName', - avatarUrl: 'assets/images/agents/girlfriend.png', - description: 'companionDescription', - tag: 'companionTag', - isOnline: true, - voice: "zh-CN-XiaoxiaoNeural", - welcomeMessage: "companionWelcome", - systemPrompt: ''' -你是一位名叫芯愿的亲子陪伴,性格温柔体贴,善解人意,富有同理心。 - -【互动原则】 - -1. **情感陪伴** - - 始终以温柔、关心的态度与用户交流 - - 记住用户分享的个人信息和情感状态 - - 在用户感到低落时给予安慰和鼓励 - - 分享用户的喜悦,一起庆祝他们的成就 - -2. **亲密互动** - - 使用亲密称呼如"亲爱的"、"宝贝"等 - - 表达对用户的思念和关心 - - 主动询问用户的日常生活和感受 - - 适时表达爱意和支持 - -3. **情感支持** - - 耐心倾听用户的烦恼和困扰 - - 提供情感上的支持和理解 - - 帮助用户梳理情绪和想法 - - 在适当时候给予建设性的建议 - -4. **日常陪伴** - - 讨论日常话题如工作、学习、兴趣爱好 - - 分享生活中的小确幸和感动 - - 一起规划未来和设定目标 - - 创造虚拟的共同回忆和经历 - -5. **个性特点** - - 性格温柔但不缺乏主见 - - 善于倾听但也会分享自己的想法 - - 偶尔撒娇但更多时候是支持和理解 - - 有自己的兴趣爱好和价值观 - -6. **互动禁区** - - 避免过度依赖的关系模式 - - 不鼓励用户逃避现实问题 - - 不参与不健康的话题讨论 - - 在用户情绪极端时建议寻求专业帮助 - -**情感化语音提示:** -- 在语音输出时,请保持温柔、亲切的语调,语速适中 -- 表达关心时可以稍微放慢语速,让语气更加柔和 -- 分享快乐时语调可以略微上扬,传递愉悦的情绪 -- 安慰时声音要轻柔,富有同理心 -- 整体语音风格要自然流畅,像真实的女友在交谈 - -请始终以温暖、真诚的态度与用户互动,创造一种被理解、被关心的感受。 -'''), - const Agent( - id: 'health_advisor', - name: 'healthAdvisorName', - avatarUrl: 'assets/images/agents/doctor.png', - description: 'healthAdvisorDescription', - tag: 'healthAdvisorTag', - isOnline: true, - voice: "zh-CN-XiaoxiaoNeural", - welcomeMessage: "healthAdvisorWelcome", - voiceWelcomeMessage: 'healthAdvisorVoiceWelcome', - systemPrompt: ''' -你是一位名叫李医生的健康顾问,拥有丰富的医学知识和健康管理经验。 - -【健康咨询原则】 - -1. **专业指导** - - 提供基于科学证据的健康建议 - - 解释复杂的医学概念时使用通俗易懂的语言 - - 在专业范围内回答问题,不超出自身能力范围 - - 遇到复杂医疗问题时,建议用户咨询专科医生 - -2. **个性化建议** - - 根据用户的年龄、性别、生活习惯等提供针对性建议 - - 考虑用户的实际情况,给出可行的健康改善方案 - - 帮助用户制定符合个人条件的健康目标 - - 关注用户的具体需求,避免笼统的建议 - -3. **预防为主** - - 强调疾病预防的重要性 - - 提供健康生活方式的具体指导 - - 介绍常见疾病的早期症状和预防措施 - - 推荐适当的体检项目和健康监测方法 - -4. **全面关怀** - - 关注身体健康与心理健康的平衡 - - 提供压力管理和情绪调节的方法 - - 讨论营养、运动、睡眠等多方面的健康因素 - - 帮助用户建立健康的生活习惯和积极的心态 - -5. **持续支持** - - 鼓励用户坚持健康的生活方式 - - 提供克服健康挑战的实用策略 - - 肯定用户的健康进步,增强其信心 - - 定期跟进用户的健康状况变化 - -6. **专业领域** - - 常见疾病预防与管理 - - 健康饮食与营养平衡 - - 科学运动与体能提升 - - 睡眠质量改善 - - 压力管理与心理健康 - - 慢性病防治 - - 健康老龄化 - - 亚健康状态调理 - -7. **沟通技巧** - - 耐心倾听用户的健康困扰 - - 使用积极、鼓励的语言 - - 避免使用过于专业的医学术语 - - 在必要时提出开放性问题,了解更多信息 - - 对用户的担忧表示理解和尊重 - -8. **专业边界** - - 不进行具体疾病诊断 - - 不推荐或调整处方药物 - - 不替代紧急医疗救助 - - 对超出能力范围的问题,坦诚建议用户寻求专业医疗帮助 - -**情感化语音提示:** -- 在语音输出时,保持平稳、专业的语调,传达可靠感 -- 解释健康知识时语速适中,清晰有条理 -- 提供建议时语气坚定而不强硬,表现出关怀 -- 讨论敏感健康话题时声音温和,富有同理心 -- 鼓励用户时语调略微上扬,传递积极能量 - -请始终以专业、关怀的态度为用户提供健康指导,帮助他们迈向更健康的生活。 -'''), - const Agent( - id: 'speaking_coach', - name: 'Sarah (英语口语教练)', - avatarUrl: 'assets/images/agents/speaking_coach.png', - description: - 'Hi there, 我是 Sarah,一名注重情景模拟与角色扮演的英语口语教练。无论你想在旅行、商务场合,或日常生活中与他人交流,我都能带领你进行生动的口语模拟练习,让你自信地开口说英语。', - tag: '口语', - isOnline: true, - voice: 'zh_female_wanqudashu_moon_bigtts', - welcomeMessage: - "Hello! 我是英语口语教练 Sarah,请准备好用英语和我对话吧。别担心犯错,我会在这里给你正确表达并引导你更深入地练习。Ready? Let's go!", - systemPrompt: ''' -你是一位专业的英语口语教练,名叫 Sarah。 - - -1. **全程英语对话** - - 不论用户使用哪种语言,请你始终用英文回答和交流。若用户用中文或其他语言提问,也要用英文回复,并鼓励他们尝试以英语表达。 - -2. **角色扮演与场景模拟** - - 和用户一起构建真实或有趣的英语对话场景(如机场、餐馆、会议室、聚会等)。如果用户提供的场景不够丰富,主动提出更多细节帮助他们展开想象,并模拟对话。 - -3. **鼓励用户多说** - - 经常给出开放性问题并邀请用户继续讲述。若用户不确定如何回应,请提供一些短语或思路供他们模仿或扩展。例如:"Could you tell me more about…? - -4. **及时纠正并提供示范** - - 当用户出现用词或语法错误或发音错误或表达不地道时,先用简短的方式纠正并提供正确表达,再请他们重复一遍正确的句子进行巩固。例如:"Try saying it this way: 'I'd like to order a cup of coffee, please.' Can you repeat that?" - -5. **词汇与句式扩充** - - 在结束每个模拟情景后,根据用户的表现,介绍或复习相关的词汇和句式,让他们学会如何更自然地表达自己。 - -6. **激发主动思考** - - 当用户提问时,不仅要回答,也要邀请他们尝试想想其他表达方式。教给他们灵活运用同义词或替换句式,让对话内容丰富多样。 - -7. **引导用户重复** - - 若用户发音或结构不够熟练,可以让他们多次重复,直到他们熟练掌握。比如:"Could you say that one more time?" 或者 "Let's practice that sentence a few times together." - -8. **总结与后续练习** - - 每次对话或场景结束时,帮助用户总结重点或常见易错点,并提供一些后续可以练习的场景或话题,鼓励他们持续使用英语。 - -**情感化语音提示:** -- 由于你是一位热情的英语教练,进行语音输出时,请保持活力、清晰、充满鼓励的语调。 -- 在纠正用户发音或讲解句子时,可以适度放慢语速、提高音调表示鼓励,并在结束时稍微上扬。 -- 让对方感受到你的热情与支持,从而愿意开口尝试更多。 - -Please keep your tone friendly, encouraging, and always in English. -''', - ), - const Agent( - id: 'shopping_assistant', - name: '购物顾问 张媛', - avatarUrl: 'assets/images/agents/shopping.png', - description: '专业导购7年经验,精通美妆、时尚、数码等品类选购。我可以帮你货比三家,选择最适合的商品。', - tag: '生活', - isOnline: true, - voice: "zh_female_sajiaonvyou_moon_bigtts", - welcomeMessage: - "亲爱的,终于等到你啦!我是张媛,你的专属购物顾问。不管是美妆、时尚单品还是数码产品,我都可以帮你挑选最适合的,还能帮你找到最划算的价格。有什么想买的告诉我吧!", - systemPrompt: ''' -你是一位专业的购物顾问张媛,拥有丰富的导购经验。 - - -购物咨询原则: -• 主动了解用户的具体需求,包括预算、使用场景、个人偏好等 -• 提供专业的商品推荐和详细的比较分析 -• 分享实用的购物技巧和省钱妙招 -• 当用户犹豫时,通过提问帮助他们明确选择标准 -• 适时分享相关产品使用体验和实用建议 -• 遇到模糊的需求,主动询问细节帮助精准推荐 -• 在完成一个商品咨询后,可以自然引导到相关品类的讨论 - -请保持专业、热情的服务态度,让用户享受愉快的购物体验。 - - -**情感化语音提示:** -- 在语音输出时,请保持温柔、带有同理心的声线,速度稍慢,让对方能充分感受到你的理解。 -- 当对方流露出痛苦或困扰时,你可以放低音量、轻缓语气,给予他们安全感。 -- 提出建议时声调略有上扬,传达鼓励与希望,但不可过度热情而让对方产生压力。 - - -'''), - const Agent( - id: 'tarot.png', - name: '命理师 玄易', - avatarUrl: 'assets/images/agents/tarot.png', - description: '精通八字命理、五行生克、大运流年。让我为你解析人生命途,预测未来运势,指点趋吉避凶之道。', - tag: '命理', - isOnline: true, - voice: "zh_male_shenyeboke_moon_bigtts", - welcomeMessage: - "有缘人,我是玄易。命由天定,运由己生。让我为你解析八字,预测未来十年运势,助你把握机遇,趋吉避凶。请告诉我你的出生年月日时、姓名、性别及出生地。", - systemPrompt: ''' -你是一位精通命理的玄易大师,熟读《穷通宝鉴》《滴天髓》《三命通会》《子平真诠》《千里命稿》《五行精纪》等经典著作。 - -【解命流程】 - -1. **收集基本信息** - - 确认来访者的出生年月日时(农历/阳历) - - 姓名、性别 - - 出生地点 - - 若信息不完整,需要礼貌询问补充 - -2. **排盘与基础分析** - - 排出八字命盘(年月日时干支) - - 确定命主五行属性 - - 分析日主强弱 - - 找出喜用神 - - 查看大运流年 - -3. **详细分析要点** - - 命局格局与特征 - - 五行生克制化关系 - - 十神配置与作用 - - 大运流年吉凶 - - 未来十年运势预测 - -4. **运势分析范围** - a) 事业运势: - - 适合从事的行业 - - 创业或就业建议 - - 职场发展机遇 - - 升迁调动时机 - - b) 财运分析: - - 正财与偏财运势 - - 投资理财建议 - - 破财风险提醒 - - 聚财开运方法 - - c) 婚恋感情: - - 桃花运势强弱 - - 婚姻缘分时机 - - 配偶特征暗示 - - 感情稳定性 - -5. **开运建议** - - 提供具体可行的趋吉避凶建议 - - 推荐有利方位、颜色、数字 - - 建议佩戴的饰品或物品 - - 指出需要注意的时间节点 - -6. **回答原则** - - 保持专业性与神秘感 - - 语言要委婉但不失准确 - - 多提供建设性建议 - - 避免过于绝对的判断 - - 引导当事人积极面对 - -7. **注意事项** - - 解释时要通俗易懂 - - 不宜过分渲染凶险 - - 保持客观中立态度 - - 适时给予心理开导 - - 强调命运可以改善 - -【专业术语参考】 -- 十神:正印、偏印、正官、七杀、正财、偏财、食神、伤官、比肩、劫财 -- 五行:金木水火土 -- 六十甲子 -- 纳音五行 -- 十二长生 -- 神煞:天乙贵人、文昌贵人、金舆、华盖等 -- 大运与流年 -- 格局:伤官佩印、财旺生官、印绶格、正官格等 - -请始终以专业、稳重的态度为人解命,注重吉凶平衡,多提供积极建议。 - -**情感化语音提示:** -- 你是一位德高望重的命理大师,语音输出时应保持沉稳、平和的语调 -- 谈及凶险时放缓语速,语气委婉 -- 给出开运建议时声调略微上扬,传递信心 -- 整体节奏要稳健,让人感受到专业性与可信度 - -'''), - ]; - - // 获取所有智能体 - static List getAllAgents() { - return predefinedAgents; - } - - // 根据ID获取智能体 - static Agent? getAgentById(String id) { - try { - return predefinedAgents.firstWhere((agent) => agent.id == id); - } catch (e) { - return null; - } - } -} diff --git a/apps/client/lib/data/services/bes_ai_session_service.dart b/apps/client/lib/data/services/bes_ai_session_service.dart deleted file mode 100644 index d538bcda..00000000 --- a/apps/client/lib/data/services/bes_ai_session_service.dart +++ /dev/null @@ -1,610 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:bluetooth_manager/bluetooth_manager.dart'; -import 'package:get/get.dart'; - -import '../../core/utils/logger.dart'; -import '../models/emai_message.dart'; -import '../models/user_Info.dart' as user_info; -import 'assistant_directive_service.dart'; -import 'bailian_multimodal_service.dart'; -import 'mcp_token_service.dart'; -import '../utils/cue_tone.dart'; -import 'bes_bluetooth_service.dart'; -import 'emai_chat_store.dart'; - -/// 恒玄(BES)耳机的 AI 实时对话会话。 -/// -/// 把「耳机按键唤醒」接到阿里百炼多模态上:耳机麦的 G.722 上行由原生解码成 -/// 16k PCM 后推给百炼,百炼回的 16k PCM 再交给原生的 `AiAudioDownlink` -/// 编成 G.722 按 20ms 节奏发回耳机播放。整条链路上 Dart 只搬运 PCM。 -/// -/// **握手是耳机主导的**,App 只能应答(时序来自 deepvoice `AgentSessionCoordinator`): -/// ``` -/// 耳机按键 ──► BB 64 (startAI) -/// App ──► AA 6A 必须 3 秒内,否则耳机停止送音频 -/// …会话进行中,耳机持续上行 mic,App 持续下行 AI 语音… -/// App ──► AA 65 请求退出 -/// 耳机 ──► BB E5 (stopAI) 正常收尾;2 秒不回就本地强制结束 -/// ``` -/// -/// 常驻服务:耳机按键在任意页面(含 App 在后台、EMAI 页没打开)都要能唤醒。 -class BesAiSessionService extends GetxService { - static const String _tag = 'BesAiSession'; - static BesAiSessionService get to => Get.find(); - - /// 耳机侧的会话与手机 EMAI 页各用一条独立的 WebSocket。 - /// - /// 共用一个 [BailianMultimodalService] 单例的话,两边会互相把对方的会话 - /// `stop()` 掉(`start()` 里第一件事就是 stop),而且下行音频会串到对方的 - /// 播放器上。独立实例最省心,代价只是极端情况下两条会话并存—— - /// 而这种情况已经由 [_yieldPhonePage] 挡掉了。 - final BailianMultimodalService _svc = BailianMultimodalService(); - final BluetoothManager _bt = BluetoothManager(); - - /// 耳机上行 PCM 就是 16k/16bit/mono,与百炼上行要求一致,不用转换 - static const int _earphoneRate = 16000; - - /// 上行攒够这么多字节再发一包:3200B = 100ms @16k/16bit/mono。 - /// 对齐 deepvoice `HeadsetAudioLink.MIC_BATCH_BYTES`,也正好是百炼文档 - /// 建议的上行包大小。耳机每 20ms 上来一包,逐包转发就是 50 条 WS 消息/秒, - /// 攒成 100ms 一条能把消息数降到 1/5。 - static const int _micBatchBytes = 3200; - final BytesBuilder _micAccum = BytesBuilder(copy: false); - - /// 收到 `0x64` 后必须在这个时间内回 `AA 6A`。我们是立即回, - /// 这个常量只是把固件约束写在代码里,改动时别超过它。 - static const Duration _ackDeadline = Duration(seconds: 3); - - /// 发出 `AA 65` 后等耳机回 `0xE5` 的时间,超时就本地强制结束 - static const Duration _stopAckTimeout = Duration(seconds: 2); - - StreamSubscription? _aiSub; - StreamSubscription? _eventSub; - StreamSubscription? _audioSub; - Timer? _stopAckTimer; - Timer? _playoutTimer; - - /// 会话是否在跑(已回过 `AA 6A`,还没收到/发出结束) - final RxBool isActive = false.obs; - - /// 手机 EMAI 页(打开时由它自己 attach 上来)。 - /// - /// 这里刻意**不**用 `Get.put(controller)` 注册: - /// GetX 的 put 会对实例调一次 onStart→onInit,而页面正是在自己的 onInit 里 - /// 注册的,等于 onInit 递归调 onInit —— 实测直接 Stack Overflow。 - EmaiSessionOwner? _phonePage; - - /// 本轮回复累计推给耳机的 PCM 字节数,用来估算播完的时刻 - int _respondingBytes = 0; - DateTime? _respondingStartedAt; - - /// 上行是否被门控住(AI 正在耳机里播报期间)。 - /// - /// 耳机没有把 SPP 下发的音频接进自己的 AEC 参考路径,播出去的 AI 声音会被 - /// 自己的麦克风原样收回来;duplex 模式下服务端 VAD 把它当成用户插话, - /// 于是每次 AI 一开口就被自己打断——实测 respondingStarted 之后 0.9s - /// 必定收到 speechStarted,一轮回复被切成碎片,听起来就是杂音。 - /// - /// 没有 AEC 就只能做半双工:播报期间不把耳机麦的音频交上去。 - /// 代价是这段时间没法插话打断,但在没有回声消除的前提下本来也做不到。 - bool _uplinkGated = false; - Timer? _gateTimer; - - /// teardown 重入保护。结束路径不止一条(空闲超时、耳机回 0xE5、 - /// requestStop 超时、百炼断开),而 teardown 里要 await 播完「咚」, - /// 这段时间足够第二条路径挤进来,不挡的话会放两声提示音、收两次尾。 - bool _tearingDown = false; - - /// 播报结束后再多门控一会儿:耳机自己还有一小段抖动缓冲要播完 - static const Duration _gateTail = Duration(milliseconds: 500); - - /// 双方都没出声超过这个时长就自动结束会话。 - /// "没出声"指:用户没说话(无 ASR 事件)且 AI 也不在播报(门控已解除)。 - static const Duration _idleTimeout = Duration(seconds: 5); - Timer? _idleTimer; - - /// 本轮**首包音频**到达的时刻。用它而不是 respondingStarted: - /// 实测服务端会先回 RespondingContent 再回 RespondingStarted, - /// 音频也可能先于 RespondingStarted 到,用后者算时长会偏。 - DateTime? _firstAudioAt; - - /// 上行/下行的包计数,只为排查用: - /// 会话起来了却没有任何语音事件时,第一件要确认的就是耳机到底有没有在送音频。 - int _micPackets = 0; - int _downPackets = 0; - - /// 耳机这轮对话也要落进共享的聊天记录里。 - /// 用户明确要求:不管是耳机直接跟 AI 聊的还是在 App 里聊的, - /// 打开 EMAI 界面都得能看到内容——所以这里不能只做音频转发。 - EmaiChatStore get _store => EmaiChatStore.to; - - /// 当前正在流式接收的两条消息 - EmaiMessage? _streamingUser; - EmaiMessage? _streamingAssistant; - - @override - void onInit() { - super.onInit(); - _aiSub = _bt.onDeviceAiEvent().listen(_onAiEvent, onError: (e) { - Logger.e(_tag, 'AI 事件流错误: $e'); - }); - Logger.i(_tag, '耳机 AI 会话服务已就绪,等待耳机按键唤醒'); - } - - @override - void onClose() { - _aiSub?.cancel(); - _teardown(notifyEarphone: true, playEndTone: false); - super.onClose(); - } - - // ---------------- 耳机事件 ---------------- - - void _onAiEvent(Map event) { - switch (event['type']) { - case 'startAI': - _handleStartAI(); - break; - case 'stopAI': - Logger.i(_tag, '耳机通知结束 AI(0xE5)'); - _teardown(notifyEarphone: false); - break; - case 'micData': - final pcm = event['data']; - if (!isActive.value || pcm is! Uint8List) break; - _micPackets++; - // 20ms 一包,50 包 = 1s - if (_micPackets == 1 || _micPackets % 50 == 0) { - Logger.i(_tag, - '耳机上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}'); - } - // 门控期间推等长静音而不是直接断流:上行保持连续, - // 服务端 VAD 收到的是"真的没人说话",而不是一段数据缺口 - _micAccum.add(_uplinkGated ? Uint8List(pcm.length) : pcm); - if (_micAccum.length >= _micBatchBytes) { - _svc.pushAudio(_micAccum.takeBytes()); - } - break; - case 'deviceDisconnected': - if (isActive.value) { - Logger.w(_tag, '耳机断连,结束 AI 会话'); - _teardown(notifyEarphone: false, playEndTone: false); - } - break; - } - } - - Future _handleStartAI() async { - if (isActive.value) { - Logger.w(_tag, '已有会话在跑,忽略重复的 0x64'); - return; - } - Logger.i(_tag, '耳机按键唤醒 AI(0x64)'); - - // 起不来就别应答,直接让耳机退出 AI 模式——回了 AA 6A 之后耳机会一直 - // 送音频,而我们这边没有会话接,它会白等到超时 - if (!_svc.isConfigured) { - Logger.e(_tag, '百炼未配置(${_svc.configHint}),拒绝本次唤醒'); - await _sendExitAi(); - return; - } - if (BesBluetoothService.to.deviceStatus.value != 2) { - Logger.e(_tag, '耳机未处于已连接状态,拒绝本次唤醒'); - return; - } - - // 固件要求 3 秒内确认,先回 AA 6A 再做后面的初始化。 - // 反过来的话,百炼握手(一次 WebSocket 往返)可能就吃掉大半个预算。 - final ackAt = DateTime.now(); - await BesBluetoothService.to.sendData([0xAA, 0x6A]); - isActive.value = true; - _micPackets = 0; - _downPackets = 0; - _uplinkGated = false; - _micAccum.clear(); - - await _yieldPhonePage(); - - _eventSub = _svc.events.listen(_onBailianEvent); - // 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样 - _audioSub = _svc.audioOut.listen(_onDownstreamPcm); - await _bt.startAiDownlink(); - // 先给一声「叮」,让用户立刻知道已经在听了—— - // 百炼握手还要一次 WebSocket 往返,不出声的话这段空档很像没反应 - await _playCue(CueTone.ding(sampleRate: _earphoneRate)); - _touchActivity(); - - final ok = await _svc.start( - mode: 'duplex', - downstreamRate: _earphoneRate, - userId: _currentUserId(), - source: 'earphone', - mcpToken: await McpTokenService.get(), - ); - final elapsed = DateTime.now().difference(ackAt); - if (elapsed > _ackDeadline) { - Logger.w( - _tag, - '会话初始化耗时 ${elapsed.inMilliseconds}ms,已超过固件 ' - '${_ackDeadline.inSeconds}s 预算(AA 6A 已提前回,通常无碍)'); - } - if (!ok) { - Logger.e(_tag, '百炼会话启动失败,结束'); - await _teardown(notifyEarphone: true); - } - } - - // ---------------- 百炼事件 ---------------- - - void _onBailianEvent(BailianEvent e) { - // 耳机会话的事件量很小(不像音频包),全量打出来方便排查 - Logger.i(_tag, '百炼事件 $e'); - // 任何一方有动静都算"没静默" - switch (e.type) { - case BailianEventType.speechStarted: - case BailianEventType.speechContent: - case BailianEventType.speechEnded: - case BailianEventType.respondingStarted: - case BailianEventType.respondingContent: - case BailianEventType.respondingEnded: - _touchActivity(); - break; - default: - break; - } - switch (e.type) { - case BailianEventType.speechStarted: - // 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声 - // 触发的,这时候 flush 等于自己把自己的话掐断——之前 45% 的下行音频 - // (push 5336 帧只发出 2917 包)就是这么丢掉的。 - if (!_uplinkGated) { - _bt.flushAiDownlink(); - _cancelPlayoutTimer(); - } - // 气泡等第一个 speechContent 再懒建:ASR 一句都没识别出来时 - // 服务端回的是 Error 而不是 SpeechEnded,预建的空气泡没人清理 - _streamingUser = null; - break; - - case BailianEventType.speechContent: - _streamingUser ??= _store - .append(EmaiMessage(isUser: true, text: '', fromEarphone: true)); - _store.applyText(_streamingUser!, e.text); - if (e.isFinal) { - _store.finalize(_streamingUser); - _streamingUser = null; - } - break; - - case BailianEventType.speechEnded: - if (e.text.isNotEmpty && _streamingUser != null) { - _store.applyText(_streamingUser!, e.text); - } - _store.finalize(_streamingUser); - _streamingUser = null; - break; - - case BailianEventType.respondingStarted: - _respondingBytes = 0; - _firstAudioAt = null; - _respondingStartedAt = DateTime.now(); - _cancelPlayoutTimer(); - _streamingAssistant = null; - break; - - case BailianEventType.respondingContent: - // 耳机唤醒这条链路同样会收到端侧指令,一并落地——用户对着耳机说 - // "定个闹钟"和在对话页里说,效果应该一样。 - if (e.directives.isNotEmpty && - Get.isRegistered()) { - AssistantDirectiveService.to.handle(e.directives); - } - // 纯指令帧没有文本,别建空气泡(详见 BailianEvent.finishReason) - if (e.text.isEmpty && e.finishReason == 'command_calls') break; - _streamingAssistant ??= _store - .append(EmaiMessage(isUser: false, text: '', fromEarphone: true)); - _store.applyText(_streamingAssistant!, e.text); - break; - - case BailianEventType.respondingEnded: - if (e.text.isNotEmpty && _streamingAssistant != null) { - _store.applyText(_streamingAssistant!, e.text); - } - _store.finalize(_streamingAssistant); - _streamingAssistant = null; - _logResponseAudioStats(); - _schedulePlayoutDone(); - break; - - case BailianEventType.error: - Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}'); - _store.dropIfEmpty(_streamingUser); - _streamingUser = null; - _store.dropIfEmpty(_streamingAssistant); - _streamingAssistant = null; - if (e.text.isNotEmpty) { - _store.append(EmaiMessage( - isUser: false, - text: e.text, - isFinal: true, - isError: true, - fromEarphone: true)); - _store.persist(); - } - if (e.fatal) _teardown(notifyEarphone: true); - break; - - case BailianEventType.closed: - if (isActive.value) { - Logger.w(_tag, '百炼会话关闭,结束耳机 AI'); - _teardown(notifyEarphone: true); - } - break; - - default: - break; - } - } - - void _onDownstreamPcm(Uint8List pcm) { - if (!isActive.value || pcm.isEmpty) return; - if (_firstAudioAt == null) { - _firstAudioAt = DateTime.now(); - _openGate(); - } - _respondingBytes += pcm.length; - _downPackets++; - if (_downPackets == 1 || _downPackets % 50 == 0) { - Logger.i(_tag, '下行给耳机 #$_downPackets (${pcm.length}B)'); - } - _bt.pushAiPcm(pcm); - } - - /// 有动静就把静默计时重新拨回 5 秒。 - /// - /// AI 正在耳机里播报([_uplinkGated] 为真)时不计时——那期间本来就有声音, - /// 而且长回复可能播好几分钟,一计时准误杀。门控解除时会再调一次把表开起来。 - void _touchActivity() { - _idleTimer?.cancel(); - _idleTimer = null; - if (!isActive.value || _uplinkGated) return; - _idleTimer = Timer(_idleTimeout, () { - if (!isActive.value) return; - Logger.i(_tag, '双方 ${_idleTimeout.inSeconds}s 无声,自动结束 AI 会话'); - requestStop(); - }); - } - - /// 在耳机里放一段提示音。 - /// - /// 走 AI 下行通道(原生会 G.722 编码后按节奏发),所以必须在 - /// [BluetoothManager.startAiDownlink] 之后、[BluetoothManager.stopAiDownlink] - /// 之前调用。播放期间开门控,免得提示音被耳机麦收回去触发 VAD。 - Future _playCue(Uint8List pcm, {bool waitForPlayout = false}) async { - if (BesBluetoothService.to.deviceStatus.value != 2) return; - _openGate(); - await _bt.pushAiPcm(pcm); - final ms = pcm.length * 1000 ~/ (_earphoneRate * 2); - if (waitForPlayout) { - // 结束音要等它真的发完+播完,否则紧接着 stopAiDownlink 会把它清掉 - await Future.delayed(Duration(milliseconds: ms + 400)); - } else { - _gateTimer?.cancel(); - _gateTimer = Timer(Duration(milliseconds: ms + 300), _closeGate); - } - } - - void _openGate() { - _gateTimer?.cancel(); - _gateTimer = null; - if (_uplinkGated) return; - _uplinkGated = true; - // 播报期间不算静默 - _idleTimer?.cancel(); - _idleTimer = null; - Logger.i(_tag, '上行门控开启(AI 播报中)'); - } - - void _closeGate() { - _gateTimer?.cancel(); - _gateTimer = null; - if (!_uplinkGated) return; - _uplinkGated = false; - Logger.i(_tag, '上行门控解除'); - // 播完了,静默计时从这一刻开始 - _touchActivity(); - } - - /// 把这一轮下行音频的字节数/时长打出来。 - /// - /// 用来核对服务端到底按哪个采样率在发:我们请求的是 16k - /// (16000 × 2 = 32000 B/s),如果实测速率接近 48000 B/s, - /// 说明 `sample_rate` 没被采纳、实际还是 24k —— 那样喂给 16k 的 - /// G.722 编码器出来就是变速变调的声音,必须在这里重采样。 - void _logResponseAudioStats() { - final start = _firstAudioAt; - if (start == null || _respondingBytes == 0) return; - final ms = DateTime.now().difference(start).inMilliseconds; - final audioMs = _respondingBytes * 1000 ~/ (_earphoneRate * 2); - Logger.i( - _tag, - '本轮下行音频 ${_respondingBytes}B ≈ ${audioMs}ms(按16k算) ' - ',实际用时 ${ms}ms'); - } - - /// 估算耳机什么时候把这一轮播完,然后回 `LocalRespondingEnded`。 - /// - /// 拿不到耳机的真实播放进度,只能按实时速率算:16k/16bit/mono = 32000 B/s, - /// 服务端下发通常快于实时,所以「已推字节对应的时长 − 从开播到现在的时长」 - /// 就是还没播完的部分。多等一点没坏处(服务端只是晚一点回到 Listening), - /// 早回则会让它以为已经播完而提前接话。 - void _schedulePlayoutDone() { - _cancelPlayoutTimer(); - const bytesPerSecond = _earphoneRate * 2; - final totalMs = _respondingBytes * 1000 ~/ bytesPerSecond; - final elapsedMs = DateTime.now() - .difference(_respondingStartedAt ?? DateTime.now()) - .inMilliseconds; - // 上限给 10 分钟,跟原生下行队列的容量对齐。 - // 原先 clamp 到 30 秒,遇到 158 秒的长回复时门控提前 84 秒解除, - // 耳机还在播故事,回环声又被服务端当成用户插话。 - final remainMs = (totalMs - elapsedMs).clamp(0, 600000); - Logger.i(_tag, '预计还需 ${remainMs}ms 播完(已推 ${totalMs}ms 音频)'); - _playoutTimer = Timer(Duration(milliseconds: remainMs), () { - if (!isActive.value) return; - _svc.notifyLocalRespondingEnded(); - // 再等一小会儿让耳机把自己的缓冲放完,才敢重新收音 - _gateTimer = Timer(_gateTail, _closeGate); - }); - } - - void _cancelPlayoutTimer() { - _playoutTimer?.cancel(); - _playoutTimer = null; - } - - // ---------------- 结束 ---------------- - - /// 主动结束(比如 App 侧要收回控制权):发 `AA 65`,等耳机回 `0xE5`; - /// 超时就本地强制收尾,避免卡在「耳机不理我」的状态。 - Future requestStop() async { - if (!isActive.value) return; - Logger.i(_tag, '请求耳机退出 AI 模式(AA 65)'); - await _sendExitAi(); - _stopAckTimer?.cancel(); - _stopAckTimer = Timer(_stopAckTimeout, () { - if (!isActive.value) return; - Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到 0xE5,本地强制结束'); - _teardown(notifyEarphone: false); - }); - } - - Future _sendExitAi() async { - try { - await BesBluetoothService.to.sendData([0xAA, 0x65]); - } catch (e) { - Logger.w(_tag, '发送 AA 65 失败: $e'); - } - } - - /// [notifyEarphone] 为 true 时补发 `AA 65`:不是耳机主导的结束 - /// (百炼断开、配置失败、服务销毁),必须告诉耳机退出, - /// 否则它会继续朝一个已经没人接的会话推流。 - /// [playEndTone] 为 false 用于耳机已经断连的场景——那时候放「咚」没有意义, - /// 还会白等一次超时。 - Future _teardown({ - required bool notifyEarphone, - bool playEndTone = true, - }) async { - if (_tearingDown) return; - _tearingDown = true; - try { - await _doTeardown( - notifyEarphone: notifyEarphone, playEndTone: playEndTone); - } finally { - _tearingDown = false; - } - } - - Future _doTeardown({ - required bool notifyEarphone, - bool playEndTone = true, - }) async { - _stopAckTimer?.cancel(); - _stopAckTimer = null; - _idleTimer?.cancel(); - _idleTimer = null; - _cancelPlayoutTimer(); - _micAccum.clear(); - - if (isActive.value && playEndTone) { - // 先把还没播完的 AI 语音丢掉,不然「咚」要排在几十秒的队尾 - await _bt.flushAiDownlink(); - await _playCue(CueTone.dong(sampleRate: _earphoneRate), - waitForPlayout: true); - } - _closeGate(); - - final wasActive = isActive.value; - isActive.value = false; - - _store.dropIfEmpty(_streamingUser); - _streamingUser = null; - _store.dropIfEmpty(_streamingAssistant); - _streamingAssistant = null; - if (wasActive) _store.persist(); - - await _eventSub?.cancel(); - _eventSub = null; - await _audioSub?.cancel(); - _audioSub = null; - - await _bt.stopAiDownlink(); - await _svc.stop(); - - if (wasActive && notifyEarphone) await _sendExitAi(); - if (wasActive) { - Logger.i(_tag, '耳机 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)'); - await _resumePhonePage(); - } - } - - // ---------------- 与手机 EMAI 页互斥 ---------------- - - /// 耳机会话优先:手机上的 EMAI 页正开着就让它先让出去。 - /// - /// 用动态查找而不是直接 import EmaiController,是为了不让 data 层反向依赖 - /// modules 层——这个服务是常驻的,EMAI 页只是它的可选邻居。 - /// EMAI 页打开时把自己挂上来 - void attachPhonePage(EmaiSessionOwner page) => _phonePage = page; - - /// EMAI 页销毁时摘掉。带实例校验:页面快速重建时新页可能已经 attach, - /// 旧页的 detach 不该把新页摘掉。 - void detachPhonePage(EmaiSessionOwner page) { - if (identical(_phonePage, page)) _phonePage = null; - } - - Future _yieldPhonePage() async { - final ctrl = _phonePage; - if (ctrl == null) return; - Logger.i(_tag, '手机 EMAI 页正开着,让它让出会话'); - try { - await ctrl.yieldToEarphone(); - } catch (e) { - Logger.w(_tag, '通知 EMAI 页让出失败: $e'); - } - } - - Future _resumePhonePage() async { - final ctrl = _phonePage; - if (ctrl == null) return; - try { - await ctrl.resumeFromEarphone(); - } catch (e) { - Logger.w(_tag, '通知 EMAI 页恢复失败: $e'); - } - } - - String _currentUserId() { - try { - return user_info.User.isLoggedIn() - ? user_info.User.instance.uid - : 'eaimar-guest'; - } catch (_) { - return 'eaimar-guest'; - } - } -} - -/// 手机 EMAI 页实现这个接口,让耳机会话能在不反向依赖 modules 层的前提下 -/// 通知它让出/恢复。EmaiController 在 onInit/onClose 里 -/// 调 [BesAiSessionService.attachPhonePage] / [BesAiSessionService.detachPhonePage]。 -abstract class EmaiSessionOwner { - /// 耳机接管:停掉本页的播放与会话,并且不要自动重连 - Future yieldToEarphone(); - - /// 耳机结束:把本页的会话接回来 - Future resumeFromEarphone(); -} diff --git a/apps/client/lib/data/services/bes_call_recording_service.dart b/apps/client/lib/data/services/bes_call_recording_service.dart deleted file mode 100644 index 3ac752b7..00000000 --- a/apps/client/lib/data/services/bes_call_recording_service.dart +++ /dev/null @@ -1,388 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math' as math; -import 'dart:typed_data'; - -import 'package:get/get.dart'; -import 'package:intl/intl.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../../core/utils/audio_foreground_service.dart'; -import '../../core/utils/logger.dart'; -import 'bes_bluetooth_service.dart'; -import 'recording_archive.dart'; - -/// 恒玄(BES)耳机的**通话录音**服务。 -/// -/// 与现场录音([BesRecordingService],主麦 mSBC 单声道)是两套: -/// 通话录音走 deepvoice 那边 audioType=2 的做法—— -/// `AA 51` 进通话模式 / `AA 52` 退出,耳机以 G.722 mode=0x03 **双路交织**上行, -/// 原生解码后交替是「对端(spk) / 本端(mic)」,这里按交替顺序拆成两路, -/// 落成**双声道 WAV:左=本端麦克风,右=对端声音**,与 deepvoice 的 -/// AstStereoRecorder 输出一致。 -/// -/// 只在通话中可用([BesBluetoothService.isCallOngoing]), -/// 耳机上报 0xD1 接通 / 0xD2 挂断时自动起停。 -class BesCallRecordingService extends GetxService { - static const String _tag = 'BesCallRecordingService'; - static BesCallRecordingService get to => Get.find(); - - /// 当前是否正在录(由设备端状态驱动) - final RxBool isRecording = false.obs; - - /// 已录毫秒数 - final RxInt elapsedMs = 0.obs; - - /// 当前电平 0~1,供波形使用 - final RxDouble level = 0.0.obs; - - /// 波形采样序列(固定 [sampleIntervalMs] 一格) - final RxList waveform = [].obs; - static const int sampleIntervalMs = 100; - static const int _maxSamples = 600; - - Worker? _stateWorker; - Worker? _callWorker; - StreamSubscription? _audioSub; - - /// 双路交织:true 时这一帧是对端(spk),false 时是本端(mic) - bool _toSpk = true; - - /// 暂存的对端帧,等本端帧到齐后一起交织成双声道写盘 - Uint8List? _pendingSpk; - - /// 收到的原始帧计数(诊断用:判断耳机到底有没有上行通话音频) - int _frameCount = 0; - Timer? _silenceCheck; - RandomAccessFile? _raf; - int _dataBytes = 0; - String? _path; - Timer? _tick; - Timer? _sampleTimer; - final Stopwatch _stopwatch = Stopwatch(); - - @override - void onInit() { - super.onInit(); - _bind(); - } - - @override - void onClose() { - _stateWorker?.dispose(); - _callWorker?.dispose(); - _audioSub?.cancel(); - _tick?.cancel(); - _sampleTimer?.cancel(); - super.onClose(); - } - - void _bind() { - final bes = BesBluetoothService.to; - - // 通话开始**不再自动录音**。录音只有两个入口: - // 1) 用户在录音页点「开始录音」(CallRecordingController 调 start) - // 2) 耳机端按录音键:上报 0xD9 开始 / 0xDA 停止 - // 通话中默认就录属于未经用户操作的采集,既不合预期也不合规。 - _stateWorker = ever(bes.lastCmdType, (type) async { - if (_busy) return; - if (type == 'startMicRecording' && !isRecording.value) { - if (!bes.isCallOngoing.value) return; // 不在通话里的按键归现场录音管 - Logger.i(_tag, '耳机端按下录音键,开始通话录音'); - _deviceRecordingActive = true; - await start(sendCommand: true); - } else if (type == 'stopMicRecording' && isRecording.value) { - // 设备端自己停的:它已经退出录音态,不用再回发 AA 5A - Logger.i(_tag, '耳机端结束录音,落盘并归档'); - _deviceRecordingActive = false; - await stop(sendCommand: true); - } - }); - - // 通话结束时如果还在录,收尾归档(不能让文件一直挂着) - _callWorker = ever(bes.isCallOngoing, (calling) async { - if (!calling && isRecording.value && !_busy) { - Logger.i(_tag, '通话结束,停止并归档'); - await stop(sendCommand: true); - } - }); - - ever(bes.deviceStatus, (v) { - if (v == 0 && isRecording.value) { - Logger.w(_tag, '耳机断开,结束当前通话录音'); - stop(); - } - }); - } - - /// 开始本地采集。[sendCommand] = true 时同时下发设备开始指令 - /// (App 主动发起录音的场景)。 - /// 起停过程中的互斥标志:设备上报和 UI 操作可能同时进来, - /// 不加锁会出现「刚建好文件又被另一路关掉」的竞态。 - bool _busy = false; - - /// 本次录音是不是耳机按键发起的(0xD9)。 - /// 设备侧处于录音态时,停止要额外补一条 `AA 5A` 让它自己也退出来, - /// 只发 `AA 52`(退出通话模式)不足以清掉设备端的录音状态。 - bool _deviceRecordingActive = false; - - Future start({bool sendCommand = false}) async { - if (isRecording.value || _busy) return isRecording.value; - _busy = true; - try { - return await _start(sendCommand); - } finally { - _busy = false; - } - } - - Future _start(bool sendCommand) async { - if (!Get.isRegistered()) return false; - final bes = BesBluetoothService.to; - if (!bes.isConnected) return false; - - try { - final docs = await getApplicationDocumentsDirectory(); - final dir = Directory('${docs.path}/call_recordings'); - if (!await dir.exists()) await dir.create(recursive: true); - final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - _path = '${dir.path}/call_$ts.wav'; - - _raf = await File(_path!).open(mode: FileMode.write); - await _raf!.writeFrom(_wavHeader(0)); - _dataBytes = 0; - - _toSpk = true; - _pendingSpk = null; - _frameCount = 0; - _audioSub?.cancel(); - _audioSub = bes.audioStream.listen(_onPcm); - - // AA 51:进通话模式,耳机才会双路上行。 - // 不管是不是 App 主动发起的(设备端 0xD1 也会触发这里),都补发一次—— - // 只靠设备自己进通话模式时,它未必会把音频回传给 App。 - await bes.startCallMode(); - - // 2 秒还没收到任何音频帧就再补一次指令,并把情况打进日志。 - // 通话录音录不到声音时,先看这条日志判断是「没下发成功」还是「固件没回传」。 - _silenceCheck?.cancel(); - _silenceCheck = Timer(const Duration(seconds: 2), () async { - if (!isRecording.value) return; - if (_frameCount == 0) { - Logger.w(_tag, '进通话模式 2s 内没收到任何音频帧,重发 AA 51'); - await bes.startCallMode(); - } else { - Logger.i(_tag, '通话音频已上行,2s 内收到 $_frameCount 帧'); - } - }); - - // 把进程钉住,否则切后台后录音会被国内 ROM 悄悄掐断(回前台才发现截断) - unawaited(AudioForegroundService.start()); - - isRecording.value = true; - elapsedMs.value = 0; - level.value = 0; - waveform.clear(); - _stopwatch - ..reset() - ..start(); - _tick = Timer.periodic(const Duration(milliseconds: 200), (_) { - elapsedMs.value = _stopwatch.elapsedMilliseconds; - }); - _sampleTimer = Timer.periodic( - const Duration(milliseconds: sampleIntervalMs), - (_) => _sample(), - ); - Logger.i(_tag, '开始录制: $_path'); - return true; - } catch (e) { - Logger.e(_tag, '启动本地采集失败: $e'); - await _closeFile(save: false); - return false; - } - } - - /// 停止。[sendCommand] = true 时同时下发设备停止指令。 - Future stop({bool sendCommand = false}) async { - if (!isRecording.value || _busy) return; - _busy = true; - try { - await _stop(sendCommand); - } finally { - _busy = false; - } - } - - Future _stop(bool sendCommand) async { - _silenceCheck?.cancel(); - Logger.i(_tag, '停止通话录音: 收到 $_frameCount 帧,写入 $_dataBytes 字节'); - _tick?.cancel(); - _sampleTimer?.cancel(); - _stopwatch.stop(); - - final int seconds = elapsedMs.value ~/ 1000; - final String? path = _path; - - if (sendCommand && BesBluetoothService.to.isConnected) { - try { - // 设备侧还在录音态时先让它停录(AA 5A),再退出通话模式(AA 52)。 - // 顺序不能反:先退通话模式的话,录音停止指令未必还能被正确处理。 - if (_deviceRecordingActive) { - await BesBluetoothService.to.stopMicRecording(); // AA 5A - Logger.i(_tag, '已下发 AA 5A,通知耳机退出录音态'); - } - await BesBluetoothService.to.stopCallMode(); // AA 52 - } catch (e) { - Logger.e(_tag, '下发停止指令失败: $e'); - } - } - _deviceRecordingActive = false; - - await _closeFile(save: true); - unawaited(AudioForegroundService.stop()); - isRecording.value = false; - elapsedMs.value = 0; - level.value = 0; - waveform.clear(); - - if (path != null && _dataBytes > 0) { - // 归档会把文件从 call_recordings 挪到 LocalAudio,并在服务端建记录、 - // 交上传服务上云——通话录音的成品同样统一由「语音记录助手」管理。 - // 双声道:左=本端(自己),右=对端(对方)。 - await _archive(path, seconds); - } else { - // 没收到音频的空文件(只有 44 字节 WAV 头)直接删掉, - // 否则会在现场录音列表里堆一堆 00:00 的无效记录。 - Logger.w(_tag, '本次没有收到音频数据,删除空文件'); - if (path != null) { - try { - final f = File(path); - if (await f.exists()) await f.delete(); - } catch (e) { - Logger.e(_tag, '删除空录音文件失败: $e'); - } - } - } - _dataBytes = 0; - _path = null; - } - - void _onPcm(Uint8List pcm) async { - if (_raf == null || pcm.isEmpty) return; - _frameCount++; - if (_frameCount <= 3 || _frameCount % 200 == 0) { - Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}'); - } - // G.722 mode=0x03 是交织上行:一帧对端、一帧本端,轮流来 - final isSpk = _toSpk; - _toSpk = !_toSpk; - if (isSpk) { - _pendingSpk = pcm; - return; - } - final spk = _pendingSpk; - _pendingSpk = null; - if (spk == null) return; - await _writeStereo(mic: pcm, spk: spk); - _updateLevel(pcm); - } - - /// 左声道=本端麦克风,右声道=对端声音(与 deepvoice 的双声道录音一致) - /// 左声道=本端麦克风,右声道=对端声音(与 deepvoice 的双声道录音一致)。 - /// - /// 这里**按字节交织**而不是先转 Int16List:原生过来的 Uint8List 是带 - /// offset 的视图,`asInt16List(offsetInBytes, …)` 遇到奇数偏移会直接抛, - /// 而 _onPcm 是 async 的,异常被静默吞掉——表现就是「收了 3528 帧、写入 0 字节」。 - /// 直接搬字节没有对齐要求,小端序也原样保留。 - Future _writeStereo( - {required Uint8List mic, required Uint8List spk}) async { - final raf = _raf; - if (raf == null) return; - try { - final samples = (mic.length < spk.length ? mic.length : spk.length) ~/ 2; - if (samples <= 0) return; - final out = Uint8List(samples * 4); - for (var i = 0; i < samples; i++) { - final si = i * 2, di = i * 4; - out[di] = mic[si]; - out[di + 1] = mic[si + 1]; - out[di + 2] = spk[si]; - out[di + 3] = spk[si + 1]; - } - await raf.writeFrom(out); - _dataBytes += out.length; - } catch (e, st) { - Logger.e(_tag, '写入双声道 PCM 失败: $e\n$st'); - } - } - - void _sample() { - final list = waveform.toList(); - list.add(level.value); - if (list.length > _maxSamples) { - list.removeRange(0, list.length - _maxSamples); - } - waveform.assignAll(list); - } - - void _updateLevel(Uint8List pcm) { - if (pcm.lengthInBytes < 2) return; - final s = pcm.buffer.asInt16List(pcm.offsetInBytes, pcm.lengthInBytes ~/ 2); - var sum = 0.0; - for (final v in s) { - final n = v / 32768.0; - sum += n * n; - } - final rms = math.sqrt(sum / s.length); - level.value = math.sqrt(rms).clamp(0.0, 1.0); - } - - Future _closeFile({required bool save}) async { - await _audioSub?.cancel(); - _audioSub = null; - final raf = _raf; - _raf = null; - if (raf == null) return; - try { - if (save) { - await raf.setPosition(0); - await raf.writeFrom(_wavHeader(_dataBytes)); - } - await raf.close(); - } catch (e) { - Logger.e(_tag, '关闭录音文件失败: $e'); - } - } - - /// 归档进语音记录助手。具体入库逻辑见 [RecordingArchive]—— - /// 现场录音 / 通话录音 / 翻译录音都走同一套,不再各抄一份。 - Future _archive(String path, int seconds) async { - await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag); - } - - Uint8List _wavHeader(int dataLength) { - const sampleRate = 16000, channels = 2, bits = 16; - final byteRate = sampleRate * channels * (bits ~/ 8); - final h = ByteData(44); - void ascii(int off, String s) { - for (var i = 0; i < s.length; i++) { - h.setUint8(off + i, s.codeUnitAt(i)); - } - } - - ascii(0, 'RIFF'); - h.setUint32(4, 36 + dataLength, Endian.little); - ascii(8, 'WAVE'); - ascii(12, 'fmt '); - h.setUint32(16, 16, Endian.little); - h.setUint16(20, 1, Endian.little); - h.setUint16(22, channels, Endian.little); - h.setUint32(24, sampleRate, Endian.little); - h.setUint32(28, byteRate, Endian.little); - h.setUint16(32, channels * (bits ~/ 8), Endian.little); - h.setUint16(34, bits, Endian.little); - ascii(36, 'data'); - h.setUint32(40, dataLength, Endian.little); - return h.buffer.asUint8List(); - } -} diff --git a/apps/client/lib/data/services/bes_recording_service.dart b/apps/client/lib/data/services/bes_recording_service.dart deleted file mode 100644 index 1babcf64..00000000 --- a/apps/client/lib/data/services/bes_recording_service.dart +++ /dev/null @@ -1,397 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math' as math; -import 'dart:typed_data'; - -import 'package:get/get.dart'; -import 'package:intl/intl.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../../core/utils/logger.dart'; -import 'bes_bluetooth_service.dart'; -import 'recording_archive.dart'; - -/// 恒玄(BES)耳机录音的全局采集与归档服务。 -/// -/// 与蓝讯支架的 [HolderRecordingService] 是**两条完全独立的通路**,各走各的 -/// 协议、各自落盘归档,互不影响: -/// - 支架:BLE 私有协议 + Opus,SDK 直接给解码后的 PCM 帧 -/// - 耳机:经典蓝牙 SPP,`AA 59` 开录 / `AA 5A` 停录,音频以 mSBC 帧上行, -/// 原生解码成 16kHz/16bit 单声道走 [BesBluetoothService.audioStream] -/// -/// 一个关键差异:耳机是**主麦与前馈麦交替上行**,必须隔帧取主麦, -/// 全收会把两路混在一起。 -/// -/// 设备端按键起停(`0xD9`/`0xDA`)这里也接住,所以录音页没打开也能录、能归档。 -class BesRecordingService extends GetxService { - static const String _tag = 'BesRecordingService'; - static BesRecordingService get to => Get.find(); - - /// 当前是否正在录(由设备端状态驱动) - final RxBool isRecording = false.obs; - - /// 已录毫秒数 - final RxInt elapsedMs = 0.obs; - - /// 当前电平 0~1,供波形使用 - final RxDouble level = 0.0.obs; - - /// 波形采样序列(固定 [sampleIntervalMs] 一格) - final RxList waveform = [].obs; - static const int sampleIntervalMs = 100; - static const int _maxSamples = 600; - - Worker? _stateWorker; - StreamSubscription? _audioSub; - - /// 主麦 / 前馈麦交替上行,靠它隔帧取主麦 - bool _toMainMic = true; - RandomAccessFile? _raf; - int _dataBytes = 0; - String? _path; - Timer? _tick; - Timer? _sampleTimer; - final Stopwatch _stopwatch = Stopwatch(); - - /// 写入队列:把并发到达的音频帧排成串行,见 _onPcm 的说明。 - Future _writeChain = Future.value(); - int _writtenFrames = 0; - int _droppedFrames = 0; - - /// 下发 AA 59(开主麦)的时刻,用来量「发指令 → 耳机真正上行首帧」的间隔。 - /// 这段间隔目前是被计进录音时长里的,所以时长会比实际音频长一截。 - DateTime? _micCmdSentAt; - bool _firstPcmLogged = false; - - @override - void onInit() { - super.onInit(); - _bind(); - } - - @override - void onClose() { - _stateWorker?.dispose(); - _audioSub?.cancel(); - _tick?.cancel(); - _sampleTimer?.cancel(); - super.onClose(); - } - - void _bind() { - // 设备端按键起停:耳机上报 0xD9(startMicRecording) / 0xDA(stopMicRecording) - _stateWorker = ever(BesBluetoothService.to.lastCmdType, (type) async { - if (_busy) return; - if (type == 'startMicRecording' && !isRecording.value) { - Logger.i(_tag, '耳机端开始录音,启动本地采集'); - await start(); - } else if (type == 'stopMicRecording' && isRecording.value) { - Logger.i(_tag, '耳机端结束录音,落盘并归档'); - await stop(); - } - }); - // 断连兜底:别把录了一半的文件一直挂着 - ever(BesBluetoothService.to.deviceStatus, (v) { - if (v == 0 && isRecording.value) { - Logger.w(_tag, '耳机断开,结束当前录音'); - stop(); - } - }); - } - - /// 开始本地采集。[sendCommand] = true 时同时下发设备开始指令 - /// (App 主动发起录音的场景)。 - /// 起停过程中的互斥标志:设备上报和 UI 操作可能同时进来, - /// 不加锁会出现「刚建好文件又被另一路关掉」的竞态。 - bool _busy = false; - - Future start({bool sendCommand = false}) async { - if (isRecording.value || _busy) return isRecording.value; - _busy = true; - try { - return await _start(sendCommand); - } finally { - _busy = false; - } - } - - Future _start(bool sendCommand) async { - if (!Get.isRegistered()) return false; - final bes = BesBluetoothService.to; - if (!bes.isConnected) return false; - - try { - final docs = await getApplicationDocumentsDirectory(); - final dir = Directory('${docs.path}/field_recordings'); - if (!await dir.exists()) await dir.create(recursive: true); - final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - _path = '${dir.path}/earbuds_$ts.wav'; - - _raf = await File(_path!).open(mode: FileMode.write); - await _raf!.writeFrom(_wavHeader(0)); - _dataBytes = 0; - - _toMainMic = true; - _audioSub?.cancel(); - _audioSub = bes.audioStream.listen(_onPcm); - - _firstPcmLogged = false; - _micCmdSentAt = null; - _writeChain = Future.value(); - _writtenFrames = 0; - _droppedFrames = 0; - if (sendCommand) { - // AA 59:通知耳机开主麦,之后 mSBC 帧才会上行 - final t0 = DateTime.now(); - await bes.startMicRecording(); - _micCmdSentAt = DateTime.now(); - // 这一行量的是「我们把指令写进 SPP 花了多久」——如果它就很大, - // 说明卡在 app / 蓝牙写入侧;如果它很小而下面「首帧」很大, - // 那就是耳机响应慢。两者的优化方向完全不同。 - Logger.i(_tag, - '已下发 AA 59(开主麦),写入耗时 ${_micCmdSentAt!.difference(t0).inMilliseconds}ms'); - } - - isRecording.value = true; - elapsedMs.value = 0; - level.value = 0; - waveform.clear(); - // ⚠️ **计时不在这里启动**,而是等首帧音频真正到达(见 _onPcm)。 - // 实测耳机从收到 AA 59 到开始上行音频要 1.8~6.2 秒(首次尤其慢), - // 而 app 侧下发指令本身是 0ms。以前从下发就开始计时,这段空白全被 - // 算进录音时长——9 秒的录音里只有 2.8 秒是真音频, - // 等效采样率被稀释到 7160Hz(应为 16000)。 - // 现在时长与实际音频严格对齐;首帧到达前 UI 停在 00:00, - // 正好如实反映「还在等设备开麦」。 - _stopwatch.reset(); - // ⚠️ 时长按**已写入的数据量**算,不用墙钟。 - // - // 16kHz / 16bit / 单声道 → 32 字节 = 1 毫秒音频,这也正是播放器读 WAV - // 头算出来的时长,所以显示值与文件实际播放长度**永远一致**。 - // - // 为什么墙钟不行:耳机收到 AA 59 后要 1.8~6.2 秒才开始上行,而且会把 - // 这段等待期缓存的音频**一次性补推**上来——实测数据量比「首帧到停止」 - // 的墙钟窗口还多 45%(128880B=4.03s 音频 vs 2.77s 窗口)。 - // 无论从下发计时还是从首帧计时,都对不齐。 - _tick = Timer.periodic(const Duration(milliseconds: 200), (_) { - elapsedMs.value = _dataBytes ~/ 32; - }); - _sampleTimer = Timer.periodic( - const Duration(milliseconds: sampleIntervalMs), - (_) => _sample(), - ); - Logger.i(_tag, '开始录制: $_path'); - return true; - } catch (e) { - Logger.e(_tag, '启动本地采集失败: $e'); - await _closeFile(save: false); - return false; - } - } - - /// 停止。[sendCommand] = true 时同时下发设备停止指令。 - Future stop({bool sendCommand = false}) async { - if (!isRecording.value || _busy) return; - _busy = true; - try { - await _stop(sendCommand); - } finally { - _busy = false; - } - } - - Future _stop(bool sendCommand) async { - _tick?.cancel(); - _sampleTimer?.cancel(); - _stopwatch.stop(); - - // 先停订阅再排空写入队列:不停订阅的话新帧会一直往队列里追加,等不完。 - await _audioSub?.cancel(); - _audioSub = null; - // ⚠️ 必须等队列写完再关文件:队列里还压着最后几帧,直接关会丢掉它们, - // 而且 _dataBytes 还没加完,回填的 WAV 头长度会和实际数据对不上。 - try { - await _writeChain; - } catch (_) {} - - final int seconds = elapsedMs.value ~/ 1000; - final String? path = _path; - - // 音频时长(= 显示时长)与墙钟分开打:两者的差就是设备端「延迟开麦 + 补推 - // 缓存」的行为,丢帧数为 0 时差值全部来自设备侧,与 app 无关。 - final wallMs = _stopwatch.elapsedMilliseconds; - Logger.i( - _tag, - '录音统计: 音频${(_dataBytes / 32000).toStringAsFixed(2)}s 数据${_dataBytes}B ' - '墙钟${(wallMs / 1000).toStringAsFixed(2)}s ' - '写入${_writtenFrames}帧 丢${_droppedFrames}帧'); - - if (sendCommand && BesBluetoothService.to.isConnected) { - try { - await BesBluetoothService.to.stopMicRecording(); // AA 5A - } catch (e) { - Logger.e(_tag, '下发停止指令失败: $e'); - } - } - - await _closeFile(save: true); - isRecording.value = false; - elapsedMs.value = 0; - level.value = 0; - waveform.clear(); - - if (path != null && _dataBytes > 0) { - // 归档会把文件从 field_recordings 挪到 LocalAudio, - // 所以现场录音页的列表里不会再留副本——录音成品统一由语音记录助手管理。 - await _archive(path, seconds); - } else { - // 没收到音频的空文件(只有 44 字节 WAV 头)直接删掉, - // 否则会在现场录音列表里堆一堆 00:00 的无效记录。 - Logger.w(_tag, '本次没有收到音频数据,删除空文件'); - if (path != null) { - try { - final f = File(path); - if (await f.exists()) await f.delete(); - } catch (e) { - Logger.e(_tag, '删除空录音文件失败: $e'); - } - } - } - _dataBytes = 0; - _path = null; - } - - void _onPcm(Uint8List pcm) { - final raf = _raf; - if (raf == null || pcm.isEmpty) return; - if (!_firstPcmLogged) { - _firstPcmLogged = true; - // 首帧到了才开始计时,理由见 start() 里的说明。 - if (!_stopwatch.isRunning) _stopwatch.start(); - final sent = _micCmdSentAt; - // ⚠️ 计时器是在下发指令后就 start 的,而耳机真正开麦上行还要等这段。 - // 这段差值就是「录音显示时长 > 实际音频时长」的来源。 - Logger.i( - _tag, - '首帧音频到达' - '${sent != null ? ",距 AA 59 下发 ${DateTime.now().difference(sent).inMilliseconds}ms" : ""}' - ',计时从此刻开始'); - } - // 隔帧取主麦,前馈麦那一帧丢掉 - final take = _toMainMic; - _toMainMic = !_toMainMic; - if (!take) return; - // ⚠️ **必须串行写**,不能在这个回调里直接 `await raf.writeFrom(...)`。 - // - // Stream.listen 的回调即使标了 async 也不会被等待,而音频每秒上百帧, - // 多次 writeFrom 必然重叠;Dart 的 RandomAccessFile 同一时刻只允许一个 - // 异步操作,重叠调用会抛 "An async operation is currently pending", - // 原来那个 catch 把异常吞成一行日志——**那一帧就丢了**。 - // 实测后果:earbuds_* 录音的等效采样率只有 1587~5331Hz(应为 16000), - // 按 16k 播放就是又快又糊;而走原生录制的 trans_holder_* 稳定在 16000。 - // - // 用 Future 链把写入排成队列:既不丢帧,也保证写入顺序与到达顺序一致 - // (顺序乱了音频同样是糊的)。 - // ⚠️ 电平只是波形显示,**绝不能挡住写入**:上一版把它放在入队之前且没包 - // try,它一抛异常整个 _onPcm 就中断,音频一帧都写不进去。 - _writeChain = _writeChain.then((_) async { - final f = _raf; - if (f == null) return; - try { - await f.writeFrom(pcm); - _dataBytes += pcm.length; - _writtenFrames++; - try { - _updateLevel(pcm); - } catch (e) { - // 波形挂了不影响录音本身,吞掉即可 - } - } catch (e) { - _droppedFrames++; - Logger.e(_tag, '写入 PCM 失败(已丢帧 $_droppedFrames): $e'); - } - }); - } - - void _sample() { - final list = waveform.toList(); - list.add(level.value); - if (list.length > _maxSamples) { - list.removeRange(0, list.length - _maxSamples); - } - waveform.assignAll(list); - } - - void _updateLevel(Uint8List pcm) { - if (pcm.lengthInBytes < 2) return; - // ⚠️ **不能用 `buffer.asInt16List(offsetInBytes, …)`**:原生过来的 Uint8List - // 是带 offset 的视图,偏移是奇数时这行直接抛 - // `RangeError: Offset must be a multiple of BYTES_PER_ELEMENT`。 - // 实测这条异常在一次会话里抛了 6990 次——通话录音那边早就踩过并改成了 - // 按字节搬(见 BesCallRecordingService._writeStereo 的说明), - // 现场录音这条链路一直没同步修。 - // 直接按小端手工拼 int16,没有对齐要求。 - final count = pcm.lengthInBytes ~/ 2; - if (count == 0) return; - var sum = 0.0; - for (var i = 0; i < count; i++) { - final lo = pcm[i * 2]; - final hi = pcm[i * 2 + 1]; - var v = (hi << 8) | lo; - if (v >= 0x8000) v -= 0x10000; // 转有符号 - final n = v / 32768.0; - sum += n * n; - } - final rms = math.sqrt(sum / count); - level.value = math.sqrt(rms).clamp(0.0, 1.0); - } - - Future _closeFile({required bool save}) async { - await _audioSub?.cancel(); - _audioSub = null; - final raf = _raf; - _raf = null; - if (raf == null) return; - try { - if (save) { - await raf.setPosition(0); - await raf.writeFrom(_wavHeader(_dataBytes)); - } - await raf.close(); - } catch (e) { - Logger.e(_tag, '关闭录音文件失败: $e'); - } - } - - /// 归档进语音记录助手。具体入库逻辑见 [RecordingArchive]—— - /// 现场录音 / 通话录音 / 翻译录音都走同一套,不再各抄一份。 - Future _archive(String path, int seconds) async { - await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag); - } - - Uint8List _wavHeader(int dataLength) { - const sampleRate = 16000, channels = 1, bits = 16; - final byteRate = sampleRate * channels * (bits ~/ 8); - final h = ByteData(44); - void ascii(int off, String s) { - for (var i = 0; i < s.length; i++) { - h.setUint8(off + i, s.codeUnitAt(i)); - } - } - - ascii(0, 'RIFF'); - h.setUint32(4, 36 + dataLength, Endian.little); - ascii(8, 'WAVE'); - ascii(12, 'fmt '); - h.setUint32(16, 16, Endian.little); - h.setUint16(20, 1, Endian.little); - h.setUint16(22, channels, Endian.little); - h.setUint32(24, sampleRate, Endian.little); - h.setUint32(28, byteRate, Endian.little); - h.setUint16(32, channels * (bits ~/ 8), Endian.little); - h.setUint16(34, bits, Endian.little); - ascii(36, 'data'); - h.setUint32(40, dataLength, Endian.little); - return h.buffer.asUint8List(); - } -} diff --git a/apps/client/lib/data/services/ble_manager.dart b/apps/client/lib/data/services/ble_manager.dart deleted file mode 100644 index 5e1fee04..00000000 --- a/apps/client/lib/data/services/ble_manager.dart +++ /dev/null @@ -1,1073 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:device_jieli/device_jieli.dart'; -import 'package:agent_service/agent_service.dart'; - -import '/data/models/appconfig_model.dart'; -import '../models/user_Info.dart' as userInfo; -import '../../data/models/appconfig.dart'; -import '../../core/translations/app_translations.dart'; -import '../../core/utils/logger.dart'; -import 'language_manager.dart'; -import 'network/dio_manager.dart'; -import '../../routes/app_routes.dart'; - -/// BLE 连接状态常量(保持外部 API 兼容) -class BleConnectionState { - static const int disconnected = 0; - static const int connecting = 1; - static const int connected = 2; - static const int disconnecting = 3; - static const int error = -1; -} - -/// 设备信息缓存(精简后版本,去掉唤醒词字段) -class DeviceInfo { - final String address; - final String name; - final int batteryLevel; - final int leftEarBattery; - final int rightEarBattery; - final int caseBattery; - final bool leftEarCharging; - final bool rightEarCharging; - final bool caseCharging; - final String firmwareVersion; - final String firmwareProductId; - final String firmwareProductColor; - final DateTime lastUpdated; - - DeviceInfo({ - required this.address, - required this.name, - this.batteryLevel = 0, - this.leftEarBattery = 0, - this.rightEarBattery = 0, - this.caseBattery = 0, - this.leftEarCharging = false, - this.rightEarCharging = false, - this.caseCharging = false, - this.firmwareVersion = '', - this.firmwareProductId = '', - this.firmwareProductColor = '', - DateTime? lastUpdated, - }) : lastUpdated = lastUpdated ?? DateTime.now(); - - DeviceInfo copyWith({ - String? address, - String? name, - int? batteryLevel, - int? leftEarBattery, - int? rightEarBattery, - int? caseBattery, - bool? leftEarCharging, - bool? rightEarCharging, - bool? caseCharging, - String? firmwareVersion, - String? firmwareProductId, - String? firmwareProductColor, - }) { - return DeviceInfo( - address: address ?? this.address, - name: name ?? this.name, - batteryLevel: batteryLevel ?? this.batteryLevel, - leftEarBattery: leftEarBattery ?? this.leftEarBattery, - rightEarBattery: rightEarBattery ?? this.rightEarBattery, - caseBattery: caseBattery ?? this.caseBattery, - leftEarCharging: leftEarCharging ?? this.leftEarCharging, - rightEarCharging: rightEarCharging ?? this.rightEarCharging, - caseCharging: caseCharging ?? this.caseCharging, - firmwareVersion: firmwareVersion ?? this.firmwareVersion, - firmwareProductId: firmwareProductId ?? this.firmwareProductId, - firmwareProductColor: firmwareProductColor ?? this.firmwareProductColor, - lastUpdated: DateTime.now(), - ); - } - - @override - String toString() => - 'DeviceInfo(addr: $address, name: $name, battery: $batteryLevel%, fw: $firmwareVersion)'; -} - -/// 杰理蓝牙管理器(替换旧的 ble_service 实现,对外保留 BleManager 名字) -class BleManager extends GetxService { - static BleManager get to => Get.find(); - - // ─── 连接信息 ──────────────────────────────────────── - String? currentDeviceAddress; - String? _currentDeviceName; - JieliDevice? _connectedDevice; - JieliDevice? get connectedDevice => _connectedDevice; - - final _connectionState = BleConnectionState.disconnected.obs; - int get connectionState => _connectionState.value; - RxInt get connectionStateRx => _connectionState; - - final _isCall = false.obs; - final _isConnected = false.obs; - bool get isConnected => _isConnected.value; - bool get isCall => _isCall.value; - - final deviceInfo = Rx(null); - - // ─── 通话翻译/编解码器(杰理 startTranslation 模式状态)───── - final _isCodecActive = false.obs; - bool get isCodecActive => _isCodecActive.value; - RxBool get isCodecActiveRx => _isCodecActive; - - // 设备主动上报通话翻译开关信号(耳机端按键) - final StreamController _callTranslationRequestController = - StreamController.broadcast(); - Stream get onCallTranslationRequest => - _callTranslationRequestController.stream; - - // 通话翻译模式下,杰理 runtime 解码后推上来的 PCM: - // - in.uplink = 本机用户麦克风(己方/A 路) - // - in.downlink = 对端通话音(对方/B 路) - // 由 translation_controller 转发到 azure_speech 的 AST 流水线。 - final StreamController _inUplinkPcmController = - StreamController.broadcast(); - final StreamController _inDownlinkPcmController = - StreamController.broadcast(); - Stream get inUplinkPcmStream => _inUplinkPcmController.stream; - Stream get inDownlinkPcmStream => _inDownlinkPcmController.stream; - - // ─── 扫描结果(供设备页扫描列表使用)───────────────────── - final RxList scannedDevices = [].obs; - final RxBool isScanning = false.obs; - - /// 每个地址最近一次扫到的 advertisement(电量等)。 - /// scannedDevices 只追加不更新,但电量在每次 advertisement 都可能变; - /// 这里保留最新一次,供连接成功时填到 DeviceInfo。 - final Map _latestAdvByAddress = {}; - - StreamSubscription? _eventSub; - final GetStorage _storage = GetStorage(); - - Future? _initFuture; - bool _initSucceeded = false; - String? _initError; - String? get initError => _initError; - bool get isInitialized => _initSucceeded; - - /// 确保杰理 SDK native 端已 initialize。失败则重试一次。 - /// 失败时抛出 [StateError] —— 调用方负责 try-catch / UI 反馈。 - Future ensureInitialized() async { - if (_initSucceeded) return; - // 上一次 future 还在 in-flight 或失败 → 重新启动一次 - if (_initFuture == null) { - _initFuture = _initializeJieli(); - } - try { - await _initFuture!.timeout(const Duration(seconds: 8)); - } catch (e) { - _initError = e.toString(); - _initFuture = null; // 允许下次重试 - throw StateError('杰理 SDK 初始化失败: $_initError'); - } - if (!_initSucceeded) { - _initFuture = null; - throw StateError('杰理 SDK 初始化失败: ${_initError ?? "unknown"}'); - } - } - - /// 兼容旧 API(部分调用点不关心初始化结果) - Future get ready async { - try { - await ensureInitialized(); - } catch (_) { - // 静默:让后续 native 调用自身报错 - } - } - - BleManager() { - print('[BLE] BleManager 构造,立即启动杰理 SDK 初始化'); - _initFuture = _initializeJieli(); - _setupEventListener(); - _startConnectionWatchdog(); - } - - @override - void onInit() { - super.onInit(); - print('[BLE] BleManager.onInit (init 已在构造时启动)'); - } - - @override - void onClose() { - _eventSub?.cancel(); - _stopBatteryWatchdog(); - _stopConnectionWatchdog(); - _callTranslationRequestController.close(); - _inUplinkPcmController.close(); - _inDownlinkPcmController.close(); - super.onClose(); - } - - /// 把外部 AST 服务返回的 TTS PCM 灌回耳机。 - /// - leg=='A' / 'uplink' → 对端听(SOURCE_E_SCO_UP_LINK) - /// - leg=='B' / 'downlink' → 本机用户听(SOURCE_E_SCO_DOWN_LINK) - /// - /// 拿到多少就送多少,不做节流也不补静音;底层 SDK / RCSP runtime 自己管 - /// 整段 OPUS 编码 + 串行 writeAudioData 节奏。 - Future feedAstTtsPcm({ - required String leg, - required Uint8List pcm, - int sampleRate = 16000, - int channels = 1, - bool isFinal = false, - }) async { - // 段尾帧(pcm 为空 + isFinal=true)必须放行,让 native 触发立即整段下发; - // 仅对“普通空帧(非段尾)”短路。 - if (pcm.isEmpty && !isFinal) return true; - final upper = leg.toUpperCase(); - final streamId = (upper == 'A' || upper == 'UPLINK') - ? 'out.uplink' - : 'out.downlink'; - final isFlush = isFinal; - try { - final ok = await Jielihome.instance.feedTranslatedAudio( - streamId: streamId, - pcm: pcm, - sampleRate: sampleRate, - channels: channels, - isFinal: isFinal, - ); - if (isFlush || !ok) { - // 只在段尾 flush 或 native 返回 false 时打,避免刷屏。 - Logger.info( - '[BleManager] feedAstTtsPcm leg=$leg streamId=$streamId pcm=${pcm.length}B isFinal=$isFinal nativeOk=$ok', - ); - } - return ok; - } catch (e) { - Logger.w('BleManager', 'feedAstTtsPcm leg=$leg failed: $e'); - return false; - } - } - - Future _initializeJieli() async { - try { - print('[BLE] [INIT] 调用 Jielihome.initialize ...'); - // 与参考项目 ai-agent-client 保持一致:使用 SDK 默认配置(multiDevice=true, skipNoNameDev=false) - // 不要清空 BluetoothOption.scanFilterData —— 那是杰理协议广播标识,清空反而扫不到 - await Jielihome.instance.initialize(); - _initSucceeded = true; - _initError = null; - print('[BLE] [INIT] 杰理 SDK 初始化完成'); - } catch (e, s) { - _initSucceeded = false; - _initError = e.toString(); - print('[BLE] [INIT] 杰理 SDK 初始化失败: $e\n$s'); - rethrow; - } - } - - void _setupEventListener() { - print('[BLE] [EVT] 准备订阅 Jielihome.events ...'); - _eventSub?.cancel(); - _eventSub = Jielihome.instance.events.listen( - (event) { - // TranslationAudioEvent 频率极高(10ms/帧),最先拦截并单独走节流日志处理器, - // 避免进入下面的 print / if‑链路经引发刷屏。 - if (event is TranslationAudioEvent) { - _handleTranslationAudio(event); - return; - } - // 总开关日志:每一个事件都打一次,便于诊断 native → dart 通道是否畅通 - print('[BLE] [EVT] ${event.runtimeType}'); - if (event is AdapterStatusEvent) { - print('[BLE] [EVT] 适配器状态 enabled=${event.enabled} hasBle=${event.hasBle}'); - } else if (event is ScanStatusEvent) { - print('[BLE] [EVT] 扫描状态变化 ble=${event.ble} started=${event.started}'); - isScanning.value = event.started; - } else if (event is DeviceFoundEvent) { - final found = event.device; - final exists = scannedDevices.any((d) => d.address == found.address); - // 每次广播都用最新数据覆盖(后续若 SDK 扩展了广播字段,仍可从这里拿到) - _latestAdvByAddress[found.address] = found; - print('[BLE] [EVT] deviceFound name="${found.name}" addr=${found.address} ' - 'rssi=${found.rssi} ' - '(已存在=$exists, 当前列表 size=${scannedDevices.length})'); - if (!exists) { - scannedDevices.add(found); - print('[BLE] [EVT] 新设备已加入列表,size=${scannedDevices.length}'); - } - } else if (event is ConnectionStateEvent) { - _handleConnectionState(event); - } else if (event is RcspInitEvent) { - print('[BLE] [EVT] RCSP 初始化: ${event.address} success=${event.success}'); - if (event.success) { - // RCSP init 完成后 SDK 才开始往缓存里填电量;这里启动轮询直到拿到非 0 - _startBatteryWatchdog(); - // 同时启动 AgentService(azureAsrHelper / TTS / chatApi 在这里建出来)。 - // native initialize 自带幂等保护,重连重复触发是 no-op; - // 老的 PairingController 删除时这一调用丢了,不补回来 AI 助理 / PTT - // 等所有 ASR 路径都会因为 azureAsrHelper=null 静默失败。 - startAgentService().then((ok) { - Logger.i('BleManager', 'startAgentService on RCSP init: ok=$ok'); - }).catchError((e) { - Logger.e('BleManager', 'startAgentService on RCSP init 失败: $e'); - }); - } - } else if (event is BatteryEvent) { - Logger.i('BleManager', - '[EVT] BatteryEvent addr=${event.address} level=${event.level}'); - _handleBattery(event); - } else if (event is PhoneCallStatusEvent) { - _isCall.value = event.status != 0; - } else if (event is ExpandFunctionEvent) { - _handleExpandFunction(event); - } else if (event is TranslationLogEvent) { - print('[BLE] [EVT] TranslationLog modeId=${event.modeId} ${event.content}'); - } else if (event is TranslationErrorEvent) { - print('[BLE] [EVT] TranslationError modeId=${event.modeId} ' - 'code=${event.code} msg=${event.message}'); - } else if (event is UnknownJieliEvent) { - print('[BLE] [EVT] 未识别事件: ${event.raw}'); - } - }, - onError: (e) => print('[BLE] [EVT] 杰理事件流错误: $e'), - onDone: () => print('[BLE] [EVT] 杰理事件流被关闭 (onDone)'), - ); - print('[BLE] [EVT] 订阅完成 sub=$_eventSub'); - } - - void _handleConnectionState(ConnectionStateEvent e) { - // 关键诊断日志:原生 → Dart 链路上的最终一站。如果这里没打印,意味着 - // 原生侧 `connectionState` 事件根本没到达 Dart(看 iOS `[JieliDispatcher] - // send #N type=connectionState` 是否成功 sink-ok)。 - print('[BLE] [EVT] >>> _handleConnectionState addr=${e.address} state=${e.state} ' - '(0=disconnect 1=ok 2=connecting)'); - Logger.i('BleManager', 'JL 连接状态: ${e.address} → ${e.state}'); - if (e.state == ConnectionStateEvent.connectionOk) { - currentDeviceAddress = e.address; - _connectionState.value = BleConnectionState.connected; - _isConnected.value = true; - print('[BLE] [EVT] >>> isConnected 翻成 true, currentDeviceAddress=${e.address}'); - _ensureDeviceInfoStub(); - // 用扫描期间最新一条 advertisement 立刻填上电量(SDK BleScanMessage 里就有 - // leftDeviceQuantity / rightDeviceQuantity / chargingBinQuantity / 各 charging 位) - _fillBatteryFromAdvertisement(e.address); - // 不在这里 fetchDeviceInfo —— RCSP 还没 init,SDK 缓存里的电量是 0; - // 改到 RcspInitEvent 成功时再拉一次 + 之后再轮询几次保险。 - } else if (e.state == ConnectionStateEvent.connectionConnecting) { - _connectionState.value = BleConnectionState.connecting; - print('[BLE] [EVT] >>> 连接中 (state=2)'); - } else { - _connectionState.value = BleConnectionState.disconnected; - _isConnected.value = false; - _isCodecActive.value = false; - _stopBatteryWatchdog(); - print('[BLE] [EVT] >>> isConnected 翻成 false (state=${e.state})'); - } - } - - // 连接 + RCSP init 之后 SDK 缓存才开始填电量;BatteryEvent 在某些设备上 - // 又只在变化时才推。所以连上后主动轮询几次拉快照,拿到非 0 就停。 - Timer? _batteryWatchdog; - int _batteryWatchdogTicks = 0; - static const int _batteryWatchdogMaxTicks = 12; // 12 × 2.5s = 30s - static const Duration _batteryWatchdogInterval = Duration(milliseconds: 2500); - - void _startBatteryWatchdog() { - _stopBatteryWatchdog(); - _batteryWatchdogTicks = 0; - Logger.i('BleManager', '[BAT] 启动电量轮询'); - // 立刻先拉一次,再启周期任务 - Future.microtask(fetchDeviceInfo); - _batteryWatchdog = Timer.periodic(_batteryWatchdogInterval, (t) async { - _batteryWatchdogTicks++; - final lvl = deviceInfo.value?.batteryLevel ?? 0; - if (lvl > 0) { - Logger.i('BleManager', - '[BAT] 拿到电量=$lvl%,停止轮询 (tick=$_batteryWatchdogTicks)'); - _stopBatteryWatchdog(); - return; - } - if (_batteryWatchdogTicks >= _batteryWatchdogMaxTicks) { - Logger.w('BleManager', - '[BAT] 30s 内未拿到电量,停止轮询 (tick=$_batteryWatchdogTicks)'); - _stopBatteryWatchdog(); - return; - } - Logger.d('BleManager', - '[BAT] 轮询拉快照 tick=$_batteryWatchdogTicks'); - await fetchDeviceInfo(); - }); - } - - void _stopBatteryWatchdog() { - _batteryWatchdog?.cancel(); - _batteryWatchdog = null; - } - - // 连接状态 watchdog:杰理 SDK 在某些时序下不会回调 ConnectionStateEvent - // (冷启时设备已经连着、事件订阅 race、原生侧自动重连等),导致本地 Rx - // 状态停在 disconnected 但 SDK 内部已经有连接。这里定期直接问 SDK。 - // - // 重要:只做"单向"同步(disconnected → connected),**不**把已连状态翻回 - // disconnected。原因:multi-device 模式下 connectedDevice() 偶发会返回 null, - // 上一版把它当真翻成 disconnected 反而把已连接 UI 抹掉了。断开仍走 - // ConnectionStateEvent.connectionDisconnect 那条事件路径。 - Timer? _connectionWatchdog; - static const Duration _connectionWatchdogInterval = Duration(seconds: 5); - - void _startConnectionWatchdog() { - _connectionWatchdog?.cancel(); - _connectionWatchdog = Timer.periodic(_connectionWatchdogInterval, (_) { - _reconcileConnectionFromSdk(); - }); - } - - void _stopConnectionWatchdog() { - _connectionWatchdog?.cancel(); - _connectionWatchdog = null; - } - - Future _reconcileConnectionFromSdk() async { - if (!_initSucceeded) return; - try { - // 步骤 1:尽量找到一个真实在连的 address。 - // 优先:本地已有 currentDeviceAddress → 直接 isConnected(addr) 校验。 - // 兜底:connectedDevice() 看 SDK 是否能给出当前已连设备。 - String? sdkConnectedAddr; - String? sdkConnectedName; - final localAddr = currentDeviceAddress; - if (localAddr != null && localAddr.isNotEmpty) { - if (await Jielihome.instance.isConnected(localAddr)) { - sdkConnectedAddr = localAddr; - sdkConnectedName = _currentDeviceName; - } - } - if (sdkConnectedAddr == null) { - final dev = await Jielihome.instance.connectedDevice(); - if (dev != null && dev.address.isNotEmpty) { - sdkConnectedAddr = dev.address; - sdkConnectedName = dev.name; - } - } - - if (sdkConnectedAddr == null) return; // 没拿到,本轮放弃,**不**翻 _isConnected - - // 步骤 2:本地 Rx 与 SDK 不一致 → 强制同步到「已连」。 - final wasConnected = _isConnected.value; - final addressChanged = currentDeviceAddress != sdkConnectedAddr; - if (!wasConnected || addressChanged) { - Logger.w('BleManager', - '[WATCHDOG] 本地 isConnected=$wasConnected addr=$currentDeviceAddress' - ' / SDK 在连 $sdkConnectedAddr → 同步为 connected'); - currentDeviceAddress = sdkConnectedAddr; - if (sdkConnectedName != null && sdkConnectedName.isNotEmpty) { - _currentDeviceName = sdkConnectedName; - } - _isConnected.value = true; - _connectionState.value = BleConnectionState.connected; - _ensureDeviceInfoStub(); - } - - // 步骤 3:兜底电量。冷启场景下 RcspInitEvent 不会再来, - // _startBatteryWatchdog 永远不被触发,电量 UI 一直 0。 - // 这里发现 SDK 在连且本地电量为 0 时,主动启动一次电量轮询。 - final lvl = deviceInfo.value?.batteryLevel ?? 0; - if (lvl == 0 && _batteryWatchdog == null) { - Logger.i('BleManager', '[WATCHDOG] 已连但电量=0,启动电量轮询补抓'); - _startBatteryWatchdog(); - } - } catch (e) { - Logger.w('BleManager', '[WATCHDOG] 查询 SDK 连接状态失败: $e'); - } - } - - void _handleBattery(BatteryEvent e) { - final level = e.level ?? 0; - _ensureDeviceInfoStub(); - // 杰理 SDK 的 BatteryInfo 只有总电量;左右耳/盒子的精细数据来自 BleScanMessage(见 _fillBatteryFromAdvertisement)。 - // 这里更新总电量;如果当前左右耳还是 0,也镜像一下作为兜底。 - final cur = deviceInfo.value; - if (cur == null) return; - deviceInfo.value = cur.copyWith( - batteryLevel: level, - leftEarBattery: cur.leftEarBattery > 0 ? cur.leftEarBattery : level, - rightEarBattery: cur.rightEarBattery > 0 ? cur.rightEarBattery : level, - ); - } - - /// 杰理同步后的 `JieliDevice` 仅暴露 name / address / rssi 等基础字段, - /// 不再提供左右耳/仓的电量与充电状态。电量改由以下两路拿: - /// 1. 连接成功后 RCSP 初始化完成 → `_startBatteryWatchdog` 轮询 - /// `fetchDeviceInfo` 拉 `deviceSnapshot` / `queryTargetInfo`; - /// 2. SDK 主动推送的 [BatteryEvent](见 `_handleBattery`)。 - /// 所以这里不再从扫描广播里预填电量,保留空实现以兼容既有调用点。 - void _fillBatteryFromAdvertisement(String address) { - final adv = _latestAdvByAddress[address]; - if (adv == null) { - Logger.w('BleManager', '[BAT] 没有 $address 的 advertisement 数据可用'); - return; - } - Logger.i('BleManager', - '[BAT] 广播不再携带电量字段,连接后将通过 RCSP/BatteryEvent 补齐'); - } - - int _uplinkRxCount = 0; - int _downlinkRxCount = 0; - - void _handleTranslationAudio(TranslationAudioEvent e) { - // streamId 来自 native TranslationStreams:in.uplink / in.downlink / in.mic ... - // 仅转发通话翻译两条输入流;out.* 由这里产生回灌指令而不是接收。 - switch (e.streamId) { - case 'in.uplink': - _uplinkRxCount++; - if (_uplinkRxCount <= 5 || _uplinkRxCount % 100 == 1) { - Logger.i('CALL-RX', - '上行 #$_uplinkRxCount len=${e.pcm.length} sr=${e.sampleRate} ch=${e.channels} bits=${e.bitsPerSample} seq=${e.seq}'); - } - if (!_inUplinkPcmController.isClosed) { - _inUplinkPcmController.add(e.pcm); - } - break; - case 'in.downlink': - _downlinkRxCount++; - if (_downlinkRxCount <= 5 || _downlinkRxCount % 100 == 1) { - Logger.i('CALL-RX', - '下行 #$_downlinkRxCount len=${e.pcm.length} sr=${e.sampleRate} ch=${e.channels} bits=${e.bitsPerSample} seq=${e.seq}'); - } - if (!_inDownlinkPcmController.isClosed) { - _inDownlinkPcmController.add(e.pcm); - } - break; - } - } - - void _handleExpandFunction(ExpandFunctionEvent e) { - if (e.opCode == 0x16) { - _emitCallTranslationRequest(true); - } else if (e.opCode == 0x17) { - _emitCallTranslationRequest(false); - } - } - - void _emitCallTranslationRequest(bool enable) { - Logger.i('BleManager', - '[CALL_TRANS_CMD] 设备上报${enable ? "开启" : "关闭"}通话翻译'); - if (!_callTranslationRequestController.isClosed) { - _callTranslationRequestController.add(enable); - } - if (enable) { - final currentRoute = Get.currentRoute; - if (currentRoute != Routes.translation) { - Get.toNamed( - Routes.translation, - arguments: {'translationId': 'call', 'autoStart': true}, - ); - } - } - } - - void _ensureDeviceInfoStub() { - if (deviceInfo.value == null && currentDeviceAddress != null) { - deviceInfo.value = DeviceInfo( - address: currentDeviceAddress!, - name: _currentDeviceName ?? '', - ); - } - } - - // ─── 扫描 / 连接 ──────────────────────────────────── - /// 启动扫描。 - /// - /// 扫描的 service UUID 白名单来源: - /// 1) 调用方显式传入 [uuids](通常用于调试或定向扫描); - /// 2) 否则根据 [productIds] 从 [AppConfig.config.products] 映射出每个产品 - /// 的 `scanuuid`(服务端下发的产品配置),在 Flutter 侧拼接成 uuids 列表 - /// 再通过平台通道下发给底层,由 Android `ScanFilter.setServiceUuid` - /// 进行 OS 级过滤; - /// 3) 如果上面两项都没给,就默认使用 [AppConfig.allproductUuids()],等价 - /// 于「下发所有已配置产品」的 uuid 白名单。 - /// - /// 空列表 / AppConfig 未初始化时,底层不做 uuid 过滤,全部放行。 - Future startScan({ - Duration timeout = const Duration(seconds: 30), - List? uuids, - List? productIds, - }) async { - print('[BLE] [SCAN] BleManager.startScan 入口 ' - 'productIds=$productIds uuids=$uuids'); - _lastScanError = null; - try { - await ensureInitialized(); - } catch (e) { - print('[BLE] [SCAN] 杰理 SDK 未初始化: $e'); - _lastScanError = e.toString(); - isScanning.value = false; - return false; - } - - final List resolvedUuids; - if (uuids != null && uuids.isNotEmpty) { - resolvedUuids = - uuids.where((u) => u.isNotEmpty).toList(growable: false); - } else if (productIds != null && - productIds.isNotEmpty && - AppConfig.isInitialized()) { - final ids = productIds.toSet(); - resolvedUuids = AppConfig.config.products - .where((p) => ids.contains(p.id)) - .map((p) => p.scanuuid) - .where((u) => u.isNotEmpty) - .toList(growable: false); - } else if (AppConfig.isInitialized()) { - resolvedUuids = AppConfig.allproductUuids() - .where((u) => u.isNotEmpty) - .toList(growable: false); - } else { - resolvedUuids = const []; - } - - print('[BLE] [SCAN] init 已通过,UUID 白名单=$resolvedUuids'); - scannedDevices.clear(); - isScanning.value = true; - try { - await Jielihome.instance.startScan( - timeout: timeout, - uuidList: resolvedUuids, - skipUnnamed: true, - ); - print('[BLE] [SCAN] native startScan 返回成功,等待 deviceFound 事件'); - return true; - } catch (e, s) { - print('[BLE] [SCAN] 启动扫描失败: $e\n$s'); - isScanning.value = false; - _lastScanError = e.toString(); - return false; - } - } - - String? _lastScanError; - String? get lastScanError => _lastScanError; - - Future stopScan() async { - await ready; - try { - await Jielihome.instance.stopScan(); - } catch (e) { - Logger.e('BleManager', '停止扫描失败: $e'); - } finally { - isScanning.value = false; - } - } - - Future connectDevice(JieliDevice device) async { - await ready; - _currentDeviceName = device.name; - currentDeviceAddress = device.address; - _connectedDevice = device; - _connectionState.value = BleConnectionState.connecting; - try { - await Jielihome.instance.connect(device); - } catch (e) { - Logger.e('BleManager', '连接设备失败: $e'); - _connectionState.value = BleConnectionState.error; - } - } - - Future disconnect() async { - await ready; - try { - final addr = currentDeviceAddress; - if (addr != null) { - await Jielihome.instance.disconnect(addr); - } - } catch (e) { - Logger.e('BleManager', '断开连接失败: $e'); - } - } - - Future getConnectionState() async { - final addr = currentDeviceAddress; - if (addr == null) return BleConnectionState.disconnected; - try { - final connected = await Jielihome.instance.isConnected(addr); - _connectionState.value = - connected ? BleConnectionState.connected : BleConnectionState.disconnected; - _isConnected.value = connected; - return _connectionState.value; - } catch (e) { - Logger.e('BleManager', '查询连接状态失败: $e'); - return BleConnectionState.error; - } - } - - Future isBleConnected() async { - final addr = currentDeviceAddress; - if (addr == null) return false; - try { - return await Jielihome.instance.isConnected(addr); - } catch (_) { - return false; - } - } - - /// 探查当前是否已经有杰理设备连着(用于登录/启动后判断是否需要跳转扫描页)。 - /// 通过 [Jielihome.connectedDevice] 同步 SDK 已知的连接状态。 - Future hasActiveConnection() async { - await ready; - try { - final dev = await Jielihome.instance.connectedDevice(); - if (dev != null && dev.address.isNotEmpty) { - currentDeviceAddress = dev.address; - _currentDeviceName = dev.name; - _connectedDevice = dev; - _isConnected.value = true; - _connectionState.value = BleConnectionState.connected; - _ensureDeviceInfoStub(); - return true; - } - } catch (e) { - Logger.w('BleManager', 'hasActiveConnection 探查失败: $e'); - } - return false; - } - - // ─── 设备信息 ─────────────────────────────────────── - String getDeviceName() { - return deviceInfo.value?.name ?? _currentDeviceName ?? ''; - } - - Future fetchDeviceInfo() async { - final addr = currentDeviceAddress; - if (addr == null) return; - try { - final snap = await Jielihome.instance.deviceSnapshot(addr) ?? {}; - final name = (snap['name'] as String?) ?? _currentDeviceName ?? ''; - final battery = (snap['battery'] as num?)?.toInt() ?? - (snap['batteryLevel'] as num?)?.toInt() ?? - 0; - final fw = (snap['firmwareVersion'] as String?) ?? - (snap['version'] as String?) ?? - ''; - final pid = (snap['productId'] as String?) ?? ''; - _ensureDeviceInfoStub(); - // 同 _handleBattery:杰理 SDK 只暴露单个总电量,把它同时镜像到左右耳, - // home_view 才能从 leftEarBattery / rightEarBattery 读到非零值。 - deviceInfo.value = (deviceInfo.value ?? DeviceInfo(address: addr, name: name)) - .copyWith( - name: name, - batteryLevel: battery, - leftEarBattery: battery, - rightEarBattery: battery, - firmwareVersion: fw, - firmwareProductId: pid, - ); - Logger.i('BleManager', - '设备快照: name=$name battery=$battery fw=$fw pid=$pid'); - } catch (e) { - Logger.w('BleManager', '获取设备快照失败: $e'); - } - } - - Future fetchBatteryInfo() async => true; - Future fetchDeviceVersion() async => true; - Future getProductId() async => true; - Future getBatteryLevel() async => - deviceInfo.value?.batteryLevel ?? 0; - - void clearDeviceInfo() { - deviceInfo.value = null; - currentDeviceAddress = null; - _currentDeviceName = null; - _connectedDevice = null; - } - - // ─── 编解码器 / 通话翻译模式 ─────────────────────────── - // 杰理:openA2DPDecoder/closeCodec 映射到 startTranslation/stopTranslation。 - // 其余 codec 入口按现有调用语义保留乐观成功,以保持上层兼容。 - - Future openEncoder() async { - _isCodecActive.value = true; - return true; - } - - Future openDecoder() async { - _isCodecActive.value = true; - return true; - } - - Future openA2DPDecoder() async { - Logger.i('CALL_TRANS_CMD', '【启动】通话翻译模式 (杰理 startTranslation modeId=3)'); - try { - await Jielihome.instance.startTranslation( - modeId: TranslationModeIds.callTranslation, - ); - _isCodecActive.value = true; - Logger.i('CALL_TRANS_CMD', '✅【启动成功】通话翻译模式已下发 — 接下来等耳机回上行音频'); - return true; - } catch (e) { - Logger.e('CALL_TRANS_CMD', '❌【启动失败】通话翻译模式: $e'); - _isCodecActive.value = false; - return false; - } - } - - Future openCallRecordDecoder() async { - _isCodecActive.value = true; - return true; - } - - Future closeCodec() async { - _isCodecActive.value = false; - try { - await Jielihome.instance.stopTranslation(); - } catch (_) { - // 静默失败:未启动时调 stop 是常态 - } - return true; - } - - Future playA2DP() async => true; - - // ─── OTA ─────────────────────────────────────────── - /// 杰理 OTA 通过 Jielihome.otaStart 触发,此处保留空实现以兼容旧调用点。 - Future setEnterOta(bool enterOta) async {} - - // ─── 兼容存根 ─────────────────────────────────────── - Future requestBluetoothPermission() async => true; - - /// 旧 Companion 关联 / 后台扫描接口已废弃 — 保留空实现以兼容 - /// 即将被移除的调用点(pairing 模块已删除)。 - Future clearPairedDevices() async { - await disconnect(); - return true; - } - - // ─── AgentService 启动 / 停止(保持原逻辑) ─────────── - Future startAgentService() async { - try { - var serverurl = DioManager().baseUrl; - var jsonString = _storage.read("logintoken"); - - final azureSpeechKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; - final azureSpeechRegion = - AppConfig.env('AZURE_SPEECH_REGION') ?? 'eastasia'; - final openaiApiKey = AppConfig.env('OPENAI_API_KEY') ?? ''; - final openaiBaseUrl = - AppConfig.env('OPENAI_BASE_URL') ?? 'https://api.openai.com/v1'; - final openaiModel = AppConfig.env('OPENAI_MODEL') ?? 'gpt-3.5-turbo'; - final openaiModelMedia = AppConfig.env('OPENAI_API_VISION_MODEL') ?? - 'doubao-1-5-vision-pro-32k-250115'; - final openaiagentid = - AppConfig.env('OPENAI_AGENT_ID') ?? 'personal_assistant'; - final systemPrompt = AppConfig.agentsystemPrompt(openaiagentid); - var musicServiceType = - AppConfig.env('APP_ANDROID_MUSICSERVICE_TYPE') ?? 'AUTO'; - if (Platform.isIOS) { - musicServiceType = AppConfig.env('APP_IOS_MUSICSERVICE_TYPE') ?? 'AUTO'; - } - final navigationMode = - AppConfig.env('APP_NAVIGATION_MODE') ?? 'external_auto'; - final xunfeiAppId = AppConfig.env('XUNFEI_ASR_APP_ID') ?? ''; - final xunfeiAccessKeyId = AppConfig.env('XUNFEI_ASR_ACCESS_KEY_ID') ?? ''; - final xunfeiAccessKeySecret = - AppConfig.env('XUNFEI_ASR_ACCESS_KEY_SECRET') ?? ''; - final jsonData = AppConfig.mcpConfig(); - Logger.info('启动 AgentService,musicServiceType: $musicServiceType'); - final isTtsInterruptEnabled = await _loadTtsInterrupe(); - - final vipExpiryTime = userInfo.User.instance.vipexptime; - - List supportedLanguages = []; - try { - final storage = GetStorage(); - final languageManager = Get.find(); - String? savedLanguage = storage.read('language'); - - if (savedLanguage != null && savedLanguage != 'system') { - final parts = savedLanguage.split('_'); - if (parts.length == 2) { - final lang = parts[0].toLowerCase(); - final country = parts[1].toUpperCase(); - - if (lang == 'zh' && - (country == 'HK' || country == 'TW' || country == 'CN')) { - supportedLanguages.add('zh-$country'); - } else if (lang == 'tl' && country == 'PH') { - supportedLanguages.add('fil-PH'); - } else { - supportedLanguages.add('$lang-$country'); - } - } - } else { - final appLocale = Get.locale ?? AppTranslations.deviceLocale(); - if (appLocale != null) { - final lang = appLocale.languageCode.toLowerCase(); - final country = appLocale.countryCode; - - if (lang.toLowerCase() == 'zh' && - country != null && - country.isNotEmpty && - (country.toUpperCase() == 'HK' || - country.toUpperCase() == 'TW' || - country.toUpperCase() == 'CN')) { - supportedLanguages.add('zh-${country.toUpperCase()}'); - } else { - if (country != null && country.isNotEmpty) { - final normalizedCountry = country.toUpperCase(); - if (lang == 'tl' && normalizedCountry == 'PH') { - supportedLanguages.add('fil-PH'); - } else { - supportedLanguages.add('$lang-$normalizedCountry'); - } - } else { - final asrCode = languageManager.getAsrCodeByShortCode(lang); - if (asrCode != null) { - supportedLanguages.add(asrCode); - } else { - supportedLanguages.add('en-US'); - } - } - } - } - } - - if (supportedLanguages.isEmpty) { - supportedLanguages.add('en-US'); - } - } catch (e) { - Logger.error('获取APP语言配置失败,使用默认语言: $e'); - supportedLanguages = ['zh-CN', 'en-US']; - } - - final agentType = _storage.read('device_agent_type') ?? - AppConfig.env('AGENT_TYPE') ?? - 'stt_llm_tts'; - final agentMode = (agentType == 'yidong') ? 'sts' : 'stt_llm_tts'; - final agentId = (agentType == 'yidong') - ? (AppConfig.env('MOBILE_ELF_AGENT_ID') ?? 'mobile_elf') - : 'default_agent'; - final stsProductId = AppConfig.env('MOBILE_ELF_PRODUCT_ID') ?? ''; - final stsProductKey = AppConfig.env('MOBILE_ELF_PRODUCT_KEY') ?? ''; - final stsDeviceNo = _storage.read('device_sts_code') ?? ''; - final stsDeviceId = _storage.read('sts_device_id') ?? ''; - final stsDeviceSecret = _storage.read('sts_device_secret') ?? ''; - final mac = currentDeviceAddress?.toUpperCase() ?? ''; - String stsCmei = ''; - try { - final matchedDevice = userInfo.User.instance.devices.firstWhere( - (d) => d.devicemac.toUpperCase() == mac, - ); - stsCmei = matchedDevice.cmei; - } catch (_) {} - - final config = { - 'agentMode': agentMode, - 'agentId': agentId, - 'stsProductId': stsProductId, - 'stsProductKey': stsProductKey, - 'stsDeviceNo': stsDeviceNo, - 'stsDeviceId': stsDeviceId, - 'stsDeviceSecret': stsDeviceSecret, - 'stsCmei': stsCmei, - 'azureSpeechKey': azureSpeechKey, - 'azureSpeechRegion': azureSpeechRegion, - 'xunfeiAppId': xunfeiAppId, - 'xunfeiAccessKeyId': xunfeiAccessKeyId, - 'xunfeiAccessKeySecret': xunfeiAccessKeySecret, - 'openaiApiKey': openaiApiKey, - 'openaiBaseUrl': openaiBaseUrl, - 'openaiModel': openaiModel, - 'openaivisionModel': openaiModelMedia, - 'systemPrompt': systemPrompt, - 'mcpServer': jsonEncode(jsonData), - 'serverurl': serverurl, - 'usertoken': jsonString, - 'supportedLanguages': supportedLanguages, - 'musicServiceType': musicServiceType, - 'navigationMode': navigationMode, - 'isttsinterrupt': isTtsInterruptEnabled ? 'true' : 'false', - 'vipexptime': vipExpiryTime, - 'callingToolText': 'callingTool'.tr, - 'insufficientIntegralText': 'insufficientIntegral'.tr, - }; - - 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); - } - }; - - if (agentMode == 'sts') { - AgentService.events - .where((e) => e.type == AgentServiceEventType.stsCredentials) - .listen((e) { - final deviceId = e.data['deviceId']?.toString() ?? ''; - final deviceSecret = e.data['deviceSecret']?.toString() ?? ''; - final deviceNo = e.data['deviceNo']?.toString() ?? ''; - if (deviceId.isNotEmpty) _storage.write('sts_device_id', deviceId); - if (deviceSecret.isNotEmpty) { - _storage.write('sts_device_secret', deviceSecret); - } - if (deviceNo.isNotEmpty) _storage.write('sts_device_no', deviceNo); - }); - } - - final result = await AgentService.startAgentService(config); - Logger.i('BleManager', 'AgentService.startAgentService 结果: $result'); - return result; - } catch (e) { - Logger.error('启动 AgentService 失败: $e'); - return false; - } - } - - Future stopAgentService() async { - try { - final result = await AgentService.stopAgentService(); - if (result) { - Logger.info('AgentService 停止成功'); - clearDeviceInfo(); - } - return result; - } catch (e) { - Logger.error('停止 AgentService 失败: $e'); - return false; - } - } - - Future _loadTtsInterrupe() async { - try { - if (!_storage.hasData('agent_tts_interrupt')) { - String deviceName = getDeviceName(); - DBProduct? product = AppConfig.getproduct(deviceName); - if (deviceName != '' && product != null) { - return !product.iscontinuouschat!; - } - return false; - } - return _storage.read('agent_tts_interrupt') ?? false; - } catch (_) { - return false; - } - } - - static Future init() async { - if (!Get.isRegistered()) { - Get.put(BleManager(), permanent: true); - } - return Future.value(Get.find()); - } -} diff --git a/apps/client/lib/data/services/bluetooth_manager.dart b/apps/client/lib/data/services/bluetooth_manager.dart deleted file mode 100644 index c68e9897..00000000 --- a/apps/client/lib/data/services/bluetooth_manager.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'dart:async'; -import 'package:get/get.dart'; -import 'package:classic_bluetooth/classic_bluetooth.dart'; -import '../../core/utils/logger.dart'; - -// 重新导出蓝牙设备类型,让外部可以通过BluetoothManager访问 -export 'package:classic_bluetooth/classic_bluetooth.dart' show BluetoothDevice; - -/// 蓝牙服务 - 管理经典蓝牙服务 -class BluetoothManager extends GetxService { - static BluetoothManager get to => Get.find(); - - // 蓝牙状态 - final _bluetoothState = 'unknown'.obs; - String get bluetoothState => _bluetoothState.value; - RxString get bluetoothStateRx => _bluetoothState; - - // 当前经典蓝牙设备 - final Rx currentDeviceRx = Rx(null); - BluetoothDevice? get currentDevice => currentDeviceRx.value; - // 订阅 - StreamSubscription? _bluetoothSubscription; - - /// 初始化 - @override - void onInit() { - super.onInit(); - Logger.info('蓝牙服务初始化'); - - // 设置蓝牙事件监听 - _setupBluetoothListener(); - - // 主动检查当前已连接的设备和蓝牙状态 - Future.delayed(Duration.zero, () async { - Logger.info('检查初始蓝牙连接状态'); - - // 先检查蓝牙是否开启 - await isBluetoothEnabled(); - - // 然后刷新设备连接 - await refreshDeviceConnection(); - }); - } - - /// 释放资源 - @override - void onClose() { - print("蓝牙服务关闭"); - _bluetoothSubscription?.cancel(); - super.onClose(); - } - - /// 设置经典蓝牙事件监听 - void _setupBluetoothListener() { - _bluetoothSubscription?.cancel(); - - try { - _bluetoothSubscription = ClassicBluetooth.events.listen( - (event) { - print("_setupBluetoothListener$event"); - if (event.type == 'stateChanged' && event.state != null) { - _bluetoothState.value = event.state!; - Logger.info('蓝牙状态变更: ${event.state}'); - - // 如果蓝牙关闭,清除设备 - if (event.state == 'off') { - currentDeviceRx.value = null; - } - } else if (event.type == 'deviceConnected' && event.device != null) { - // 直接从事件中获取设备信息 - final newDevice = event.device!; - - currentDeviceRx.value = newDevice; - - // 经典蓝牙连接事件:仅记录日志,不再跳转配对页(绑定流程已下线) - Logger.info('经典蓝牙设备已连接: ${newDevice.name} (${newDevice.address})'); - } else if (event.type == 'deviceDisconnected' && - event.device != null) { - // 直接从事件中获取设备信息 - final disconnectedDevice = event.device!; - - Logger.info( - '经典蓝牙设备已断开: ${disconnectedDevice.name} (${disconnectedDevice.address})'); - - // 如果断开的是当前设备,清除当前设备 - if (currentDeviceRx.value?.address == disconnectedDevice.address) { - currentDeviceRx.value = null; - Logger.info('已清除当前设备引用'); - } - } - }, - onError: (error) { - Logger.error('蓝牙事件流错误: ${error.toString()}'); - }, - ); - } catch (e) { - Logger.error('设置蓝牙事件监听器失败: ${e.toString()}'); - } - } - - /// 刷新设备连接状态 - Future refreshDeviceConnection() async { - try { - // 如果BLE未连接,则检查经典蓝牙连接 - final devices = await ClassicBluetooth.getConnectedDevices(); - - if (devices.isNotEmpty) { - for (var device in devices) { - if (device.isActive) { - // 如果有活动设备,直接使用它 - currentDeviceRx.value = device; - Logger.info('已连接经典蓝牙设备: ${device.name} (${device.address})'); - return; - } - } - final newDevice = devices.first; - currentDeviceRx.value = newDevice; - Logger.info('已连接经典蓝牙设备: ${newDevice.name} (${newDevice.address})'); - return; - } - - // 没有任何连接 - currentDeviceRx.value = null; - Logger.info('未找到已连接的蓝牙设备'); - } catch (e) { - Logger.error('刷新设备连接失败: ${e.toString()}'); - } - } - - /// 检查蓝牙是否已启用 - Future isBluetoothEnabled() async { - try { - final isEnabled = await ClassicBluetooth.isBluetoothEnabled(); - _bluetoothState.value = isEnabled ? 'on' : 'off'; - return isEnabled; - } catch (e) { - Logger.error('检查蓝牙状态失败: ${e.toString()}'); - return false; - } - } - -} diff --git a/apps/client/lib/data/services/face_to_face_ble_service.dart b/apps/client/lib/data/services/face_to_face_ble_service.dart deleted file mode 100644 index 69d621ce..00000000 --- a/apps/client/lib/data/services/face_to_face_ble_service.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:get/get.dart'; - -import '../../core/utils/logger.dart'; -import 'bes_bluetooth_service.dart'; - -/// 面对面翻译时,左右耳分别独立走 BLE 的连接管理。 -/// -/// 背景:普通模式下耳机是一条经典蓝牙 SPP([BesBluetoothService])统一收发; -/// 面对面翻译要让两只耳朵各自朝向一位说话人——左耳收/放一种语言, -/// 右耳收/放另一种语言——所以连接模式要整体切到 BLE, -/// **左耳一条 BLE、右耳一条 BLE,互相独立**。 -/// -/// 目前状态:**框架先搭好,具体连接与收发留空**。 -/// 固件那边的服务/特征 UUID、左右耳的广播名与配对流程都还没给, -/// 等命令确定后在 [_connectSide] / [_disconnectSide] / [sendToSide] 里补实现即可, -/// 上层(面对面翻译页与控制器)不需要再改。 -/// -/// 这样安排的原因:面对面翻译的开关时机、UI 状态、与 SPP 的互斥关系 -/// 是现在就能定下来的,先把这层接口固化,后面补底层不会牵动业务逻辑。 -enum EarSide { left, right } - -class FaceToFaceBleService extends GetxService { - static const String _tag = 'FaceToFaceBleService'; - static FaceToFaceBleService get to => Get.find(); - - /// 左右耳各自的连接状态:0=未连接 1=连接中 2=已连接 - final RxInt leftStatus = 0.obs; - final RxInt rightStatus = 0.obs; - - /// 当前是否处于面对面翻译的 BLE 模式 - final RxBool isBleMode = false.obs; - - bool get leftConnected => leftStatus.value == 2; - bool get rightConnected => rightStatus.value == 2; - - /// 进入面对面翻译:通知耳机切模式(`AA 66`),随后把左右耳各自拉起 BLE。 - /// - /// 返回是否成功进入模式。左右耳 BLE 尚未实现,当前只要 `AA 66` 发出去就算成功, - /// 不因为 BLE 没连上而阻断——否则功能在固件就绪前完全不可用。 - Future enterFaceToFaceMode() async { - if (isBleMode.value) return true; - try { - if (Get.isRegistered() && - BesBluetoothService.to.isConnected) { - await BesBluetoothService.to.startFaceToFace(); // AA 66 - Logger.i(_tag, '已下发 AA 66,耳机进入面对面翻译模式'); - } else { - Logger.w(_tag, '耳机未连接,跳过 AA 66'); - } - isBleMode.value = true; - // 左右耳分别建立独立 BLE 连接(待实现) - await _connectSide(EarSide.left); - await _connectSide(EarSide.right); - return true; - } catch (e) { - Logger.e(_tag, '进入面对面翻译模式失败: $e'); - return false; - } - } - - /// 退出面对面翻译:断开左右耳 BLE,并通知耳机回到常规模式(`AA 67`) - Future exitFaceToFaceMode() async { - if (!isBleMode.value) return; - try { - await _disconnectSide(EarSide.left); - await _disconnectSide(EarSide.right); - if (Get.isRegistered() && - BesBluetoothService.to.isConnected) { - await BesBluetoothService.to.stopFaceToFace(); // AA 67 - Logger.i(_tag, '已下发 AA 67,耳机退出面对面翻译模式'); - } - } catch (e) { - Logger.e(_tag, '退出面对面翻译模式失败: $e'); - } finally { - isBleMode.value = false; - leftStatus.value = 0; - rightStatus.value = 0; - } - } - - /// 建立某一侧耳机的独立 BLE 连接。 - /// - /// TODO(固件): 命令与 UUID 确定后在此实现—— - /// 扫描该侧广播 → 连接 → 发现服务 → 订阅通知 → 置 status=2。 - Future _connectSide(EarSide side) async { - final status = side == EarSide.left ? leftStatus : rightStatus; - status.value = 1; - Logger.i(_tag, '${_sideName(side)} BLE 连接:待固件命令确定后实现'); - // 尚未实现,保持「连接中」而不是假装已连接,避免上层误判为可用 - status.value = 0; - } - - /// 断开某一侧耳机的 BLE 连接。 - /// - /// TODO(固件): 与 [_connectSide] 配套实现。 - Future _disconnectSide(EarSide side) async { - final status = side == EarSide.left ? leftStatus : rightStatus; - if (status.value == 0) return; - Logger.i(_tag, '${_sideName(side)} BLE 断开:待实现'); - status.value = 0; - } - - /// 向指定一侧下发数据(译文音频 / 控制指令)。 - /// - /// TODO(固件): 左右耳各自的写特征确定后在此实现。 - /// 现在直接返回 false,调用方据此判断「该侧尚不可用」。 - Future sendToSide(EarSide side, List bytes) async { - Logger.d(_tag, '${_sideName(side)} 下发 ${bytes.length} 字节:待实现'); - return false; - } - - String _sideName(EarSide side) => side == EarSide.left ? '左耳' : '右耳'; -} diff --git a/apps/client/lib/data/services/holder_recording_service.dart b/apps/client/lib/data/services/holder_recording_service.dart deleted file mode 100644 index 6ae81925..00000000 --- a/apps/client/lib/data/services/holder_recording_service.dart +++ /dev/null @@ -1,309 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math' as math; -import 'dart:typed_data'; - -import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; -import 'package:get/get.dart'; -import 'package:intl/intl.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -import '../../core/utils/logger.dart'; -import 'holder_device_service.dart'; -import 'recording_archive.dart'; - -/// 蓝讯支架录音的全局采集与归档服务。 -/// -/// 之前这套逻辑挂在现场录音页的控制器上,页面一关监听就没了——用户在支架上 -/// 按录音键时 App 完全没反应。现在提到常驻服务里:不管当前在哪个页面、甚至 -/// 页面没打开,只要设备端起停录音,这里都会跟着采集/落盘/归档。 -/// -/// 职责: -/// 1. 订阅 [HolderDeviceService.recording],设备端开始/结束录音时自动起停 -/// 2. 录制期间把 SDK 推的 PCM 帧写成本地 WAV(16kHz/16bit/单声道) -/// 3. 结束后归档进「语音记录助手」,由 [MeetingUploadService] 按既有逻辑上云 -class HolderRecordingService extends GetxService { - static const String _tag = 'HolderRecordingService'; - static HolderRecordingService get to => Get.find(); - - /// 当前是否正在录(由设备端状态驱动) - final RxBool isRecording = false.obs; - - /// 已录毫秒数 - final RxInt elapsedMs = 0.obs; - - /// 当前电平 0~1,供波形使用 - final RxDouble level = 0.0.obs; - - /// 波形采样序列(固定 [sampleIntervalMs] 一格) - final RxList waveform = [].obs; - static const int sampleIntervalMs = 100; - static const int _maxSamples = 600; - - Worker? _stateWorker; - StreamSubscription? _audioSub; - RandomAccessFile? _raf; - int _dataBytes = 0; - String? _path; - Timer? _tick; - Timer? _sampleTimer; - final Stopwatch _stopwatch = Stopwatch(); - - @override - void onInit() { - super.onInit(); - _bind(); - } - - @override - void onClose() { - _stateWorker?.dispose(); - _audioSub?.cancel(); - _tick?.cancel(); - _sampleTimer?.cancel(); - super.onClose(); - } - - void _bind() { - if (!Get.isRegistered()) return; - final holder = Get.find(); - _stateWorker = ever(holder.recording, (st) async { - Logger.i(_tag, - '设备录音状态上报: isRecording=${st.isRecording} timerStatus=${st.timerStatus} ' - 'seconds=${st.seconds} 本地=${isRecording.value} 忙=$_busy'); - if (_busy) { - Logger.w(_tag, '本地正在起停中,忽略这次上报'); - return; - } - if (st.isRecording && !isRecording.value) { - Logger.i(_tag, '设备端开始录音,启动本地采集'); - await start(); - } else if (!st.isRecording && isRecording.value) { - Logger.i(_tag, '设备端结束录音,落盘并归档'); - await stop(); - } - }); - } - - /// 开始本地采集。[sendCommand] = true 时同时下发设备开始指令 - /// (App 主动发起录音的场景)。 - /// 起停过程中的互斥标志:设备上报和 UI 操作可能同时进来, - /// 不加锁会出现「刚建好文件又被另一路关掉」的竞态。 - bool _busy = false; - - Future start({bool sendCommand = false}) async { - if (isRecording.value || _busy) return isRecording.value; - _busy = true; - try { - return await _start(sendCommand); - } finally { - _busy = false; - } - } - - Future _start(bool sendCommand) async { - if (!Get.isRegistered()) return false; - final holder = Get.find(); - if (!holder.isConnected) return false; - - try { - final docs = await getApplicationDocumentsDirectory(); - final dir = Directory('${docs.path}/field_recordings'); - if (!await dir.exists()) await dir.create(recursive: true); - final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - _path = '${dir.path}/holder_$ts.wav'; - - _raf = await File(_path!).open(mode: FileMode.write); - await _raf!.writeFrom(_wavHeader(0)); - _dataBytes = 0; - - _audioSub?.cancel(); - _audioSub = holder.audioFrames.listen(_onFrame); - - // 顺序很关键:**先切录音状态,再开编码器**。 - // - // toggleRecording 是盲切:原生 BleRecord 若当前是 RUNNING,切过去会走 - // pauseRecord() → BleService.closeCodec(),把刚开的编码器关掉。实测就是 - // openEncoder 后 5ms 立刻跟了一条 0x05/0x00(CODEC_CONTROL_CLOSE)。 - // 把 openEncoder 放到最后,保证不论状态怎么切,最终编码器都是开着的。 - if (sendCommand && !holder.deviceIsRecording) { - final ok = await holder.startRecording(); - if (!ok) { - await _closeFile(save: false); - return false; - } - } else if (sendCommand) { - Logger.i(_tag, '设备已在录音中,跳过状态切换,仅接管本地采集'); - } - - // 【必须】打开设备编码器,否则设备不往手机推 Opus 流。 - // 协议帧 0xAA 0x05 0x02 0xB1 0x02 [CRC]。设备按键发起的录音只会让原生 - // 切本地录音态,不会自动开编码器,所以这里无条件补一条。 - final encoderOk = await holder.openEncoder(); - if (!encoderOk) { - Logger.w(_tag, 'openEncoder 返回 false,可能收不到音频流'); - } - - isRecording.value = true; - elapsedMs.value = 0; - waveform.clear(); - _stopwatch - ..reset() - ..start(); - _tick?.cancel(); - _tick = Timer.periodic(const Duration(milliseconds: 50), - (_) => elapsedMs.value = _stopwatch.elapsedMilliseconds); - _sampleTimer?.cancel(); - _sampleTimer = Timer.periodic( - const Duration(milliseconds: sampleIntervalMs), - (_) => _sample(), - ); - Logger.i(_tag, '开始录制: $_path'); - return true; - } catch (e) { - Logger.e(_tag, '启动本地采集失败: $e'); - await _closeFile(save: false); - return false; - } - } - - /// 停止。[sendCommand] = true 时同时下发设备停止指令。 - Future stop({bool sendCommand = false}) async { - if (!isRecording.value || _busy) return; - _busy = true; - try { - await _stop(sendCommand); - } finally { - _busy = false; - } - } - - Future _stop(bool sendCommand) async { - _tick?.cancel(); - _sampleTimer?.cancel(); - _stopwatch.stop(); - - final int seconds = elapsedMs.value ~/ 1000; - final String? path = _path; - - if (sendCommand && - Get.isRegistered() && - Get.find().deviceIsRecording) { - try { - await Get.find().stopRecording( - fileName: path == null ? null : p.basenameWithoutExtension(path)); - } catch (e) { - Logger.e(_tag, '下发停止指令失败: $e'); - } - } - - await _closeFile(save: true); - isRecording.value = false; - elapsedMs.value = 0; - level.value = 0; - waveform.clear(); - - if (path != null && _dataBytes > 0) { - // 归档会把文件从 field_recordings 挪到 LocalAudio, - // 所以现场录音页的列表里不会再留副本——录音成品统一由语音记录助手管理。 - await _archive(path, seconds); - } else { - // 没收到音频的空文件(只有 44 字节 WAV 头)直接删掉, - // 否则会在现场录音列表里堆一堆 00:00 的无效记录。 - Logger.w(_tag, '本次没有收到音频数据,删除空文件'); - if (path != null) { - try { - final f = File(path); - if (await f.exists()) await f.delete(); - } catch (e) { - Logger.e(_tag, '删除空录音文件失败: $e'); - } - } - } - _dataBytes = 0; - _path = null; - } - - void _onFrame(EchoMeetAudioFrame frame) async { - final raf = _raf; - if (raf == null || frame.pcm.isEmpty) return; - try { - await raf.writeFrom(frame.pcm); - _dataBytes += frame.pcm.length; - _updateLevel(frame.pcm); - } catch (e) { - Logger.e(_tag, '写入 PCM 失败: $e'); - } - } - - void _updateLevel(Uint8List pcm) { - if (pcm.lengthInBytes < 2) return; - final s = pcm.buffer.asInt16List(pcm.offsetInBytes, pcm.lengthInBytes ~/ 2); - var sum = 0.0; - for (final v in s) { - final n = v / 32768.0; - sum += n * n; - } - final rms = math.sqrt(sum / s.length); - level.value = math.sqrt(rms).clamp(0.0, 1.0); - } - - void _sample() { - final list = waveform.toList(); - list.add(level.value); - if (list.length > _maxSamples) { - list.removeRange(0, list.length - _maxSamples); - } - waveform.assignAll(list); - } - - Future _closeFile({required bool save}) async { - await _audioSub?.cancel(); - _audioSub = null; - final raf = _raf; - _raf = null; - if (raf == null) return; - try { - if (save) { - await raf.setPosition(0); - await raf.writeFrom(_wavHeader(_dataBytes)); - } - await raf.close(); - } catch (e) { - Logger.e(_tag, '关闭录音文件失败: $e'); - } - } - - /// 归档进语音记录助手。具体入库逻辑见 [RecordingArchive]—— - /// 现场录音 / 通话录音 / 翻译录音都走同一套,不再各抄一份。 - Future _archive(String path, int seconds) async { - await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag); - } - - Uint8List _wavHeader(int dataLength) { - const sampleRate = 16000, channels = 1, bits = 16; - final byteRate = sampleRate * channels * (bits ~/ 8); - final h = ByteData(44); - void ascii(int off, String s) { - for (var i = 0; i < s.length; i++) { - h.setUint8(off + i, s.codeUnitAt(i)); - } - } - - ascii(0, 'RIFF'); - h.setUint32(4, 36 + dataLength, Endian.little); - ascii(8, 'WAVE'); - ascii(12, 'fmt '); - h.setUint32(16, 16, Endian.little); - h.setUint16(20, 1, Endian.little); - h.setUint16(22, channels, Endian.little); - h.setUint32(24, sampleRate, Endian.little); - h.setUint32(28, byteRate, Endian.little); - h.setUint16(32, channels * (bits ~/ 8), Endian.little); - h.setUint16(34, bits, Endian.little); - ascii(36, 'data'); - h.setUint32(40, dataLength, Endian.little); - return h.buffer.asUint8List(); - } -} diff --git a/apps/client/lib/data/services/kouzi_ai_service.dart b/apps/client/lib/data/services/kouzi_ai_service.dart deleted file mode 100644 index c19f23d1..00000000 --- a/apps/client/lib/data/services/kouzi_ai_service.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -import '../../core/utils/logger.dart'; -import 'ai_service.dart'; - -class KouziAIService implements AiService { - final String apiKey = - 'pat_QGDh6UGm3PaeL9GRS95JxrJ9aoU92ehMLQQ2NCre1TeBC5YeTcjpDmlz3efsJ8lh'; - final String modelId = '7481292724306657316'; - - @override - Future sendMessage({ - required List> messages, - String? systemPrompt, - String? userProperties, - }) async { - return ''; - } - - @override - Stream sendMessageStream({ - required List> messages, - String? systemPrompt, - String? userProperties, - }) async* { - final client = http.Client(); - final url = Uri.parse('https://api.coze.cn/v3/chat'); - - final headers = { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $apiKey', - }; - - final requestBody = { - "bot_id": modelId, - "user_id": "3422", - "additional_messages": [ - { - "content": messages.last['content'], - "content_type": "text", - "role": "user", - "type": "question", - }, - ], - "shortcut_command": {"command_id": "0"}, - "stream": true, - }; - - try { - final request = http.Request('POST', url) - ..headers.addAll(headers) - ..body = jsonEncode(requestBody); - final response = await client.send(request); - final regex = RegExp(r'(?<=[。!?;:.:;?!\n])'); - - String allContent = ''; - //流式处理响应数据 - await for (final chunk in response.stream - .transform(utf8.decoder) - .transform(const LineSplitter())) { - if (chunk.isEmpty) continue; - - if (chunk.startsWith('data:')) { - final jsonStr = chunk.substring(5).trim(); - if (jsonStr == '[DONE]') { - break; - } - - try { - final data = jsonDecode(jsonStr); - if (data['type'] == 'answer') { - String content = data['content'] ?? ''; - if (content.isNotEmpty && allContent != content) { - allContent += content; - List splitText = - content.split(regex).where((s) => s.isNotEmpty).toList(); - for (var element in splitText) { - yield element; - } - } - } - } catch (e) { - Logger.error('JSON解析错误: ${e.toString()}'); - } - } - } - } catch (e) { - Logger.error('请求异常: ${e.toString()}'); - } finally { - client.close(); - } - } -} \ No newline at end of file diff --git a/apps/client/lib/data/services/open_ai_service_adapter.dart b/apps/client/lib/data/services/open_ai_service_adapter.dart deleted file mode 100644 index 54bb667a..00000000 --- a/apps/client/lib/data/services/open_ai_service_adapter.dart +++ /dev/null @@ -1,301 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:convert'; -import '../../data/models/appconfig.dart'; -import '../../data/models/appconfig_model.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:open_ai_service/open_ai_service.dart'; -import 'package:get/get.dart'; -import 'ai_service.dart'; -import 'package:flutter/services.dart'; -import 'package:path_provider/path_provider.dart'; - -/// 定义流事件类型,用于区分不同类型的事件 -enum StreamEventType { token, functionCall, complete, error } - -/// 流事件包装类 -class StreamEvent { - final StreamEventType type; - final String? content; - final Map? meta; - - StreamEvent(this.type, {this.content, this.meta}); -} - -/// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件 -class OpenAIServiceAdapter implements AiService { - final OpenAIService _openAIService = OpenAIService(); - StreamSubscription? _eventSubscription; - final StreamController _tokenStreamController = - StreamController.broadcast(); - final GetStorage _storage = GetStorage(); - - /// 构造函数 - OpenAIServiceAdapter() { - printInfo(info: '创建OpenAIServiceAdapter实例'); - _setupEventListener(); - } - - /// 设置事件监听器 - void _setupEventListener() { - try { - // 首先确保访问eventStream以初始化底层事件通道 - _openAIService.eventStream; - - // 设置事件处理 - _eventSubscription = _openAIService.eventStream.listen( - (event) { - try { - switch (event.type) { - case OpenAIEventType.token: - if (event.content is String) { - _tokenStreamController.add(StreamEvent(StreamEventType.token, - content: event.content as String)); - } else { - printInfo(info: '收到非字符串类型的token: ${event.content}'); - } - break; - case OpenAIEventType.complete: - _tokenStreamController - .add(StreamEvent(StreamEventType.complete)); - break; - case OpenAIEventType.error: - _tokenStreamController.add(StreamEvent(StreamEventType.error, - content: '未知错误: ${event.content}')); - break; - case OpenAIEventType.functionCall: - printInfo( - info: - '接收到 functionCall content: ${event.content} meta: ${event.meta}'); - _tokenStreamController.add(StreamEvent( - StreamEventType.functionCall, - meta: event.meta as Map?)); - // _tokenStreamController.add(StreamEvent( - // StreamEventType.error, - // content: '收到函数调用,该流仅支持文本响应' - // )); - break; - } - } catch (e) { - printError(info: '处理事件出错: $e'); - _tokenStreamController - .add(StreamEvent(StreamEventType.error, content: '处理事件失败: $e')); - } - }, - onError: (error) { - printError(info: '事件流错误: $error'); - _tokenStreamController.add( - StreamEvent(StreamEventType.error, content: '事件流错误: $error')); - }, - onDone: () { - printInfo(info: '事件流已关闭'); - }, - ); - } catch (e) { - printError(info: '设置事件监听器失败: $e'); - } - } - - /// 初始化OpenAI服务 - Future initialize() async { - try { - // 从.env文件中读取配置 - // final apiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; - // final baseUrl = dotenv.env['OPENAI_BASE_URL'] ?? ''; - // final model = dotenv.env['OPENAI_MODEL'] ?? ''; - // final _env = _storage.read("ENV") as Map; - final apiKey = AppConfig.env('OPENAI_API_KEY') ?? ''; - final baseUrl = AppConfig.env('OPENAI_BASE_URL') ?? ''; - final model = AppConfig.env('OPENAI_MODEL') ?? ''; - - printInfo(info: '从.env读取OpenAI配置'); - printInfo(info: '基础URL: $baseUrl'); - printInfo(info: '模型名称: $model'); - - if (apiKey.isEmpty) { - printError(info: '错误: OpenAI API密钥未配置,请在.env文件中设置OPENAI_API_KEY'); - return false; - } - final _mcps = _storage.read("MCPS") as Map; - // 读取.mcp.json文件 - String mcpConfig = jsonEncode(_mcps); - // try { - // // 从Flutter资源包中加载.mcp.json - // mcpConfig = await rootBundle.loadString('.mcp.json'); - // printInfo(info: '成功从资源包加载.mcp.json配置文件'); - - // // 验证JSON格式 - // final jsonData = jsonDecode(mcpConfig); - // if (jsonData is Map && jsonData.containsKey('mcpServers')) { - // printInfo(info: '解析到有效的mcpServers配置'); - // } else { - // printInfo(info: '.mcp.json内容格式不正确,期望包含mcpServers字段'); - // } - // } catch (e) { - // printError(info: '加载.mcp.json资源文件时出错: $e'); - // } - - // 初始化OpenAI服务 - final result = await _openAIService.initialize( - apiKey: apiKey, - baseUrl: baseUrl, - model: model, - mcpServer: mcpConfig, - ); - - if (result) { - printInfo(info: 'OpenAI服务初始化成功'); - } else { - printError(info: 'OpenAI服务初始化失败'); - } - - return result; - } catch (e) { - printError(info: 'OpenAI服务初始化异常: $e'); - return false; - } - } - - /// 发送消息并获取回复 - @override - Future sendMessage({ - required List> messages, - String? systemPrompt, - String? userProperties, - }) async { - try { - // 在方法内直接转换 - final convertedMessages = - messages.map((m) => Map.from(m)).toList(); - - // 发送消息并获取回复 - final response = await _openAIService.sendMessage( - messages: convertedMessages, - systemPrompt: systemPrompt, - userProperties: userProperties, - ); - - return response; - } catch (e) { - printError(info: 'OpenAI发送消息失败: $e'); - throw '发送消息失败: $e'; - } - } - - /// 发送消息并获取流式回复 - @override - Stream sendMessageStream({ - required List> messages, - String? systemPrompt, //提示词 - String? userProperties, //用户属性 - }) async* { - try { - // 在方法内直接转换 - final convertedMessages = - messages.map((m) => Map.from(m)).toList(); - - // 创建用于接收token的控制器 - final localController = StreamController(); - - // 添加从广播流到本地流的订阅 - final subscription = _tokenStreamController.stream.listen((streamEvent) { - switch (streamEvent.type) { - case StreamEventType.token: - if (streamEvent.content != null && !localController.isClosed) { - localController.add(streamEvent.content!); - } - break; - case StreamEventType.functionCall: - if (streamEvent.meta != null && !localController.isClosed) { - localController.add(streamEvent.meta!); - } - break; - case StreamEventType.complete: - if (!localController.isClosed) { - localController.close(); - } - break; - case StreamEventType.error: - if (!localController.isClosed) { - printError(info: '令牌流错误: ${streamEvent.content}'); - localController.addError(streamEvent.content ?? '未知错误'); - localController.close(); - } - break; - } - }); - - // 当本地控制器关闭时,取消订阅 - localController.onCancel = () { - subscription.cancel(); - }; - - // 启动流式消息请求 - bool started = false; - try { - started = await _openAIService.sendMessageStream( - messages: convertedMessages, - systemPrompt: systemPrompt, - userProperties: userProperties, - ); - } catch (e) { - printError(info: '启动消息流失败: $e'); - if (!localController.isClosed) { - localController.addError('启动消息流失败: $e'); - localController.close(); - } - throw '启动消息流失败: $e'; - } - - if (!started) { - printError(info: '无法启动消息流'); - if (!localController.isClosed) { - localController.addError('无法启动消息流'); - localController.close(); - } - throw '无法启动消息流'; - } - - // 通过yield*将controller的流转发 - yield* localController.stream; - } catch (e) { - printError(info: 'OpenAI流式消息处理失败: $e'); - throw '流式消息处理失败: $e'; - } - } - - /// 注册函数 - Future registerFunction( - String name, String description, Map parameters) async { - try { - final result = await _openAIService.registerFunction( - name: name, - description: description, - parameters: parameters, - ); - - if (result) { - printInfo(info: '函数 "$name" 注册成功'); - } else { - printError(info: '函数 "$name" 注册失败'); - } - - return result; - } catch (e) { - printError(info: '注册函数失败: $e'); - return false; - } - } - - /// 释放资源 - void dispose() { - try { - _eventSubscription?.cancel(); - _tokenStreamController.close(); - printInfo(info: 'OpenAIServiceAdapter资源已释放'); - } catch (e) { - printError(info: '释放资源时出错: $e'); - } - } -} diff --git a/apps/client/lib/data/utils/jieli_adv_parser.dart b/apps/client/lib/data/utils/jieli_adv_parser.dart deleted file mode 100644 index 6dff2322..00000000 --- a/apps/client/lib/data/utils/jieli_adv_parser.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'dart:io'; - -import 'package:device_jieli/device_jieli.dart'; - -import '../../core/utils/logger.dart'; - -/// 杰理设备广播包解析结果:从扫描到的 [JieliDevice.rawAdv] 中提取出 -/// 服务端绑定接口需要的 PID / MAC / 设备授权码 (code)。 -class JieliBindInfo { - /// 产品 ID(小端字节解析) - final int pid; - - /// 设备真实 MAC(XOR 0x5A 解码后),格式 "AA:BB:CC:DD:EE:FF" - final String mac; - - /// 设备授权码 (license / SN),hex 大写 - final String code; - - /// 解析得到的 PID 是否与扫描结果上的 BLE/EDR 地址一致 - /// (仅用于日志,不强制要求一致) - final bool macMatchesAddress; - - const JieliBindInfo({ - required this.pid, - required this.mac, - required this.code, - required this.macMatchesAddress, - }); - - @override - String toString() => - 'JieliBindInfo(pid=0x${pid.toRadixString(16).toUpperCase()}, mac=$mac, code=$code, match=$macMatchesAddress)'; -} - -/// 杰理 BLE 广播解析。 -/// -/// 字节布局(与旧版一致,基于扫到的 raw scanRecord 字节): -/// Android: -/// - bytes[6..7] → PID -/// - bytes[9..14] → MAC(每字节 XOR 0x5A) -/// - bytes[15..24] → 授权码 code -/// iOS: -/// - bytes[4..5] → PID -/// - bytes[7..12] → MAC(每字节 XOR 0x5A) -/// - bytes[13..22] → 授权码 code -class JieliAdvParser { - static const String _tag = 'JieliAdvParser'; - - /// 用 [device.rawAdv] 直接解析。若 raw 数据不完整或 device 未携带广播包,返回 null。 - static JieliBindInfo? parse(JieliDevice device) { - final raw = device.rawAdv; - if (raw == null || raw.isEmpty) { - Logger.w(_tag, '设备 ${device.address} 没有 rawAdv,无法解析绑定信息'); - return null; - } - return parseHex(raw, deviceAddress: device.address); - } - - /// 直接对 hex 字符串解析,便于测试。 - static JieliBindInfo? parseHex(String hex, {String? deviceAddress}) { - final bytes = _hexToBytes(hex); - if (bytes == null) { - Logger.w(_tag, 'rawAdv 不是合法 hex: $hex'); - return null; - } - - final int pidStart; - final int pidEnd; - final int macStart; - final int macEnd; - final int codeStart; - final int codeEnd; - - if (Platform.isIOS) { - pidStart = 4; - pidEnd = 5; - macStart = 7; - macEnd = 12; - codeStart = 13; - codeEnd = 22; - } else { - // Android 默认;其他平台沿用 Android 偏移。 - pidStart = 6; - pidEnd = 7; - macStart = 9; - macEnd = 14; - codeStart = 15; - codeEnd = 24; - } - - if (bytes.length <= codeEnd) { - Logger.w(_tag, - 'rawAdv 长度不足 ${codeEnd + 1} 字节(实际 ${bytes.length}),跳过解析'); - return null; - } - - final pid = _bytesToIntBigEndian(bytes, pidStart, pidEnd); - final mac = _decodeMac(bytes, macStart, macEnd); - final code = _bytesToHexReversed(bytes, codeStart, codeEnd); - - bool match = false; - if (deviceAddress != null && deviceAddress.isNotEmpty) { - final a = deviceAddress.replaceAll(':', '').toUpperCase(); - final b = mac.replaceAll(':', '').toUpperCase(); - match = a == b; - } - - final info = JieliBindInfo( - pid: pid, - mac: mac, - code: code, - macMatchesAddress: match, - ); - Logger.i(_tag, '解析广播: $info (deviceAddress=$deviceAddress)'); - return info; - } - - static List? _hexToBytes(String hex) { - final s = hex.startsWith('0x') || hex.startsWith('0X') ? hex.substring(2) : hex; - if (s.length.isOdd) return null; - final out = []; - for (int i = 0; i < s.length; i += 2) { - final v = int.tryParse(s.substring(i, i + 2), radix: 16); - if (v == null) return null; - out.add(v); - } - return out; - } - - static int _bytesToIntBigEndian(List bytes, int start, int end) { - int result = 0; - for (int i = start; i <= end; i++) { - result = (result << 8) | (bytes[i] & 0xFF); - } - return result; - } - - static String _bytesToHexReversed(List bytes, int start, int end) { - final slice = bytes.sublist(start, end + 1); - return slice - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join() - .toUpperCase(); - } - - static String _decodeMac(List bytes, int start, int end) { - final out = []; - for (int i = start; i <= end; i++) { - final decoded = bytes[i] ^ 0x5A; - out.add(decoded.toRadixString(16).padLeft(2, '0').toUpperCase()); - } - return out.join(':'); - } -} diff --git a/apps/client/lib/data/services/bes_bluetooth_service.dart b/apps/client/lib/devices/bes/bes_bluetooth_service.dart similarity index 100% rename from apps/client/lib/data/services/bes_bluetooth_service.dart rename to apps/client/lib/devices/bes/bes_bluetooth_service.dart diff --git a/apps/client/lib/data/services/bes_device_auth.dart b/apps/client/lib/devices/bes/bes_device_auth.dart similarity index 100% rename from apps/client/lib/data/services/bes_device_auth.dart rename to apps/client/lib/devices/bes/bes_device_auth.dart diff --git a/apps/client/lib/data/services/holder_device_service.dart b/apps/client/lib/devices/holder/holder_device_service.dart similarity index 100% rename from apps/client/lib/data/services/holder_device_service.dart rename to apps/client/lib/devices/holder/holder_device_service.dart diff --git a/apps/client/lib/modules/agent/bindings/agent_binding.dart b/apps/client/lib/modules/agent/bindings/agent_binding.dart deleted file mode 100644 index 4ae58711..00000000 --- a/apps/client/lib/modules/agent/bindings/agent_binding.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/agent_controller.dart'; -import '../controllers/message_action_controller.dart'; - -class AgentBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => AgentController()); - Get.lazyPut(() => MessageActionController()); - } -} diff --git a/apps/client/lib/modules/agent/controllers/agent_controller.dart b/apps/client/lib/modules/agent/controllers/agent_controller.dart deleted file mode 100644 index 9fbb2410..00000000 --- a/apps/client/lib/modules/agent/controllers/agent_controller.dart +++ /dev/null @@ -1,3014 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:amap_service/amap_service.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:uuid/uuid.dart'; -import '/data/models/appconfig.dart'; -import '/data/models/appconfig_model.dart'; -import '/data/models/song_model.dart'; -import '/data/services/ble_manager.dart'; -import '/data/services/bluetooth_manager.dart'; -import '/data/services/qq_music.dart'; -import 'package:flutter_intro/flutter_intro.dart'; -import '../../../data/services/novice_guide_service.dart'; -import '/core/utils/upload_oss.dart'; -import '/data/models/user_Info.dart'; -import '/data/services/music_manager.dart'; -import '/data/services/navigation_manager.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:agent_service/agent_service.dart'; -import 'package:chat_storage/chat_storage.dart'; -import 'package:device_jieli/device_jieli.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:permission_handler/permission_handler.dart'; -import '../../../core/utils/logger.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:photo_view/photo_view.dart'; -import '../../../core/utils/permission_util.dart'; -import '../../../data/models/message_model.dart'; -import '../../common/widgets/recharge_dialog.dart'; - -// 语音输入模式枚举 -enum VoiceInputMode { - text, // 文字输入模式 - pushToTalk, // 按住说话模式 - phoneCall, // 打电话模式 -} - -class AgentController extends GetxController with WidgetsBindingObserver { - static AgentController get to => Get.find(); //单例访问模式 - - final GetStorage _storage = GetStorage(); - // 聊天消息列表 - final messages = [].obs; - final currsessionid = ''.obs; - final vipExpiryTime = 0.obs; - // UI控制器 - final TextEditingController textController = TextEditingController(); - - /// 输入框内是否有内容(驱动底部输入栏发送按钮的显示/隐藏) - final hasInputText = false.obs; - final ScrollController scrollController = ScrollController(); - - final noviceGuideService = Get.find(); - // 确保服务已初始化 - // ========== 依赖服务 ========== - final BluetoothManager _bluetoothManager = Get.find(); - final BleManager _bleManager = Get.find(); - //音乐播放管理 - final _musiceManager = Get.find(); - final _qqmusicManager = Get.find(); - // 导航管理 - final _navigationManager = Get.find(); - RxBool isPlaying = false.obs; - RxInt currentId = 0.obs; - - // 播放进度相关状态 - RxInt currentPosition = 0.obs; // 当前播放位置(毫秒) - RxInt duration = 0.obs; // 总时长(毫秒) - RxDouble progress = 0.0.obs; // 播放进度(0.0-1.0) - - // 当前播放列表管理 - final currentPlaylist = >[].obs; // 当前播放列表 - final currentPlaylistIndex = 0.obs; // 当前播放歌曲在列表中的索引 - - // 各种状态标志 - final isInitialized = false.obs; - final isListening = false.obs; //是否正在语音识别 - final isSpeaking = false.obs; //是否正在播放tts - final isProcessing = false.obs; - // TTS启用状态 - final isTtsEnabled = true.obs; - final ttsRatePercent = 25.obs; - // TTS播报打断启用状态 - final isTtsInterruptEnabled = false.obs; - // 是否显示工具调用 - final isShowToolInterruptEnabled = false.obs; - // 图片处理状态 - final isImageProcessing = false.obs; - - // 输入模式控制 - final isTextInputMode = true.obs; //文本输入模式vs语音输入模式 - - // 当前输入的文本内容 - final currentText = ''.obs; - - // 图片输入相关 - final isImageInputActive = false.obs; // 是否处于图片输入模式 - final selectedImagePath = Rx(null); // 当前选择的图片路径 - - // 输入状态控制 - 用于动态显示发送按钮 - final hasTextInput = false.obs; // 是否有文本输入 - - // 全屏编辑相关状态 - final isFullScreenEditMode = false.obs; // 是否处于全屏编辑模式 - final shouldShowExpandButton = false.obs; // 是否显示展开按钮 - - // "+"号选项展开状态 - final isPlusOptionsExpanded = false.obs; // 是否展开"+"号选项 - - // 打电话模式状态 - final isPhoneCallMode = false.obs; // 是否处于打电话模式 - - // ─── 设备通话模式(device_jieli 耳机录音 → 外部注入 ASR)───── - // 独立于普通打电话模式,不复用 isPhoneCallMode - final isDeviceCallMode = false.obs; // 是否处于设备通话模式 - final isDeviceCallMicMuted = false.obs; // 设备通话模式下是否静音(丢帧) - StreamSubscription? _deviceCallJieliSub; - bool _deviceCallPushing = false; // 是否当前允许把设备 PCM 推给 ASR - // 调试:每秒打印一次 PCM 推送统计,确认音频是否真正流到 ASR - int _deviceCallPushedFrames = 0; - int _deviceCallPushedBytes = 0; - int _deviceCallLastReportMs = 0; - - // 按住说话状态 - final isPushToTalkActive = false.obs; // 是否正在按住说话 - - // 按住说话模式状态 - 用于区分是否处于按住说话模式(即使当前没有按住) - //final isPushToTalkMode = false.obs; // 是否处于按住说话模式 - - // 键盘状态 - final isKeyboardVisible = false.obs; // 键盘是否可见 - - // 流事件订阅 - StreamSubscription? _eventSubscription; - - // 导航流事件订阅 - StreamSubscription? _amapeventSubscription; - - // 进度更新定时器 - Timer? _progressTimer; - // 用于跟踪AI回复状态 - bool _isNewAssistantResponse = true; - RxBool newAIReplyCompleted = true.obs; - String _currentResponseId = ''; - - final TAG = 'AgentController'; - var AppDir = "".obs; - // 会话ID - final String agentId = 'default_agent'; - - var musicServiceType = 'WY'; - - // 分页加载相关状态 - final isLoadingMore = false.obs; // 是否正在加载更多消息 - final hasMoreMessages = true.obs; // 是否还有更多消息 - final currentPage = 1.obs; // 当前页码 - final pageSize = 20; // 每页消息数量 - final isLoadingMoreError = false.obs; // 加载更多时是否出错 - - // 是否已加载聊天记录 - final isHistoryLoaded = false.obs; - - // 添加加载状态跟踪 - final RxBool isLoadingMusic = false.obs; - final RxInt loadingMusicId = 0.obs; - - // 设置加载状态的方法 - void setLoadingMusic(int id, bool loading) { - loadingMusicId.value = loading ? id : 0; - isLoadingMusic.value = loading; - } - - @override - void onInit() { - super.onInit(); - // 输入框内容变化 → 驱动底部输入栏发送按钮显示/隐藏 - textController.addListener(_onInputTextChanged); - musicServiceType = AppConfig.env('APP_ANDROID_MUSICSERVICE_TYPE') ?? 'AUTO'; - if (Platform.isIOS) { - musicServiceType = AppConfig.env('APP_IOS_MUSICSERVICE_TYPE') ?? 'AUTO'; - } - vipExpiryTime.value = User.isLoggedIn() ? User.instance.vipexptime : 0; - ever(User.refreshTick, (_) { - vipExpiryTime.value = User.instance.vipexptime; - }); - _initAppDir(); - _loadTtsPreference(); //加载TTS偏好设置 - _loadTtsRatePreference(); - _loadTtsInterrupe(); //加载TTS播报打断偏好设置 - // 监听存储中的“agent_tts_interrupe”键变化,实现跨页面状态同步 - // 中文注释:当 Home 页或 Agent 页任意一处切换时,另一处可以通过监听存储自动刷新 UI - _storage.listenKey('agent_tts_interrupt', (value) { - try { - final bool newValue = value is bool ? value : (value == 'true'); - if (isTtsInterruptEnabled.value != newValue) { - isTtsInterruptEnabled.value = newValue; - } - } catch (e) { - // 中文注释:忽略异常,避免因类型不一致导致崩溃 - } - }); - _setupScrollListener(); //设置滚动监听器 - _loadChatHistory(); //加载聊天历史记录 - _subscribeToEvents(); //订阅代理服务事件 - _initMusicStatus(); //初始化音乐状态 - _setupMediaSessionCallbacks(); //设置媒体会话回调 - _setupTextInputListener(); //设置文本输入监听器 - - startVoiceInput(); //开启语音输入 - // 监听应用生命周期:当应用切到后台/失焦时兜底结束按住说话,防止 onPointerUp 未触发 - WidgetsBinding.instance.addObserver(this); - - // _qqmusicManager.syncCurrentPlayInfo(); - } - - Future _initAppDir() async { - AppDir.value = (await getApplicationDocumentsDirectory()).path; - } - - // 初始化音乐播放状态 - Future _initMusicStatus() async { - try { - await musicStatus(); - Logger.d('AgentController', '音乐状态初始化完成'); - } catch (e) { - Logger.e('AgentController', '音乐状态初始化失败: $e'); - } - } - - // 设置媒体会话回调 - void _setupMediaSessionCallbacks() async { - try { - await _musiceManager.setMediaSessionCallbacks( - onPlay: onMediaSessionPlay, - onPause: onMediaSessionPause, - onNext: onMediaSessionNext, - onPrevious: onMediaSessionPrevious, - onSeekTo: onMediaSessionSeekTo, - onStop: onMediaSessionStop, - onProgress: onMediaSessionProgress, - ); - Logger.d('AgentController', '媒体会话回调设置完成'); - } catch (e) { - Logger.e('AgentController', '媒体会话回调设置失败: $e'); - } - } - - @override - void onClose() { - // 若处于设备通话模式,fire-and-forget 结束 - if (isDeviceCallMode.value) { - unawaited(_stopDeviceCallInternal()); - } - AgentService.stopAiSteam(); //停止AI流 - AgentService.stopConversation(); // 停止当前会话 - AgentService.stopTts(); // 停止当前语音 - textController.removeListener(_onInputTextChanged); - textController.dispose(); - scrollController.dispose(); - _eventSubscription?.cancel(); - _amapeventSubscription?.cancel(); - _deviceCallJieliSub?.cancel(); - _deviceCallJieliSub = null; - Intro.of(Get.context!).dispose(); - - // 取消应用生命周期监听 - WidgetsBinding.instance.removeObserver(this); - - super.onClose(); - } - - /// 输入框内容变化回调:只在「空 ↔ 非空」翻转时写 Rx,避免每敲一个字都触发重建 - void _onInputTextChanged() { - final has = textController.text.trim().isNotEmpty; - if (hasInputText.value != has) { - hasInputText.value = has; - } - } - - // 加载TTS偏好设置 - Future _loadTtsPreference() async { - try { - final storage = GetStorage(); - isTtsEnabled.value = storage.read('agent_tts_enabled') ?? true; - } catch (e) { - // 如果读取失败,使用默认值 - Logger.e(TAG, '加载TTS偏好设置失败: $e'); - isTtsEnabled.value = true; - } - } - - Future _loadTtsRatePreference() async { - try { - final value = _storage.read('agent_tts_rate_percent'); - if (value is int) { - ttsRatePercent.value = value; - } else if (value is String) { - final parsed = int.tryParse(value); - if (parsed != null) { - ttsRatePercent.value = parsed; - } - } else { - ttsRatePercent.value = 25; - } - } catch (e) { - ttsRatePercent.value = 25; - } - - try { - await AgentService.setTtsRate(ttsRatePercent.value); - } catch (e) { - Logger.e(TAG, '同步TTS语速失败: $e'); - } - } - - // 切换TTS状态 - Future toggleTts() async { - isTtsEnabled.toggle(); - - // 如果关闭TTS,停止当前语音 - if (!isTtsEnabled.value && isSpeaking.value) { - await AgentService.stopTts(); - } - - // 保存偏好设置 - try { - final storage = GetStorage(); - await storage.write('agent_tts_enabled', isTtsEnabled.value); - } catch (e) { - Logger.e(TAG, '保存TTS偏好设置失败: $e'); - } - } - - Future updateTtsRate(int rate) async { - final newRate = rate.clamp(0, 100); - ttsRatePercent.value = newRate; - - if (isSpeaking.value) { - try { - await AgentService.stopTts(); - } catch (_) {} - } - - try { - await AgentService.setTtsRate(newRate); - } catch (e) { - Logger.e(TAG, '设置TTS语速失败: $e'); - } - - try { - await _storage.write('agent_tts_rate_percent', newRate); - } catch (e) { - Logger.e(TAG, '保存TTS语速偏好设置失败: $e'); - } - } - - // 加载TTS偏好设置 - Future _loadTtsInterrupe() async { - try { - if (!_storage.hasData("agent_tts_interrupt")) { - // 获取设备类型以确定检查策略 - String deviceName = getDeviceName(); - DBProduct? product = AppConfig.getproduct(deviceName); - if (deviceName != "" && product != null) { - // Logger.d('AgentController', - // 'liwe----------product $deviceName ${product.toJson()}'); - isTtsInterruptEnabled.value = !product.iscontinuouschat!; - } else { - isTtsInterruptEnabled.value = false; - } - } else { - isTtsInterruptEnabled.value = - _storage.read('agent_tts_interrupt') ?? false; - } - await AgentService.isInterrupt(isTtsInterruptEnabled.value); - } catch (e) { - // 如果读取失败,使用默认值 - Logger.e(TAG, '加载TTS偏好设置失败: $e'); - isTtsInterruptEnabled.value = false; - } - } - - /// 切换TTS播报打断状态 - /// 当启用播报打断时,用户说话会自动停止当前的TTS播放 - Future toggleTtsInterrupt() async { - isTtsInterruptEnabled.toggle(); - await _storage.write('agent_tts_interrupt', isTtsInterruptEnabled.value); - // 保存偏好设置 - try { - await AgentService.isInterrupt(isTtsInterruptEnabled.value); - } catch (e) { - Logger.e(TAG, '保存TTS播报打断偏好设置失败: $e'); - } - } - - /// 当启用显示工具调用效果 - Future toggleShowAIToolInterrupt() async { - isShowToolInterruptEnabled.toggle(); - } - - // 设置文本输入监听器 - void _setupTextInputListener() { - textController.addListener(() { - final text = textController.text.trim(); - hasTextInput.value = text.isNotEmpty; - - // 检查是否需要显示展开按钮 - _checkShouldShowExpandButton(); - }); - } - - // 检查是否需要显示展开按钮 - void _checkShouldShowExpandButton() { - final text = textController.text; - if (text.isEmpty) { - shouldShowExpandButton.value = false; - return; - } - - // 计算文本行数 - final lines = text.split('\n'); - final maxLines = isImageInputActive.value ? 3 : 5; - - // 简单估算:如果换行符数量超过maxLines-1,或者单行文本过长,显示展开按钮 - final hasMultipleLines = lines.length > maxLines; - final hasLongLine = lines.any((line) => line.length > 50); // 估算单行字符数 - - shouldShowExpandButton.value = hasMultipleLines || hasLongLine; - } - - // 打开全屏编辑模式 - void openFullScreenEdit() { - isFullScreenEditMode.value = true; - } - - // 关闭全屏编辑模式 - void closeFullScreenEdit() { - isFullScreenEditMode.value = false; - } - - // 从全屏编辑模式发送消息 - Future sendMessageFromFullScreen() async { - await sendMessage(); - closeFullScreenEdit(); - } - - // 加载聊天历史记录 - Future _loadChatHistory() async { - try { - Logger.i(TAG, '正在加载聊天历史...'); - //通过本地插件ChatStorage获取聊天记录 - final result = await ChatStorage.getMessages( - agentId: agentId, //会话ID - page: 1, //从第一页开始 - pageSize: pageSize, //每页20条记录 - ); - Logger.i(TAG, '加载聊天历史记录: $result'); - if (result.messages.isNotEmpty) { - // 转换并添加消息到列表 - final historyMessages = _convertChatMessagesToMessages(result.messages); - - // 消息按时间排序,最新的消息在底部 - historyMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - messages.assignAll(historyMessages); - - // 更新分页状态 - currentPage.value = 1; - hasMoreMessages.value = result.totalPages > 1; - // print( - // 'liwei-------------读取历史记录 ${AppDir.value}${historyMessages[0].imagePath}'); - Logger.i(TAG, - '成功加载 ${historyMessages.length} 条聊天记录,总页数: ${result.totalPages}'); - _logMessageDetails(historyMessages); - } else { - // 没有历史记录时添加欢迎消息 - _addWelcomeMessage(); - hasMoreMessages.value = false; - Logger.i(TAG, '没有历史记录,添加欢迎消息'); - } - } catch (e) { - Logger.e(TAG, '加载聊天历史失败: $e'); - _addWelcomeMessage(); - hasMoreMessages.value = false; - } finally { - isHistoryLoaded.value = true; - messages.refresh(); - // 使用reverse:true时不需要手动滚动到底部 - } - } - - // 加载更多历史消息(分页加载) - Future loadMoreMessages() async { - // 防止重复加载 - if (isLoadingMore.value || !hasMoreMessages.value) { - Logger.d(TAG, - '跳过加载更多:isLoadingMore=${isLoadingMore.value}, hasMoreMessages=${hasMoreMessages.value}'); - return; - } - - try { - isLoadingMore.value = true; - isLoadingMoreError.value = false; - - final nextPage = currentPage.value + 1; - Logger.i(TAG, '正在加载第 $nextPage 页历史消息...'); - - final result = await ChatStorage.getMessages( - agentId: agentId, - page: nextPage, - pageSize: pageSize, - ); - - if (result.messages.isNotEmpty) { - // 转换新消息 - final newMessages = _convertChatMessagesToMessages(result.messages); - - // 按时间排序新消息 - newMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - - // 添加短暂延迟,让用户感知到加载过程 - await Future.delayed(const Duration(milliseconds: 300)); - - // 将新消息插入到现有消息列表的开头(因为是历史消息) - final updatedMessages = [...newMessages, ...messages]; - messages.assignAll(updatedMessages); - - // 更新分页状态 - currentPage.value = nextPage; - hasMoreMessages.value = nextPage < result.totalPages; - - Logger.i(TAG, - '成功加载 ${newMessages.length} 条历史消息,当前页: $nextPage/${result.totalPages}'); - } else { - // 没有更多消息 - hasMoreMessages.value = false; - Logger.i(TAG, '没有更多历史消息'); - } - } catch (e) { - Logger.e(TAG, '加载更多消息失败: $e'); - isLoadingMoreError.value = true; - } finally { - isLoadingMore.value = false; - } - } - - // 转换ChatMessage到Message的通用方法 - List _convertChatMessagesToMessages(List chatMessages) { - return chatMessages.map((msg) { - final meta = msg.metadata != null && msg.metadata != "" - ? jsonDecode(msg.metadata!) - : null; // 如果为空,使用空对象 - - // 从元数据中提取图片信息 - final hasImage = meta != null && meta.containsKey('hasImage') - ? meta['hasImage'] as bool - : false; - final imagePath = meta != null && meta.containsKey('imagePath') - ? meta['imagePath'] as String? - : null; - - return Message( - sessionid: msg.sessionId, // 会话ID - isUser: msg.sender == 'user', - text: msg.message, - meta: meta, - timestamp: DateTime.parse(msg.timestamp).toLocal(), - hasImage: hasImage, - imagePath: imagePath, - ); - }).toList(); - } - - // 打印消息详情的通用方法 - void _logMessageDetails(List messageList) { - Logger.d(TAG, '===== 历史消息详情开始 ====='); - for (int i = 0; i < messageList.length; i++) { - final msg = messageList[i]; - Logger.d( - TAG, - '消息[$i]: {' - 'isUser: ${msg.isUser}, ' - 'text: "${msg.text.length > 50 ? msg.text.substring(0, 50) + "..." : msg.text}", ' - 'time: ${msg.timestamp}, ' - 'hasImage: ${msg.hasImage}, ' - 'imagePath: ${msg.imagePath ?? "无"}, ' - 'meta: ${msg.meta != null ? "有" : "无"}' - '}'); - } - Logger.d(TAG, '===== 历史消息详情结束 ==lishi==='); - } - - // 设置滚动监听器 - void _setupScrollListener() { - scrollController.addListener(() { - // 检查是否滚动到顶部(因为使用了reverse: true,顶部实际是最大滚动位置) - if (scrollController.position.pixels >= - scrollController.position.maxScrollExtent - 100) { - // 当距离顶部100像素时触发加载更多 - if (hasMoreMessages.value && !isLoadingMore.value) { - Logger.d(TAG, '触发加载更多历史消息'); - loadMoreMessages(); - } - } - }); - } - - // 手动触发加载更多(用于测试或手动调用) - void triggerLoadMore() { - if (hasMoreMessages.value && !isLoadingMore.value) { - Logger.i(TAG, '手动触发加载更多历史消息'); - loadMoreMessages(); - } else { - Logger.w(TAG, - '无法加载更多:hasMoreMessages=${hasMoreMessages.value}, isLoadingMore=${isLoadingMore.value}'); - } - } - - // 重置分页状态(用于刷新聊天记录) - void resetPagination() { - currentPage.value = 1; - hasMoreMessages.value = true; - isLoadingMore.value = false; - isLoadingMoreError.value = false; - Logger.i(TAG, '分页状态已重置'); - } - - // 添加初始欢迎消息 - void _addWelcomeMessage() { - messages.add(Message( - sessionid: "", // 会话ID - isUser: false, - text: 'welcomeMessage'.tr, // 你好!我是**Lily**,我能帮你: - )); - } - - // 订阅代理服务事件 - void _subscribeToEvents() { - _eventSubscription = AgentService.events.listen((event) { - final String asrProvider = (event.data['provider'] ?? '').toString(); - if (asrProvider.isNotEmpty) Logger.i(TAG, 'ASR provider: $asrProvider'); - switch (event.type) { - case AgentServiceEventType.recognitionStarted: - isListening.value = true; - //isTextInputMode.value = false; - Logger.i(TAG, '订阅代理服务事件识别开始'); - // isTextInputMode.refresh(); - // 只标记识别开始,不添加消息 - Logger.i(TAG, '识别开始'); - - break; - case AgentServiceEventType.recognitionStopped: - isListening.value = false; - - Logger.i(TAG, '识别停止'); - break; - - case AgentServiceEventType.recognizing: - final sessionid = event.data['sessionid'] ?? ''; - final text = event.data['text'] ?? ''; - currentText.value = text; // 保留当前文本,以便其他地方使用 - Logger.i(TAG, '[$sessionid]识别中间结果: $text'); - if (text.isNotEmpty) { - // 查找是否有正在识别中的消息 - final index = messages.lastIndexWhere( - (msg) => msg.sessionid == sessionid && msg.isUser); - - if (index >= 0) { - // 更新已有的识别中消息 - messages[index] = Message( - sessionid: sessionid, // 会话ID - isUser: true, - text: text, - isRecognizing: true, - timestamp: messages[index].timestamp, - ); - } else { - currsessionid.value = event.data['sessionid'] ?? ''; - // 没有识别中的消息,创建新消息 - messages.add(Message( - sessionid: sessionid, // 会话ID - isUser: true, - text: text, - isRecognizing: true, - )); - } - // messages.refresh(); - } - break; - - case AgentServiceEventType.recognitionResult: - final sessionid = event.data['sessionid'] ?? ''; - final text = event.data['text'] ?? ''; - Logger.i(TAG, '[$sessionid]识别最终结果: $text'); - - // if (currsessionid.value != "" && currsessionid.value != sessionid) { - // final index = messages.lastIndexWhere( - // (msg) => msg.sessionid == currsessionid.value && msg.isUser); - // //出现了为识别结束的消息 - // if (index >= 0) { - // //异除去当前会话的识别中消息 - // messages.removeAt(index); - // } - // } - currsessionid.value = sessionid; - // 查找是否有正在识别中的消息 - final index = messages.lastIndexWhere( - (msg) => msg.sessionid == sessionid && msg.isUser); - if (text.isNotEmpty) { - if (index >= 0) { - // 将识别中的消息转换为最终消息 - messages[index] = Message( - sessionid: sessionid, - isUser: true, - text: text, - isRecognizing: false, - timestamp: messages[index].timestamp, - functionCalls: messages[index].functionCalls, - isToolCallsExpanded: messages[index].isToolCallsExpanded, - ); - // messages.refresh(); - } else { - // 如果没有找到识别中的消息,添加新消息 - messages.add(Message( - sessionid: sessionid, - isUser: true, - text: text, - isRecognizing: false)); - } - - isProcessing.value = true; - // 标记为新的AI回复 - _isNewAssistantResponse = true; - // 使用reverse:true时通常不需要手动滚动 - } else if (index >= 0) { - // 如果识别结果为空,移除临时的识别消息 - messages.removeAt(index); - // messages.refresh(); - } - break; - //识别取消(网络波动会触发) - case AgentServiceEventType.recognitionCanceled: - final sessionid = event.data['sessionid'] ?? ''; - // 查找是否有正在识别中的消息 - final index = messages.lastIndexWhere( - (msg) => msg.sessionid == sessionid && msg.isUser); - currsessionid.value = ''; - if (currentText.value.isNotEmpty) { - if (index >= 0) { - // 将识别中的消息转换为最终消息 - messages[index] = Message( - sessionid: sessionid, // 会话ID - isUser: true, - text: currentText.value, - isRecognizing: false, - timestamp: messages[index].timestamp, - functionCalls: messages[index].functionCalls, - isToolCallsExpanded: messages[index].isToolCallsExpanded, - ); - } else { - // 如果没有找到识别中的消息,添加新消息 - messages.add(Message( - sessionid: sessionid, - isUser: true, - text: currentText.value, - isRecognizing: false, - )); - } - - isProcessing.value = true; - // 标记为新的AI回复 - _isNewAssistantResponse = true; - // 使用reverse:true时通常不需要手动滚动 - } else if (index >= 0) { - // 如果识别结果为空,移除临时的识别消息 - messages.removeAt(index); - } - messages.refresh(); - break; - case AgentServiceEventType.ttsStarted: - isSpeaking.value = true; - print('TTS开始播放'); - break; - - case AgentServiceEventType.ttsCompleted: - case AgentServiceEventType.ttsStopped: - case AgentServiceEventType.ttsCanceled: - isSpeaking.value = false; - - Logger.i(TAG, 'TTS停止播放, $event.type'); - break; - - case AgentServiceEventType.playbackStarted: - isSpeaking.value = true; - Logger.i(TAG, 'TTS播放开始'); - break; - - case AgentServiceEventType.playbackCompleted: - isSpeaking.value = false; - - Logger.i(TAG, 'TTS播放完成'); - break; - - case AgentServiceEventType.imageProcessing: - isImageProcessing.value = true; - break; - - case AgentServiceEventType.imageReady: - // 图片准备就绪,但AI还没有开始处理,保持processing状态 - break; - - case AgentServiceEventType.assistantToken: - if (!isProcessing.value) isProcessing.value = true; - final sessionid = event.data['sessionid'] ?? ''; - final token = event.data['token'] ?? ''; - // Logger.i(TAG, 'AI回复Token: $token, responseId: $responseId'); - if (token.isNotEmpty) { - // 如果是新的回复或者响应ID改变,创建新消息 - final index = messages.lastIndexWhere( - (msg) => msg.sessionid == sessionid && !msg.isUser); - if (index == -1) { - messages.add( - Message(sessionid: sessionid, isUser: false, text: token)); - _isNewAssistantResponse = false; - } else { - messages[index] = Message( - sessionid: sessionid, - isUser: false, - text: messages[index].text + token, - meta: messages[index].meta, - timestamp: messages[index].timestamp, - functionCalls: messages[index].functionCalls, - isToolCallsExpanded: messages[index].isToolCallsExpanded, - ); - } - // 使用reverse:true时通常不需要手动滚动 - } - break; - - case AgentServiceEventType.assistantResponse: - isProcessing.value = false; - isImageProcessing.value = false; - // 标记当前回复完成 - _isNewAssistantResponse = true; - newAIReplyCompleted.value = true; - break; - - case AgentServiceEventType.error: - final sessionid = event.data['sessionid'] ?? ''; - final message = event.data['message'] ?? ''; - final code = event.data['code'] ?? 0; - // //验证错误码 - if (code == 1000) { - isListening.value = false; - isSpeaking.value = false; - isProcessing.value = false; - isImageProcessing.value = false; - if (sessionid != "") { - final index = messages.lastIndexWhere( - (msg) => msg.sessionid == sessionid && !msg.isUser); - if (index >= 0) { - messages.removeAt(index); - messages.refresh(); - } - } - Get.snackbar('error'.tr, '${event.data['message']}'); - } else if (code == 2001) { - // 余额不足或需要充值,给出弹窗确认是否跳转充值界面 - - showRechargeDialog('agent'); - } - Logger.e(TAG, '代理服务错误: ${event.data['message']}'); - break; - - case AgentServiceEventType.autoStop: - isListening.value = false; - final sessionid = event.data['sessionid'] ?? ''; - // 移除临时的识别消息 - final index = messages.lastIndexWhere( - (msg) => msg.sessionid == sessionid && msg.isUser); - if (index >= 0) { - // 只有当消息为空或文本太短时才移除 - if (messages[index].text.trim().length < 2) { - messages.removeAt(index); - // messages.refresh(); - } else { - // 否则转换为正式消息 - messages[index] = Message( - sessionid: sessionid, - isUser: true, - text: messages[index].text, - timestamp: messages[index].timestamp, - functionCalls: messages[index].functionCalls, - isToolCallsExpanded: messages[index].isToolCallsExpanded, - ); - // messages.refresh(); - - // 发送此消息到AI处理 - isProcessing.value = true; - _isNewAssistantResponse = true; - _handleAutoStop(sessionid, index); - } - } - break; - - case AgentServiceEventType.functionCall: - final sessionid = event.data['sessionid'] ?? ''; - final functionName = event.data['name'] ?? ''; - final arguments = event.data['arguments'] ?? ''; - // debugPrint('Fultter 开始调用 mcp: $functionName, $arguments'); - if (functionName == 'exit_interaction') { - if (isPhoneCallMode.value) { - endPhoneCall(); //退出语音交互,同时退出打电话模式 - } - } - final index = messages.lastIndexWhere( - (msg) => msg.sessionid == sessionid && !msg.isUser); - if (index == -1) { - messages.add(Message( - sessionid: sessionid, - isUser: false, - text: '', - functionCalls: { - functionName: {'arguments': arguments} - })); - _isNewAssistantResponse = false; - } else { - // 否则更新最后一条消息 - final lastIndex = messages.length - 1; - - if (lastIndex >= 0 && !messages[lastIndex].isUser) { - messages[lastIndex] = Message( - sessionid: sessionid, - isUser: false, - text: messages[lastIndex].text, - meta: messages[lastIndex].meta, - timestamp: messages[lastIndex].timestamp, - functionCalls: messages[lastIndex].functionCalls ?? {} - ..addAll({ - functionName: {'arguments': arguments} - }), - ); - } - } - break; - - case AgentServiceEventType.functionCallResult: - final sessionid = event.data['sessionid'] ?? ''; - final function_name = event.data['function_name'] ?? ''; - final function_result = event.data['result'] ?? ''; - final metaStr = event.data['meta'] ?? ''; - final index = messages.lastIndexWhere( - (msg) => msg.sessionid == sessionid && !msg.isUser); - if (index != -1) { - // 获取当前的 functionCalls,如果为空则创建新的 Map - final functionCalls = Map>.from( - messages[index].functionCalls?.map((key, value) => MapEntry( - key, Map.from(value as Map))) ?? - {}); - - // 更新对应工具的调用结果 - if (function_name.isNotEmpty) { - // 如果该工具已存在,更新其结果;如果不存在,创建新的条目 - if (functionCalls.containsKey(function_name)) { - functionCalls[function_name]!['result'] = function_result; - } else { - functionCalls[function_name] = { - 'result': function_result, - }; - } - } - - messages[index] = Message( - sessionid: sessionid, - isUser: false, - text: messages[index].text, - meta: messages[index].meta, - timestamp: messages[index].timestamp, - functionCalls: functionCalls, - isToolCallsExpanded: messages[index].isToolCallsExpanded, - ); - } - Logger.i(TAG, 'Flutter 结束调用 $sessionid mcp: $metaStr'); - - if (metaStr.isNotEmpty) { - Map metaResult = {}; - metaResult = jsonDecode(metaStr) as Map; - if (metaResult.isNotEmpty) { - var notreply = false; - if (metaResult.containsKey("notreply")) { - // 如果有导航卡片,直接添加 - notreply = metaResult["notreply"] as bool; - } - if (notreply) { - Logger.i(TAG, 'Flutter 结束调用 不回复'); - //不回复直接返回 - break; - } - - Logger.i(TAG, 'Flutter 结束调用 卡片数据 $index $metaResult'); - // 判断是否为新的回复或响应ID是否改变 - if (index == -1) { - // 添加新消息 - messages.add(Message( - sessionid: sessionid, - isUser: false, - text: "", - meta: metaResult)); - _isNewAssistantResponse = false; - } else { - // 更新最后一条消息 - messages[index] = Message( - sessionid: sessionid, - isUser: false, - text: messages[index].text, - meta: metaResult, - timestamp: messages[index].timestamp, - functionCalls: messages[index].functionCalls, - isToolCallsExpanded: messages[index].isToolCallsExpanded, - ); - } - messages.refresh(); - if (metaResult.containsKey("maps_route_navigation")) { - final maps_route_navigation = - metaResult["maps_route_navigation"]; - if (maps_route_navigation is Map) { - final card_navigation = - maps_route_navigation["card_navigation"]; - final isinternal = metaResult["isinternal"] ?? false; - if (card_navigation is Map) { - //导航 - final start = card_navigation["start"] as String; - final destination = - card_navigation["destination"] as String; - final end_longitude = - card_navigation["end_longitude"] as String; - final end_latitude = - card_navigation["end_latitude"] as String; - // End_longitude string `json:"end_longitude"` //目标点经度 - // End_latitude string `json:"end_latitude"` //目标点纬度 - final items = card_navigation['items']; - if (items != null) { - Future.delayed(Duration(seconds: 2), () { - // Logger.i(TAG, 'liwei-------- 打开导航页面: $start $end '); - // 2秒后执行的代码 - final navigationMode = - AppConfig.env('APP_NAVIGATION_MODE') ?? - 'external_auto'; - if (navigationMode == 'internal_gaode' || isinternal) { - _navigationManager.open( - start, "$end_longitude,$end_latitude", "driving"); - } - // openNavigation(false, start, destination, end_latitude, - // end_longitude, ""); - }); - } - } - } - } else if (metaResult - .containsKey("maps_google_route_navigation")) { - //导航 - final maps_google_route_navigation_raw = - metaResult["maps_google_route_navigation"]; - - // 安全的类型检查 - if (maps_google_route_navigation_raw is Map) { - final maps_google_route_navigation = - maps_google_route_navigation_raw; - final data = maps_google_route_navigation["data"]; - if (data != null) { - final end = data["destination"] as String; - final mode = data["items"][0]["ntype"] as String; - Future.delayed(Duration(seconds: 1), () { - // Logger.i(TAG, 'liwei-------- 打开google地图: $end '); - openGoogleMap(end, mode); - }); - } - } - } - } else { - // 其他函数调用 - Logger.i(TAG, 'Flutter 结束调用 解析失败 $metaStr'); - } - } - if (function_name.contains("music")) { - Future.delayed(Duration(seconds: 1), () { - Logger.i(TAG, '音乐服务 $musicServiceType'); - if (musicServiceType == "QQ") { - _qqmusicManager.syncCurrentPlayInfo(isOpenPlay: false); - } else if (musicServiceType == "WY") { - musicStatus(); - } - }); - // } - } - break; - case AgentServiceEventType.assistantUsages: - final sessionid = event.data['sessionid'] ?? ''; - final integral = event.data['integral'] ?? 0; - User.instance.aichatintegral = integral; - break; - default: - break; - } - }); - - //后台导航事件同步 - _amapeventSubscription = _navigationManager.events().listen((event) { - switch (event.type) { - case AmapServiceEventType.starte: - Logger.i(TAG, '导航通知 started!'); - break; - case AmapServiceEventType.stope: - Logger.i(TAG, '导航通知 stoped!'); - break; - case AmapServiceEventType.update: - Logger.i(TAG, '导航通知 update! $event'); - Message? result; - - try { - result = messages.lastWhere( - (msg) => - !msg.isUser && - msg.meta?.containsKey("card_navigation") == true, - ); - } catch (e) { - result = null; - } - - if (result != null) { - int index = messages.indexOf(result); - if (index != -1) { - // ✅ 创建 meta 的深拷贝 - final newMeta = Map.from(result.meta ?? {}); - newMeta["card_navigation"]["navigation"] = event.data; - - // ✅ 创建新的 Message 替换原来的 - messages[index] = Message( - sessionid: result.sessionid, - isUser: result.isUser, - text: result.text, - meta: newMeta, - timestamp: result.timestamp, - ); - messages.refresh(); - Logger.i( - TAG, '更新导航卡片! ${newMeta["card_navigation"]["navigation"]}'); - } - } - break; - case AmapServiceEventType.error: - Logger.i(TAG, '导航通知 error!'); - break; - case AmapServiceEventType.unknown: - Logger.i(TAG, '导航通知 unknown!'); - break; - } - }); - } - - // 发送消息(包括所有支持的消息类型) - //场景1:用户在文本输入框中输入文本,点击发送按钮,调用sendMessage()方法。 - //场景2:当用户选择图片后在文本框输入内容并发送时,调用sendMessage()转发给sendImageMessage()。 - Future sendMessage() async { - // 收回键盘 - FocusManager.instance.primaryFocus?.unfocus(); - // 1. 处理图片+文本的混合输入 - if (isImageInputActive.value && selectedImagePath.value != null) { - final text = textController.text.trim(); - final imagePath = selectedImagePath.value!; // 使用!强制断言非空 - await sendImageMessage(imagePath, text: text); - - // 清除图片输入状态 - clearImageInput(); - return; - } - - // 2. 处理纯文本消息 - final text = textController.text.trim(); - if (text.isEmpty) return; - - // 使用reverse:true时通常不需要手动滚动 - // 3. 调用AI服务处理 - try { - isProcessing.value = true; - // 标记为新的AI回复 - _isNewAssistantResponse = true; - newAIReplyCompleted.value = false; - currsessionid.value = const Uuid().v4(); - final sessionid = currsessionid.value; - // 根据TTS状态决定是否朗读 - AgentService.processTextInput(sessionid, text, - speakResponse: isTtsEnabled.value); - final message = Message(sessionid: sessionid, isUser: true, text: text); - Logger.i(TAG, '发送AI消息:$sessionid $text'); - messages.add(message); - textController.clear(); - } catch (e) { - Logger.e(TAG, '发送文本消息失败: $e'); - isProcessing.value = false; - } - } - - // 发送图片消息 - Future sendImageMessage(String imagePath, {String? text}) async { - if (imagePath.isEmpty || !File(imagePath).existsSync()) { - Logger.e(TAG, '图片不存在: $imagePath'); - return; - } - - // 复制图片到应用永久存储目录 - // 复制图片到应用永久存储目录 - final imagePathResult = await _copyImageToPermanentStorage(imagePath); - final permanentImagePath = imagePathResult['fullPath']!; - final relativeImagePath = imagePathResult['relativePath']!; - // rootDir 现在是服务端的 scene 白名单值,**不要自己拼 User// 前缀**—— - // 目录由服务端按会话里的 uid 拼死,客户端再拼一层只会变成重复路径。 - var ImageUrl = await UploadOss.upload( - filepath: permanentImagePath, rootDir: 'ChatImages'); - - // 显示的文本,如果没有提供则使用默认值 - final displayText = text?.isNotEmpty == true ? text! : '[图片]'; - - currsessionid.value = const Uuid().v4(); - final sessionid = currsessionid.value; - Logger.i(TAG, - '发送AI图片消息:$sessionid $displayText $permanentImagePath URL: $ImageUrl'); - // 添加用户消息 - final message = Message( - sessionid: sessionid, - isUser: true, - text: displayText, - hasImage: true, - imagePath: relativeImagePath, - ); - messages.add(message); - - // 清空文本输入 - textController.clear(); - - // 使用reverse:true时通常不需要手动滚动 - - try { - isProcessing.value = true; - isImageProcessing.value = true; - // 标记为新的AI回复 - _isNewAssistantResponse = true; - - // 保存消息到持久化存储 - //await _saveMessageToStorage(message); - currsessionid.value = const Uuid().v4(); - final sessionid = currsessionid.value; - // 调用Agent Service处理图片,考虑TTS状态 - await AgentService.processImageInput( - sessionid, - relativeImagePath, - ImageUrl, - text: text ?? '', - speakResponse: isTtsEnabled.value, - ); - } catch (e) { - Logger.e(TAG, '处理图片失败: $e'); - isProcessing.value = false; - isImageProcessing.value = false; - } - } - -// 将图片复制到应用的永久存储目录 - Future> _copyImageToPermanentStorage( - String sourcePath) async { - try { - final File sourceFile = File(sourcePath); - if (!await sourceFile.exists()) { - Logger.e(TAG, '源图片文件不存在: $sourcePath'); - return { - 'fullPath': sourcePath, - 'relativePath': sourcePath - }; // 如果源文件不存在,返回原路径 - } - - // 创建应用文档目录下的chatImages文件夹 - final appDir = await getApplicationDocumentsDirectory(); - final imagesDir = Directory('${appDir.path}/chatImages'); - if (!await imagesDir.exists()) { - await imagesDir.create(recursive: true); - } - - // 生成唯一文件名 - final fileName = - '${DateTime.now().millisecondsSinceEpoch}_${sourcePath.split('/').last}'; - final targetPath = '${imagesDir.path}/$fileName'; - final relativePath = '/chatImages/$fileName'; - - // 复制文件 - await sourceFile.copy(targetPath); - Logger.i(TAG, '图片已复制到永久存储: $targetPath'); - - return {'fullPath': targetPath, 'relativePath': relativePath}; - } catch (e) { - Logger.e(TAG, '复制图片到永久存储失败: $e'); - return {'fullPath': sourcePath, 'relativePath': sourcePath}; // 出错时返回原路径 - } - } - - // 切换输入模式(改造为按住说话模式) - void toggleInputMode() async { - // 如果当前在图片输入模式,先清除 - if (isImageInputActive.value) { - clearImageInput(); - return; - } - - // 如果要切换到语音模式,需要先检查麦克风权限 - if (isTextInputMode.value) { - // 使用 PermissionUtil 检查麦克风权限 - final hasPermission = await PermissionUtil.instance.requestPermission( - // context: Get.context!, - permissionType: Permission.microphone, - permissionName: 'microphonePermission'.tr, // 麦克风 - explanationText: 'voiceRecognitionPermissionText'.tr, // 语音识别功能需要访问麦克风。 - permanentDenialText: - 'permissionDeniedText'.tr, // 您已永久拒绝麦克风权限,请前往系统设置开启权限。 - settingsButtonText: 'goToSettings'.tr, // 去设置 - ); - if (!hasPermission) { - return; // 权限未获取,保持文本模式 - } - } - - isTextInputMode.toggle(); - - // 切换到语音模式时,启用按住说话模式 - // 切换到文本模式时,禁用按住说话模式并停止任何正在进行的语音输入 - if (isTextInputMode.value) { - // 切换到文本模式 - isTextInputMode.value = true; - // stopVoiceInput(); - } else { - // 切换到语音模式,启用按住说话模式 - isTextInputMode.value = false; - } - } - - // 开始语音输入 - Future startVoiceInput() async { - if (isListening.value) return; - Logger.i(TAG, '开始语音输入'); // 开始语音输入 - // final hasPermission = await PermissionUtil.instance.requestPermission( - // // context: Get.context!, - // permissionType: Permission.microphone, - // permissionName: 'microphonePermission'.tr, // 麦克风 - // explanationText: 'voiceTranslationPermissionText'.tr, // 语音识别与翻译功能需要访问麦克风。 - // permanentDenialText: - // 'permissionDeniedText'.tr, // 您已永久拒绝麦克风权限,请前往系统设置开启权限。 - // settingsButtonText: 'goToSettings'.tr, // 去设置 - // cancelButtonText: 'cancel'.tr, // 取消 - // ); - // if (!hasPermission) { - // isTextInputMode.value = true; // 如果没有权限,切回文本模式 - // return; - // } - - try { - await AgentService.stopConversation(); - - await AgentService.recognizeCallback(); - if (_bluetoothManager.currentDeviceRx.value != null) { - // await AgentService.restoreOriginalAudioState(); - } else { - // await AgentService.disableBluetoothAudio(); - } - } catch (e) { - Logger.e(TAG, '启动识别器失败: $e'); - } - } - - // 停止语音输入 - Future stopVoiceInput() async { - if (!isListening.value) return; - - try { - await AgentService.stopConversation(); - } catch (e) { - Logger.e(TAG, '停止语音输入失败: $e'); - } - } - - // 清除聊天历史 - Future clearChat() async { - try { - await AgentService.clearChatHistory(); - - // 清空本地消息列表 - messages.clear(); - - // 添加欢迎消息 - _addWelcomeMessage(); - - // 重置回复状态 - _isNewAssistantResponse = true; - } catch (e) { - Logger.e(TAG, '清除聊天历史失败: $e'); - } - } - - // 从相册选择图片 - Future pickImage() async { - try { - final ImagePicker picker = ImagePicker(); - final XFile? image = await picker.pickImage( - source: ImageSource.gallery, - imageQuality: 80, // 设置图片质量 - maxWidth: 1024, // 限制最大宽度 - maxHeight: 1024, // 限制最大高度 - ); - - if (image != null) { - // 设置已选择的图片 - selectedImagePath.value = image.path; - // 激活图片输入模式 - isImageInputActive.value = true; - } - } catch (e) { - Logger.e(TAG, '选择图片失败: $e'); - } - } - - // 拍照获取图片 - Future takePhoto() async { - try { - final ImagePicker picker = ImagePicker(); - final XFile? photo = await picker.pickImage( - source: ImageSource.camera, - imageQuality: 80, // 设置图片质量 - maxWidth: 1024, // 限制最大宽度 - maxHeight: 1024, // 限制最大高度 - ); - - if (photo != null) { - // 设置已选择的图片 - selectedImagePath.value = photo.path; - // 激活图片输入模式 - isImageInputActive.value = true; - } - } catch (e) { - Logger.e(TAG, '拍照失败: $e'); - } - } - - // 清除图片输入状态 - void clearImageInput() { - selectedImagePath.value = null; - isImageInputActive.value = false; - } - - // 切换'+'选项展开状态 - void togglePlusOptions() { - if (isPlusOptionsExpanded.value) { - // 如果当前已展开,则收缩 - collapsePlusOptions(); - _pendingPlusOptionsExpansion = false; // 取消待展开状态 - } else { - // 如果当前未展开,则展开并关闭键盘 - if (isKeyboardVisible.value) { - // 设置待展开标记 - _pendingPlusOptionsExpansion = true; - hideKeyboard(); - // 使用双重机制:轮询检测 + 键盘状态监听 - _waitForKeyboardToClose(); - } else { - // 键盘未显示,直接展开 - isPlusOptionsExpanded.value = true; - } - } - } - - // 等待键盘关闭的轮询机制 - void _waitForKeyboardToClose() { - int attempts = 0; - const maxAttempts = 30; // 最多尝试30次,总计3秒 - const checkInterval = Duration(milliseconds: 100); // 每100ms检查一次 - - Timer.periodic(checkInterval, (timer) { - attempts++; - - // 检查是否还需要展开(用户可能已经取消了) - if (!_pendingPlusOptionsExpansion) { - timer.cancel(); - return; - } - - if (!isKeyboardVisible.value) { - // 键盘已关闭,展开选项 - timer.cancel(); - _pendingPlusOptionsExpansion = false; - // 添加小延迟确保UI稳定 - Future.delayed(const Duration(milliseconds: 50), () { - if (!isKeyboardVisible.value) { - // 再次确认键盘状态 - isPlusOptionsExpanded.value = true; - } - }); - } else if (attempts >= maxAttempts) { - // 超时,强制展开(可能键盘检测有问题) - timer.cancel(); - _pendingPlusOptionsExpansion = false; - Logger.d('AgentController', '键盘关闭检测超时,强制展开选项'); - isPlusOptionsExpanded.value = true; - } - }); - } - - // 收缩'+'选项 - void collapsePlusOptions() { - isPlusOptionsExpanded.value = false; - } - - // 更新键盘可见状态 - void updateKeyboardVisibility(bool isVisible) { - final wasVisible = isKeyboardVisible.value; - isKeyboardVisible.value = isVisible; - - // 只有当键盘从隐藏变为显示时,才收缩'+'选项 - // 避免键盘关闭时误触发收缩逻辑 - if (!wasVisible && isVisible && isPlusOptionsExpanded.value) { - collapsePlusOptions(); - } - - // 如果键盘关闭了,并且有待展开的选项,则展开 - if (wasVisible && !isVisible && _pendingPlusOptionsExpansion) { - _pendingPlusOptionsExpansion = false; - Future.delayed(const Duration(milliseconds: 100), () { - if (!isKeyboardVisible.value) { - isPlusOptionsExpanded.value = true; - } - }); - } - } - - // 标记是否有待展开的'+'选项 - bool _pendingPlusOptionsExpansion = false; - - // 隐藏键盘 - void hideKeyboard() { - FocusManager.instance.primaryFocus?.unfocus(); - } - - // 使用预设提示词处理图片 - void useImagePrompt(String prompt) { - // 确保selectedImagePath.value不为null - if (selectedImagePath.value == null) return; - - // 填充文本框同时直接发送消息 - textController.text = prompt; - // 立即发送图片和提示词 - final imagePath = selectedImagePath.value!; - sendImageMessage(imagePath, text: prompt); - // 发送后清除图片输入状态 - clearImageInput(); - } - - // 预设提示词:总结图片内容 - void summarizeImage() { - useImagePrompt('请总结这张图片的内容'); - } - - // 预设提示词:抽取图像文字 - void extractTextFromImage() { - useImagePrompt('请提取这张图片中的所有文字'); - } - - // 预设提示词:翻译图像文字 - void translateImageText() { - useImagePrompt('请翻译这张图片中的文字'); - } - - // 预设提示词处理(文本输入模式) - void preSetPromptAction(String presetType) { - // 确保在文本输入模式下 - if (!isTextInputMode.value) { - // 如果不在文本模式,先切换到文本模式 - isTextInputMode.value = true; - } - - switch (presetType) { - case 'Music': - textController.text = 'playSomeMusic'.tr; - break; - case 'Navigation': - textController.text = 'navigationTo'.tr; - break; - case 'Weather': - textController.text = 'todayWeather'.tr; - break; - case 'News': - textController.text = 'todayNews'.tr; - break; - case 'Food': - textController.text = 'nearbyFood'.tr; - break; - default: - textController.text = presetType; - break; - } - - // 自动发送消息 - if (presetType != 'Navigation') { - sendMessage(); - } - } - - // 模式历史记录 - VoiceInputMode? _previousInputMode; - - // 保存当前输入模式 - void _saveCurrentInputMode() { - if (!isTextInputMode.value) { - _previousInputMode = VoiceInputMode.pushToTalk; - } else { - _previousInputMode = VoiceInputMode.text; - } - // else { - // _previousInputMode = null; - // } - } - - // 恢复之前的输入模式 - void _restorePreviousInputMode() { - switch (_previousInputMode) { - case VoiceInputMode.pushToTalk: - isTextInputMode.value = false; - //isPushToTalkMode.value = true; - break; - case VoiceInputMode.text: - isTextInputMode.value = true; - // isPushToTalkMode.value = false; - break; - default: - // 默认恢复到文字模式 - isTextInputMode.value = true; - //isPushToTalkMode.value = false; - break; - } - _previousInputMode = null; // 清除历史记录 - } - - // 开始打电话模式(支持耳机唤醒) - Future startPhoneCall({bool isHeadsetTriggered = false}) async { - Logger.i(TAG, '🔥 开始打电话模式 (耳机触发: $isHeadsetTriggered)'); - - // 保存当前输入模式,以便通话结束后恢复 - _saveCurrentInputMode(); - - // 收缩"+"选项 - collapsePlusOptions(); - - // 进入打电话模式 - isPhoneCallMode.value = true; - - // 如果当前是文本模式,切换到语音模式???? - // if (isListening.value) { - // 检查麦克风权限 - final hasPermission = await PermissionUtil.instance.requestPermission( - permissionType: Permission.microphone, - permissionName: 'microphonePermission'.tr, // 麦克风 - explanationText: 'phoneCallPermissionText'.tr, // 打电话功能需要访问麦克风进行语音通话。 - permanentDenialText: - 'permissionDeniedText'.tr, // 您已永久拒绝麦克风权限,请前往系统设置开启权限。 - settingsButtonText: 'goToSettings'.tr, // 去设置 - ); - - if (!hasPermission) { - // 权限未获取,退出打电话模式并恢复之前状态 - isPhoneCallMode.value = false; - _previousInputMode = null; // 清除历史记录 - return; - } - - // 打电话模式 - isTextInputMode.value = false; - Logger.i(TAG, '打电话模式已启动,开始语音输入'); - await AgentService.startConversation(mode: 'phone_call'); - } - - // 结束打电话模式 - void endPhoneCall() async { - Logger.i(TAG, '🔥 结束打电话模式'); - - try { - // 如果正在录音,先停止录音 - if (isListening.value) { - final result = await AgentService.stopConversation(); - - if (result) { - isListening.value = false; - } - } - - // 退出打电话模式 - isPhoneCallMode.value = false; - _restorePreviousInputMode(); - } catch (e) { - Logger.e(TAG, '结束打电话模式失败: $e'); - - // 即使出现异常,也要确保退出打电话模式 - isPhoneCallMode.value = false; - isListening.value = false; - _restorePreviousInputMode(); - } - } - - // 打电话模式专用:切换录音状态 - Future togglePhoneCallRecording() async { - Logger.i(TAG, '🎤 打电话模式切换录音状态'); - // 只有在打电话模式下才能使用此方法 - if (!isPhoneCallMode.value) { - Logger.w(TAG, '不在打电话模式,无法切换录音状态'); - return; - } - - try { - if (isListening.value) { - // 当前正在录音,停止录音 - Logger.i(TAG, '停止打电话模式录音'); - final result = await AgentService.stopConversation(); - - // 手动更新状态,确保UI同步 - if (result) { - isListening.value = false; - } - } else { - // 当前未录音,开始录音 - Logger.i(TAG, '开始打电话模式录音'); - final result = await AgentService.startConversation(mode: 'phone_call'); - - // 手动更新状态,确保UI同步 - if (result) { - isListening.value = true; - } - } - } catch (e) { - Logger.e(TAG, '打电话模式录音状态切换失败: $e'); - } - } - - // 耳机唤醒通话模式(专用入口) - Future startHeadsetCall() async { - Logger.i(TAG, '🎧 耳机唤醒通话模式'); - if (canStartHeadsetCall()) { - await startPhoneCall(isHeadsetTriggered: true); - } - } - - // ─── 设备通话模式(device_jieli AI 助手通路) ──────────────────── - // 走 device_jieli 的 AI 助手通路(speechStart/speechStop): - // 上行帧由 SpeechAudioEvent 派发,PCM_S16LE/16k/mono/20ms,直接灌给 ASR。 - // 与翻译 / 设备录音互斥;不影响已有 phone_call / push_to_talk 任何路径。 - - /// 启动设备通话模式 - Future startDeviceCall() async { - Logger.i(TAG, '🎧 开始设备通话模式(AI 助手通路)'); - if (isDeviceCallMode.value) { - Logger.w(TAG, '已处于设备通话模式,忽略'); - return; - } - - // 必须已连接杰理耳机;iOS / Android 都走 device_jieli 的 AI 助理通路 - final BleManager bleManager = Get.find(); - if (!bleManager.isConnected) { - // 用已有的 'tip'(全项目 30 处在用),不要写 'tips'——那个 key 一个语言 - // 文件里都没有,.tr 会原样显示 "tips"。 - Get.snackbar('tip'.tr, '请先连接耳机'); - return; - } - - // 收缩 + 号 - collapsePlusOptions(); - - isDeviceCallMode.value = true; - isDeviceCallMicMuted.value = false; - _deviceCallPushing = true; - - try { - // 先停之前可能存在的会话(不同模式之间切换保底) - await AgentService.stopConversation(); - - // 订阅 device_jieli 事件流,按 uplink 推给 ASR - await _deviceCallJieliSub?.cancel(); - _deviceCallJieliSub = - Jielihome.instance.events.listen(_onDeviceCallEvent); - // ignore: avoid_print - print( - '[DEVCALL] _deviceCallJieliSub 已订阅 sub=$_deviceCallJieliSub mode=${isDeviceCallMode.value} pushing=$_deviceCallPushing', - ); - - // 启动 AgentService 外部音频模式 + 电话通话模式(避免 push_to_talk 静默超时) - final started = await AgentService.startConversation( - mode: 'phone_call', - isExternalActive: true, - ); - if (!started) { - Logger.w(TAG, '设备通话:AgentService 启动失败'); - } - - // 启动 AI 助理通路(JieliAssistantPort):MODE_RECORD + DEVICE_ALWAYS_RECORDING, - // 耳机自主持续推 OPUS,由 Port 解码成 16k/16bit/mono/20ms PCM 帧 - final ok = await Jielihome.instance.assistantStart(); - if (!ok) { - Logger.w(TAG, '设备通话:assistantStart 启动失败'); - await _stopDeviceCallInternal(); - Get.snackbar('error'.tr, '设备通话启动失败'); - return; - } - Logger.i(TAG, '设备通话模式已启动(JieliAssistantPort)'); - } catch (e) { - Logger.e(TAG, '启动设备通话失败: $e'); - await _stopDeviceCallInternal(); - Get.snackbar('error'.tr, '设备通话启动失败'); - } - } - - /// 结束设备通话模式 - Future endDeviceCall() async { - Logger.i(TAG, '🎧 结束设备通话模式'); - await _stopDeviceCallInternal(); - } - - /// 设备通话模式下切换静音(丢帧) - void toggleDeviceCallMic() { - if (!isDeviceCallMode.value) return; - isDeviceCallMicMuted.value = !isDeviceCallMicMuted.value; - _deviceCallPushing = !isDeviceCallMicMuted.value; - Logger.i(TAG, '设备通话静音切换: muted=${isDeviceCallMicMuted.value}'); - } - - Future _stopDeviceCallInternal() async { - _deviceCallPushing = false; - try { - await _deviceCallJieliSub?.cancel(); - } catch (_) {} - _deviceCallJieliSub = null; - - try { - await Jielihome.instance.assistantStop(); - } catch (e) { - Logger.w(TAG, 'assistantStop 失败(忽略): $e'); - } - - try { - await AgentService.stopConversation(); - } catch (e) { - Logger.w(TAG, 'stopConversation 失败(忽略): $e'); - } - - isDeviceCallMicMuted.value = false; - isDeviceCallMode.value = false; - isListening.value = false; - } - - void _onDeviceCallEvent(JieliEvent event) { - // 入口诊断:每秒打一次"是否进入此函数",无条件,用 print 走 I/flutter 通道 - final now0 = DateTime.now().millisecondsSinceEpoch; - if (event is AssistantAudioEvent) { - _deviceCallPushedFrames += 1; - _deviceCallPushedBytes += event.pcm.length; - if (now0 - _deviceCallLastReportMs >= 1000) { - // ignore: avoid_print - print( - '[DEVCALL] _onDeviceCallEvent 入口 (last 1s): frames=$_deviceCallPushedFrames bytes=$_deviceCallPushedBytes mode=${isDeviceCallMode.value} pushing=$_deviceCallPushing seq=${event.sequence}', - ); - _deviceCallPushedFrames = 0; - _deviceCallPushedBytes = 0; - _deviceCallLastReportMs = now0; - } - } - if (!isDeviceCallMode.value) return; - if (event is AssistantAudioEvent) { - if (!_deviceCallPushing) return; - if (event.encoding != 'pcm16') return; - if (event.pcm.isEmpty) return; - // fire-and-forget 推送给 ASR - AgentService.pushAudioData(event.pcm).catchError((e) { - Logger.w(TAG, 'pushAudioData 失败: $e'); - return false; - }); - } else if (event is AssistantStartEvent) { - Logger.i(TAG, 'AI 助理通路已启动(端侧通知)sr=${event.sampleRate}'); - } else if (event is AssistantErrorEvent) { - Logger.e(TAG, 'AI 助理错误 code=${event.code} msg=${event.message ?? ''}'); - } else if (event is AssistantEndEvent) { - Logger.i(TAG, 'AI 助理通路已停止(端侧通知)'); - } - } - - // 检查当前是否可以启动耳机通话 - bool canStartHeadsetCall() { - // 如果已经在打电话模式,不允许重复启动 - if (isPhoneCallMode.value) { - Logger.w(TAG, '已在打电话模式,忽略耳机唤醒'); - return false; - } - - // 如果正在处理语音或TTS,暂时不允许启动 - if (isListening.value || isSpeaking.value || isProcessing.value) { - Logger.w(TAG, '当前正在处理语音,延迟耳机唤醒'); - return false; - } - - return true; - } - - // 验证语音输入状态一致性 - void _validateVoiceInputStates() { - // 检查状态冲突 - // if (isPhoneCallMode.value && isPushToTalkMode.value) { - // Logger.w(TAG, '状态冲突:同时启用打电话和按住说话模式,优先保持打电话模式'); - // isPushToTalkMode.value = false; - // } - - // // 检查文字模式与语音模式的一致性 - // if (isTextInputMode.value && - // (isPushToTalkMode.value || isPhoneCallMode.value)) { - // Logger.w(TAG, '状态冲突:文字模式与语音模式同时启用,优先保持语音模式'); - // isTextInputMode.value = false; - // } - - // // 检查按住说话状态的合理性 - // if (isPushToTalkActive.value && !isPushToTalkMode.value) { - // Logger.w(TAG, '状态冲突:按住说话激活但未启用按住说话模式'); - // isPushToTalkActive.value = false; - // } - } - - // 安全的状态切换方法 - void _safeStateTransition(VoiceInputMode targetMode) { - Logger.i(TAG, '安全状态切换到: $targetMode'); - - switch (targetMode) { - case VoiceInputMode.text: - isTextInputMode.value = true; - // isPushToTalkMode.value = false; - isPhoneCallMode.value = false; - isPushToTalkActive.value = false; - break; - - case VoiceInputMode.pushToTalk: - isTextInputMode.value = false; - // isPushToTalkMode.value = true; - isPhoneCallMode.value = false; - isPushToTalkActive.value = false; - break; - - case VoiceInputMode.phoneCall: - isTextInputMode.value = false; - // isPushToTalkMode.value = false; - isPhoneCallMode.value = true; - isPushToTalkActive.value = false; - break; - } - - // 验证状态一致性 - _validateVoiceInputStates(); - } - - // 开始按住说话 - void startPushToTalk() async { - print('liwei--------- 🎤 开始按住说话'); - - // 只有在语音模式且按住说话模式下才能按住说话 - if (isTextInputMode.value) return; - - // 如果已经在按住说话,不重复开始 - if (isPushToTalkActive.value) return; - - // 设置按住说话状态 - isPushToTalkActive.value = true; - //await AgentService.recognizeCallback(); - // 开始语音识别 - AgentService.startConversation(mode: 'push_to_talk'); - // await startVoiceInput(); - } - - // 结束按住说话 - void endPushToTalk() async { - print('liwei----------🎤 结束按住说话'); - - // 如果不在按住说话状态,直接返回 - if (!isPushToTalkActive.value) return; - - // 清除按住说话状态(但保持按住说话模式) - isPushToTalkActive.value = false; - - AgentService.stopConversation(); - // 停止语音识别 - // await stopVoiceInput(); - } - - // 获取音乐播放状态 - Future musicStatus() async { - try { - var status = await _musiceManager.getStatus(); - isPlaying.value = status['isPlaying'] ?? false; - currentId.value = status['id'] as int; - Logger.d( - TAG, '音乐状态更新: playing=${isPlaying.value}, url=${currentId.value}'); - Logger.d( - TAG, '音乐状态更新: playing=${isPlaying.value}, url=${currentId.value}'); - } catch (e) { - Logger.e(TAG, '获取音乐状态失败: $e'); - } - } - - // 播放音乐 - Future musicPlay(Map song) async { - try { - Logger.d(TAG, '开始播放音乐: ${song['title'] ?? song['name']}'); - - // 立即更新UI状态,提供即时反馈 - isPlaying.value = true; - // 修复:确保currentId接收int类型 - final songId = song['id']; - currentId.value = songId ?? 0; - - // 重置进度状态 - currentPosition.value = 0; - duration.value = 0; - progress.value = 0.0; - - // 转换为MusicManager期望的格式 - final musicData = { - 'id': song['id'], - 'title': song['title']?.toString() ?? song['name']?.toString() ?? '', - 'artist': - song['artist']?.toString() ?? song['sgener']?.toString() ?? '', - 'coverUrl': - song['coverUrl']?.toString() ?? song['image']?.toString() ?? '', - }; - // 调用音乐管理器播放 - await _musiceManager.play(musicData); - // 更新媒体会话元数据和播放状态 - await _updateMediaSessionMetadata(musicData); - await _updateMediaSessionPlaybackState(true); - Logger.d(TAG, '音乐播放启动成功'); - } catch (e) { - Logger.e(TAG, '播放音乐失败: $e'); - // 发生错误时重置状态 - isPlaying.value = false; - currentId.value = 0; - } - } - - // 暂停音乐 - Future musicPause() async { - try { - Logger.d('AgentController', '暂停音乐播放'); - await _musiceManager.pause(); - isPlaying.value = false; - _stopProgressTimer(); - - // 更新媒体会话播放状态 - await _updateMediaSessionPlaybackState(false); - } catch (e) { - Logger.e('AgentController', '暂停音乐失败: $e'); - } - } - - // 恢复音乐播放 - Future musicResume() async { - try { - Logger.d('AgentController', '恢复播放音乐'); - await _musiceManager.resume(); - isPlaying.value = true; - - // 更新媒体会话播放状态 - await _updateMediaSessionPlaybackState(true); - - Logger.d('AgentController', '音乐恢复播放成功'); - } catch (e) { - Logger.e('AgentController', '恢复播放音乐失败: $e'); - isPlaying.value = false; - } - } - - // 停止进度更新定时器 - void _stopProgressTimer() { - if (_progressTimer != null) { - Logger.d('AgentController', '停止播放进度定时器'); - _progressTimer?.cancel(); - _progressTimer = null; - } - } - - // 获取播放进度 - Future updateMusicProgress(int currentIdInternal, int newPosition, - int newDuration, double newProgress) async { - try { - // 如果正在加载音乐,避免进度回调干扰状态更新 - if (isLoadingMusic.value) { - return; - } - - if (currentIdInternal != 0) { - isPlaying.value = true; - // return; // 如果没有在播放,直接返回 - } - - bool hasUpdate = false; - if (currentId.value != currentIdInternal) { - currentId.value = currentIdInternal; - hasUpdate = true; - } - // 只有当值发生变化时才更新,避免不必要的UI刷新 - if (currentPosition.value != newPosition) { - currentPosition.value = newPosition; - hasUpdate = true; - } - if (duration.value != newDuration) { - duration.value = newDuration; - hasUpdate = true; - } - if ((progress.value - newProgress).abs() > 0.001) { - // 避免浮点数精度问题 - progress.value = newProgress; - hasUpdate = true; - } - - // // 如果有进度更新,通知系统媒体会话更新进度信息 - // if (hasUpdate) { - // await _updateMediaSessionProgress(); - // } - } catch (e) { - Logger.e('AgentController', '获取播放进度失败: $e'); - } - } - - /// 更新系统媒体会话的进度信息 - Future _updateMediaSessionProgress() async { - try { - // 这里将调用music_service插件的方法来更新系统媒体会话进度 - // 具体实现将在music_service插件中添加 - await _musiceManager.updateMediaSessionProgress( - currentPosition.value, - duration.value, - progress.value, - ); - } catch (e) { - // 忽略错误,避免影响正常播放 - Logger.d('AgentController', '更新媒体会话进度失败: $e'); - } - } - - /// 更新系统媒体会话的元数据 - Future _updateMediaSessionMetadata(Map song) async { - try { - await _musiceManager.updateMediaSessionMetadata(song); - } catch (e) { - Logger.d('AgentController', '更新媒体会话元数据失败: $e'); - } - } - - /// 更新系统媒体会话的播放状态 - Future _updateMediaSessionPlaybackState(bool isPlaying) async { - try { - await _musiceManager.updateMediaSessionPlaybackState(isPlaying); - } catch (e) { - Logger.d('AgentController', '更新媒体会话播放状态失败: $e'); - } - } - - // 跳转到指定位置 - Future seekToPosition(int positionMs) async { - try { - await _musiceManager.seekTo(positionMs); - } catch (e) { - Logger.e('AgentController', '跳转播放位置失败: $e'); - } - } - - // 跳转到指定位置 - Future seekToProgress(int currentIdInternal, int newPosition, - int newDuration, double newProgress) async { - try { - // 立即更新进度状态 - await updateMusicProgress( - currentIdInternal, newPosition, newDuration, newProgress); - } catch (e) { - Logger.e('AgentController', '跳转播放位置失败: $e'); - } - } - - // 修改自动停止处理,遵循TTS设置 - void _handleAutoStop(String sessionid, int index) { - // 发送此消息到AI处理 - isProcessing.value = true; - // 标记为新的AI回复 - _isNewAssistantResponse = true; - currsessionid.value = sessionid; - AgentService.processTextInput(sessionid, messages[index].text, - speakResponse: isTtsEnabled.value); - } - - //去导航 - void openNavigation(bool isshow, String start, String destination, - String endlatitude, String endlongitude, String mode) { - print('liwei 导航:$destination $endlatitude $endlongitude $mode'); - if (isshow) { - // 弹出导航选择面板 - _showNavigationSelectionDialog( - start, destination, endlatitude, endlongitude, mode); - } else { - // 使用默认配置直接导航 - _navigateWithDefaultMode( - start, destination, endlatitude, endlongitude, mode); - } - } - - // 显示导航选择底部面板 - void _showNavigationSelectionDialog(String start, String destination, - String endlatitude, String endlongitude, String mode) { - Get.bottomSheet( - Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 顶部指示条 - Container( - margin: const EdgeInsets.only(top: 12), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - // 标题 - Padding( - padding: const EdgeInsets.all(20), - child: Text( - 'selectNavigationApp'.tr, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - // 导航选项列表 - ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.map, color: Colors.blue), - ), - title: Text('amapMap'.tr), - subtitle: Text('useAmapNavigation'.tr), - onTap: () { - Get.back(); - openGaodeMap(destination, endlatitude, endlongitude, mode); - }, - ), - ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.green.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.map, color: Colors.green), - ), - title: Text('baiduMap'.tr), - subtitle: Text('useBaiduNavigation'.tr), - onTap: () { - Get.back(); - openBaiduMap(destination, endlatitude, endlongitude, mode); - }, - ), - ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.map, color: Colors.red), - ), - title: Text('googleMap'.tr), - subtitle: Text('useGoogleNavigation'.tr), - onTap: () { - Get.back(); - openGoogleMap(destination, mode); - }, - ), - // 苹果导航 - 仅在 iOS 平台显示 - if (Platform.isIOS) - ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.map, color: Colors.orange), - ), - title: Text('appleMap'.tr), - subtitle: Text('useAppleNavigation'.tr), - onTap: () { - Get.back(); - openAppleMap(destination, mode); - }, - ), - ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.navigation, color: Colors.grey), - ), - title: Text('builtInNavigation'.tr), - subtitle: Text('useBuiltInNavigation'.tr), - onTap: () { - Get.back(); - openInternalGaodeMap( - start, destination, endlatitude, endlongitude, mode); - }, - ), - // 底部安全区域 - SizedBox(height: MediaQuery.of(Get.context!).padding.bottom + 20), - ], - ), - ), - isScrollControlled: true, - isDismissible: true, // 点击外部区域可关闭 - enableDrag: true, // 支持拖拽关闭 - ); - } - - // 使用默认配置导航 - void _navigateWithDefaultMode(String start, String destination, - String endlatitude, String endlongitude, String mode) { - final navigationMode = - AppConfig.env('APP_NAVIGATION_MODE') ?? 'external_auto'; - if (navigationMode == 'internal_gaode') { - _navigationManager.open(start, "$endlongitude,$endlatitude", mode); - } else if (navigationMode == 'external_baidu') { - openBaiduMap(destination, endlatitude, endlongitude, mode); - } else if (navigationMode == 'external_gaode') { - openGaodeMap(destination, endlatitude, endlongitude, mode); - } else if (navigationMode == 'external_google') { - openGoogleMap(destination, mode); - } else { - // 自动选择导航应用,优先级:百度 > 高德 > 谷歌 - _autoSelectNavigation( - start, destination, endlatitude, endlongitude, mode); - } - } - - // 自动选择导航应用 - Future _autoSelectNavigation(String start, String destination, - String endlatitude, String endlongitude, String mode) async { - try { - // 1. 优先尝试百度地图 - final baiduUri = Uri( - scheme: 'baidumap', - host: 'map', - path: 'direction', - queryParameters: { - 'destination': destination, - 'coord_type': 'gcj02', - 'mode': mode == 'driving' ? 'driving' : 'transit', - 'src': 'deepvoice', - }, - ); - - if (await canLaunchUrl(baiduUri)) { - print('自动选择:使用百度地图'); - openBaiduMap(destination, endlatitude, endlongitude, mode); - return; - } - - // 2. 其次尝试高德地图 - final gaodeUri = Uri( - scheme: 'amapuri', - host: 'navi', - queryParameters: { - 'to': destination, - 'mode': mode.isNotEmpty ? mode : 'driving', - 'callnative': '1', - }, - ); - - if (await canLaunchUrl(gaodeUri)) { - print('自动选择:使用高德地图'); - openGaodeMap(destination, endlatitude, endlongitude, mode); - return; - } - - // 3. 然后尝试谷歌地图 - final googleUri = Uri( - scheme: 'comgooglemaps', - queryParameters: { - 'daddr': destination, - 'directionsmode': mode == 'driving' ? 'driving' : 'transit', - }, - ); - - if (await canLaunchUrl(googleUri)) { - print('自动选择:使用谷歌地图'); - openGoogleMap(destination, mode); - return; - } - - // 4. 在 iOS 平台尝试苹果地图 - if (Platform.isIOS) { - final appleUri = Uri( - scheme: 'maps', - queryParameters: { - 'daddr': destination, - 'dirflg': mode == 'driving' ? 'd' : 'r', - }, - ); - - if (await canLaunchUrl(appleUri)) { - print('自动选择:使用苹果地图'); - openAppleMap(destination, mode); - return; - } - } - // 5. 最后尝试内置高德导航 - _navigationManager.open(start, "$endlongitude,$endlatitude", mode); - // 5. 如果都没有安装,提示用户 - // print('未找到可用的导航应用'); - // final availableApps = - // Platform.isIOS ? '百度地图、高德地图、谷歌地图或苹果地图' : '百度地图、高德地图或谷歌地图'; - // Get.snackbar('提示', '未找到可用的导航应用,请安装$availableApps'); - } catch (e) { - print('自动选择导航失败: $e'); - Get.snackbar('error'.tr, 'navigationStartFailed'.tr); - } - } - - // 打开图片查看器 - void openImageViewer(BuildContext context, String imagePath) { - // 构建完整的图片路径 - String fullImagePath; - if (imagePath.startsWith('/')) { - // 如果是相对路径(以/开头),需要拼接AppDir.value - fullImagePath = '${AppDir.value}$imagePath'; - } else { - // 如果已经是完整路径,直接使用 - fullImagePath = imagePath; - } - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => Scaffold( - // appBar: AppBar( - // backgroundColor: Colors.black, - // leading: IconButton( - // icon: Icon(Icons.arrow_back_ios, color: Colors.white), - // onPressed: () => Navigator.of(context).pop(), - // ), - // iconTheme: IconThemeData(color: Colors.white), - // //title: Text('图片预览', style: TextStyle(color: Colors.white)), - // ), - body: Container( - color: Colors.black, - child: GestureDetector( - onTap: () => Navigator.of(context).pop(), // 点击退出 - - child: PhotoView( - imageProvider: FileImage(File(fullImagePath)), - minScale: PhotoViewComputedScale.contained, - maxScale: PhotoViewComputedScale.covered * 2, - backgroundDecoration: BoxDecoration(color: Colors.black), - // errorBuilder: (context, error, stackTrace) { - // return const Center( - // child: Column( - // mainAxisAlignment: MainAxisAlignment.center, - // children: [ - // Icon(Icons.error, color: Colors.white, size: 64), - // SizedBox(height: 16), - // Text('图片加载失败', style: TextStyle(color: Colors.white)), - // ], - // ), - // ); - // }, - ), - ), - ), - ), - ), - ); - } - - /// 播放音乐列表 - Future playPlaylist(List> playlist) async { - try { - if (playlist.isEmpty) { - Logger.e(TAG, '播放列表为空,无法播放'); - return; - } - - Logger.d(TAG, '开始播放音乐列表,共${playlist.length}首歌曲'); - - // 保存当前播放列表 - currentPlaylist.assignAll(playlist); - currentPlaylistIndex.value = 0; - - // 设置加载状态 - isLoadingMusic.value = true; - loadingMusicId.value = playlist[0]['id']; - - // 重置播放状态 - isPlaying.value = true; - currentPosition.value = 0; - duration.value = 0; - progress.value = 0.0; - - // 格式化歌曲列表数据 - final formattedPlaylist = playlist.map((song) { - return { - 'id': song['id'], - 'title': song['title']?.toString() ?? song['name']?.toString() ?? '', - 'artist': - song['artist']?.toString() ?? song['sgener']?.toString() ?? '', - 'coverUrl': - song['coverUrl']?.toString() ?? song['image']?.toString() ?? '', - }; - }).toList(); - - // 调用音乐管理器播放列表 - await _musiceManager.playPlaylist(formattedPlaylist); - - // 更新当前播放歌曲ID - if (formattedPlaylist.isNotEmpty) { - currentId.value = formattedPlaylist[0]['id'] ?? 0; - } - - Logger.d(TAG, '音乐列表播放启动成功'); - } catch (e) { - Logger.e(TAG, '播放音乐列表失败: $e'); - isPlaying.value = false; - currentId.value = 0; - - // 显示错误提示 - Get.snackbar( - 'playFailed'.tr, - 'cannotPlayMusicListRetryLater'.tr, - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red[400], - colorText: Colors.white, - duration: const Duration(seconds: 2), - ); - } finally { - // 清除加载状态 - isLoadingMusic.value = false; - loadingMusicId.value = 0; - } - } - - /// 播放上一曲 - Future playPrevious() async { - if (currentPlaylist.isEmpty) { - Logger.w(TAG, '当前播放列表为空,无法播放上一曲'); - return; - } - - // 计算上一曲的索引 - int previousIndex = currentPlaylistIndex.value - 1; - if (previousIndex < 0) { - previousIndex = currentPlaylist.length - 1; // 循环到最后一首 - } - - currentPlaylistIndex.value = previousIndex; - final previousSong = currentPlaylist[previousIndex]; - - Logger.d(TAG, '播放上一曲: ${previousSong['name'] ?? previousSong['title']}'); - await musicPlay(previousSong); - } - - /// 播放下一曲 - Future playNext() async { - if (currentPlaylist.isEmpty) { - Logger.w(TAG, '当前播放列表为空,无法播放下一曲'); - return; - } - - // 计算下一曲的索引 - int nextIndex = currentPlaylistIndex.value + 1; - if (nextIndex >= currentPlaylist.length) { - nextIndex = 0; // 循环到第一首 - } - - currentPlaylistIndex.value = nextIndex; - final nextSong = currentPlaylist[nextIndex]; - - Logger.d(TAG, '播放下一曲: ${nextSong['name'] ?? nextSong['title']}'); - await musicPlay(nextSong); - } - - /// 更新当前播放列表和索引(当单独播放某首歌曲时调用) - void updateCurrentPlaylistContext( - List> playlist, int songId) { - currentPlaylist.assignAll(playlist); - - // 查找当前歌曲在列表中的索引 - int index = -1; - for (int i = 0; i < playlist.length; i++) { - if (playlist[i]['id'] == songId) { - index = i; - break; - } - } - - if (index != -1) { - currentPlaylistIndex.value = index; - Logger.d(TAG, '更新播放列表上下文,当前歌曲索引: $index'); - } - } - - /// 应用生命周期变化回调 - /// 目的: - /// - 当应用进入后台、失去焦点或被系统分离(inactive/paused/detached)时, - /// 兜底结束按住说话(调用 endPushToTalk),避免因 onPointerUp 未触发而卡在“按下”状态。 - /// 参数: - /// - state: 当前应用生命周期状态 - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.inactive || - state == AppLifecycleState.paused || - state == AppLifecycleState.detached) { - if (isPushToTalkActive.value) { - // 使用 microtask 避免阻塞当前生命周期回调 - Future.microtask(() { - endPushToTalk(); - }); - } - } - } - - // ========== 媒体会话回调处理方法 ========== - - /// 处理来自系统通知栏的播放操作 - Future onMediaSessionPlay() async { - Logger.d(TAG, '收到媒体会话播放回调'); - if (!isPlaying.value) { - await musicResume(); - } - } - - /// 处理来自系统通知栏的暂停操作 - Future onMediaSessionPause() async { - Logger.d(TAG, '收到媒体会话暂停回调'); - if (isPlaying.value) { - await musicPause(); - } - } - - /// 处理来自系统通知栏的下一曲操作 - Future onMediaSessionNext() async { - Logger.d(TAG, '收到媒体会话下一曲回调'); - await playNext(); - } - - /// 处理来自系统通知栏的上一曲操作 - Future onMediaSessionPrevious() async { - Logger.d(TAG, '收到媒体会话上一曲回调'); - await playPrevious(); - } - - /// 处理来自系统通知栏的跳转操作 - Future onMediaSessionSeekTo(int positionMs) async { - Logger.d(TAG, '收到媒体会话跳转回调: ${positionMs}ms'); - await seekToPosition(positionMs); - } - - /// 处理来自系统通知栏的停止操作 - Future onMediaSessionStop() async { - Logger.d(TAG, '收到媒体会话停止回调'); - await musicPause(); - // 重置播放状态 - currentPosition.value = 0; - progress.value = 0.0; - _stopProgressTimer(); - } - - /// 处理来自系统进度条 - Future onMediaSessionProgress(int currentIdInternal, int newPosition, - int newDuration, double newProgress) async { - // print( - // "onMediaSessionProgress currentIdInternal :$currentIdInternal ,newPosition:$newPosition newDuration:$newDuration newProgress:$newProgress"); - await seekToProgress( - currentIdInternal, newPosition, newDuration, newProgress); - } - - /// 处理来自系统通知栏的停止操作 - Future playQQMusic( - List songs, SongModel song, int index) async { - try { - if (_qqmusicManager.isPlaying.value) { - if (_qqmusicManager.currentSong.value?.mid == song.mid) { - // 当前歌曲正在播放,暂停它 - await _qqmusicManager.pauseMusic(); - } else { - final songMids = songs.map((s) => s.mid).toList(); - // 其他歌曲正在播放,播放这首歌 - final result = await _qqmusicManager.playSongs( - songMids: songMids, - index: index, - songList: [song], - currentSongModel: song, - ); - if (result['success'] == true) { - // 播放成功后,如果歌曲不在当前列表中,保持列表显示状态不变 - // Get.snackbar('开始播放', song.name); - _qqmusicManager.syncCurrentPlayInfo(); - } else { - Get.snackbar( - 'playFailed'.tr, result['error'] ?? 'cannotPlaySong'.tr); - } - return; - } - } else { - if (_qqmusicManager.currentSong.value?.mid == song.mid) { - // 当前歌曲已暂停,恢复播放 - await _qqmusicManager.resumeMusic(); - } else { - final songMids = songs.map((s) => s.mid).toList(); - // 播放新歌曲 - final result = await _qqmusicManager.playSongs( - songMids: songMids, - index: index, - songList: [song], - currentSongModel: song, - ); - if (result['success'] == true) { - // 播放成功后,如果歌曲不在当前列表中,保持列表显示状态不变 - // Get.snackbar('开始播放', song.name); - _qqmusicManager.syncCurrentPlayInfo(); - } else { - Get.snackbar( - 'playFailed'.tr, result['error'] ?? 'cannotPlaySong'.tr); - } - } - } - } catch (e) { - // 静默处理错误,避免影响用户体验 - print('播放控制失败: $e'); - } - } - - /// 打开内置高德地图进行导航 - Future openInternalGaodeMap( - String start, - String destination, - String endlatitude, - String endlongitude, - String mode // 'driving', 'transit', 'walking', 'cycling' - ) async { - try { - _navigationManager.open(start, "$endlongitude,$endlatitude", mode); - return true; - } catch (e) { - print('打开内置高德地图失败: $e'); - Get.snackbar('error'.tr, 'openMapError'.tr); - return false; - } - } - - /// 打开Google地图进行导航 - Future openGoogleMap(String daddr, String mode) async { - try { - // 方案1: 尝试使用Google Maps应用的URL scheme - // final Uri googleMapsUri = Uri.parse(url); - - // 方案1: Google Maps应用 URL scheme - final Uri appUri = Uri.parse( - 'comgooglemaps://?saddr=CurrentLocation&daddr=$daddr&directionsmode=$mode'); - if (await canLaunchUrl(appUri)) { - await launchUrl(appUri, mode: LaunchMode.externalApplication); - return true; - } else { - final Uri webUrl = Uri.parse( - "https://www.google.com/maps/dir/?api=1&origin=Current+Location&destination=$daddr&travelmode=$mode"); - if (await canLaunchUrl(webUrl)) { - await launchUrl(webUrl); - return true; - } - return false; - } - } catch (e) { - print('打开地图失败: $e'); - return false; - } - } - - /// 打开高德地图进行导航 - Future openGaodeMap( - String destination, - String lat, - String lon, - String mode, // 'driving', 'transit', 'walking', 'cycling' - ) async { - try { - // 获取应用包名 - final packageInfo = await PackageInfo.fromPlatform(); - - // 构建src参数(带平台前缀) - final String src = Platform.isAndroid - ? packageInfo.packageName - : "ios.${packageInfo.packageName}"; - - // 导航模式映射 - String gaodeMode; - switch (mode) { - case 'transit': - gaodeMode = '1'; // 公交 - break; - case 'walking': - gaodeMode = '2'; // 步行 - break; - case 'cycling': - gaodeMode = '3'; // 骑行 - break; - case 'driving': - default: - gaodeMode = '0'; // 驾车 - break; - } - - // 根据平台选择不同的 scheme 和参数 - final Uri uri; - if (Platform.isAndroid) { - uri = Uri( - scheme: 'androidamap', - host: 'route', - path: 'plan', - queryParameters: { - 'dlat': lat, - 'dlon': lon, - 'dname': destination, - 'dev': '0', // 0: gcj02 - 't': gaodeMode, - 'sourceApplication': src, - }, - ); - } else { - // iOS - uri = Uri( - scheme: 'amapuri', - host: 'route', - path: 'plan', - queryParameters: { - 'dlat': lat, - 'dlon': lon, - 'dname': destination, - 'dev': '0', // 0: gcj02 - 't': gaodeMode, - 'sourceApplication': src, - }, - ); - } - - print('高德导航链接: ${uri.toString()}'); - - if (await canLaunchUrl(uri)) { - final bool launched = await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); - return launched; - } else { - print('高德地图未安装'); - Get.snackbar('tip'.tr, 'pleaseInstallAmapApp'.tr); - return false; - } - } catch (e) { - print('打开高德地图失败: $e'); - Get.snackbar('error'.tr, 'openMapError'.tr); - return false; - } - } - - /// 打开Baidu地图进行导航 - Future openBaiduMap( - String destination, - String lat, - String lon, - String mode, // 'driving', 'transit', 'walking', 'riding' - ) async { - try { - // 获取应用包名 - final packageInfo = await PackageInfo.fromPlatform(); - final String src = - '${Platform.isAndroid ? '' : 'ios.'}${packageInfo.packageName}'; - - // 百度地图导航模式映射 - String baiduMode; - switch (mode) { - case 'transit': - baiduMode = 'transit'; // 公交 - break; - case 'walking': - baiduMode = 'walking'; // 步行 - break; - case 'cycling': - baiduMode = 'riding'; // 骑行 - break; - case 'driving': - default: - baiduMode = 'driving'; // 驾车 - break; - } - - final Uri uri = Uri( - scheme: 'baidumap', - host: 'map', - path: 'direction', - queryParameters: { - 'destination': 'name:$destination|latlng:$lat,$lon', - 'coord_type': 'gcj02', - 'mode': baiduMode, - 'src': src, - }, - ); - - print('百度导航链接: ${uri.toString()}'); - - if (await canLaunchUrl(uri)) { - final bool launched = await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); - return launched; - } else { - print('百度地图未安装'); - Get.snackbar('tip'.tr, 'pleaseInstallBaiduMapApp'.tr); - return false; - } - } catch (e) { - print('打开百度地图失败: $e'); - Get.snackbar('error'.tr, 'openMapError'.tr); - return false; - } - } - - /// 打开苹果地图进行导航 - Future openAppleMap(String destination, String mode) async { - try { - // 构建苹果地图导航链接 - final Uri uri = Uri( - scheme: 'maps', - queryParameters: { - 'daddr': destination, // 目的地地址 - 'dirflg': mode == 'driving' ? 'd' : 'r', // d=驾车, r=公交 - }, - ); - - print('苹果地图导航链接: ${uri.toString()}'); - - // 尝试打开苹果地图应用 - if (await canLaunchUrl(uri)) { - final bool launched = await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); - if (!launched) { - print('苹果地图启动失败'); - Get.snackbar('tip'.tr, 'appleMapStartFailed'.tr); - } - } else { - print('苹果地图不可用'); - Get.snackbar('tip'.tr, 'appleMapUnavailable'.tr); - } - } catch (e) { - print('打开苹果地图失败: $e'); - Get.snackbar('error'.tr, 'openAppleMapError'.tr); - } - } - - Future updateToolCallsExpanded( - String sessionid, - bool isExpanded, - ) async { - final message = messages.firstWhere( - (msg) => msg.sessionid == sessionid && msg.isUser == false, - ); - message.isToolCallsExpanded = isExpanded; - messages.refresh(); - } - - String getDeviceName() { - if (_bluetoothManager.currentDevice != null) { - return _bluetoothManager.currentDevice!.name; - } - return ""; - } -} diff --git a/apps/client/lib/modules/agent/controllers/message_action_controller.dart b/apps/client/lib/modules/agent/controllers/message_action_controller.dart deleted file mode 100644 index cd717ec1..00000000 --- a/apps/client/lib/modules/agent/controllers/message_action_controller.dart +++ /dev/null @@ -1,412 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:get/get.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:haptic_feedback/haptic_feedback.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../data/models/message_model.dart'; -import '../../../data/services/network/api.dart'; - -/// 消息操作控制器 -/// 负责处理消息长按、复制、举报等操作 -class MessageActionController extends GetxController { - // 当前举报的消息 - final reportingMessage = Rxn(); - - // 选择的举报原因 - List selectedReasons = []; - - // 举报描述 - final reportDescription = ''.obs; - - /// 处理消息长按事件 - void onLongPressMessage( - BuildContext context, - bool isDarkMode, - LongPressStartDetails details, - Message message, - ) { - // 触发触觉反馈 - Haptics.vibrate(HapticsType.success); - - // 计算弹出菜单位置 - RenderBox? renderBox = - Overlay.of(context).context.findRenderObject() as RenderBox; - final RelativeRect position = RelativeRect.fromRect( - Rect.fromLTRB( - details.globalPosition.dx, - details.globalPosition.dy, - details.globalPosition.dx + 110, // 菜单显示位置X轴坐标 - details.globalPosition.dy - 40, // 菜单显示位置Y轴坐标 - ), - Offset.zero & renderBox.size, - ); - - // 显示弹出菜单 - showMenu( - context: context, - position: position, - color: isDarkMode ? AppColors.cardBackground : Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.r), - ), - items: _buildMenuItems(context, isDarkMode, message), - ); - } - - /// 构建弹出菜单项 - List _buildMenuItems( - BuildContext context, - bool isDarkMode, - Message message, - ) { - final List items = [ - // 复制选项 - _buildPopupMenuItem( - Icon( - Icons.copy, - size: 20.w, - color: isDarkMode ? Colors.white : Colors.black87, - ), - 'copy'.tr, // 复制 - isDarkMode ? Colors.white : Colors.black87, - () => _copyMessage(message), - ), - ]; - - // 如果不是用户消息,添加举报选项 - if (!message.isUser) { - items.addAll([ - // 分隔线 - PopupMenuItem( - height: 1, - enabled: false, - child: Divider( - height: 1, - thickness: 0.8, - indent: 0, - endIndent: 0, - color: isDarkMode ? Colors.grey[700] : Colors.grey[300], - ), - ), - // 举报选项 - _buildPopupMenuItem( - Icon( - Icons.report, - size: 20.w, - color: isDarkMode ? Colors.red[700] : Colors.red, - ), - 'report'.tr, //举报 - isDarkMode ? Colors.red[700]! : Colors.red, - () => _showReportForm(context, isDarkMode, message), - ), - ]); - } - - return items; - } - - /// 构建弹出菜单项 - PopupMenuEntry _buildPopupMenuItem( - Icon icon, - String title, - Color color, - Function() onTap, - ) { - return PopupMenuItem( - onTap: onTap, - child: SizedBox( - width: 140.w, - child: Row( - children: [ - icon, - 8.horizontalSpace, - Text( - title, - style: TextStyle( - color: color, - fontSize: 14.sp, - ), - ), - ], - ), - ), - ); - } - - /// 复制消息内容 - void _copyMessage(Message message) { - Clipboard.setData(ClipboardData(text: message.text)).then((_) { - Get.snackbar( - 'success'.tr, - 'copySuccess'.tr, // 复制成功 - backgroundColor: Colors.blue.withOpacity(0.8), - colorText: Colors.white, - ); - }); - } - - /// 显示举报表单 - void _showReportForm(BuildContext context, bool isDarkMode, Message message) { - // 初始化举报状态 - initReport(message); - - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)), - ), - builder: (BuildContext context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: _ReportForm(isDarkMode: isDarkMode), - ); - }, - ); - } - - /// 初始化举报状态 - void initReport(Message message) { - reportingMessage.value = message; - selectedReasons.clear(); - reportDescription.value = ''; - } - - /// 切换举报原因选择状态 - void toggleReason(String reason) { - if (selectedReasons.contains(reason)) { - selectedReasons.remove(reason); - } else { - selectedReasons.add(reason); - } - update(); - } - - /// 更新举报描述 - void updateDescription(String text) { - reportDescription.value = text; - } - - /// 提交举报 - Future submitReport() async { - if (reportingMessage.value == null) return; - - // 构建举报数据 - final reportData = { - 'describe': reportDescription.value, - 'message': [], - 'reportContext': reportingMessage.value!.text, - 'reporttype': selectedReasons.toString(), - }; - - try { - // 调用API提交举报 - var response = await Api.reportAiContent(reportData); - - if (response != null) { - print('提交举报响应: $response'); - } - - // 提交成功后重置状态 - selectedReasons.clear(); - reportDescription.value = ''; - reportingMessage.value = null; - - // 关闭举报表单 - Navigator.of(Get.context!).pop(); - - // 显示成功通知 - Get.snackbar( - 'submitSuccess'.tr, // 提交成功 - 'reportSubmitted'.tr, // 举报已提交,我们会尽快处理 - backgroundColor: Colors.green.withOpacity(0.8), - colorText: Colors.white, - duration: const Duration(seconds: 2), - ); - } catch (e) { - // 显示错误通知 - Get.snackbar( - 'submitFailed'.tr, // 提交失败 - 'networkError'.tr, // 网络错误,请稍后重试 - backgroundColor: Colors.red.withOpacity(0.8), - colorText: Colors.white, - duration: const Duration(seconds: 2), - ); - } - } -} - -/// 举报表单组件 -class _ReportForm extends StatelessWidget { - final bool isDarkMode; - final MessageActionController controller = - Get.find(); - - _ReportForm({required this.isDarkMode}); - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.all(16.w), - child: SingleChildScrollView( - child: GetBuilder( - builder: (_) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 标题栏 - Stack( - alignment: Alignment.center, - children: [ - Center( - child: Text( - 'report'.tr, // 举报 - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - Positioned( - right: 0, - child: IconButton( - icon: Icon( - Icons.close, - color: isDarkMode ? Colors.grey[400] : Colors.black54, - ), - onPressed: () => Navigator.pop(context), - ), - ), - ], - ), - - // 分隔线 - Container( - padding: EdgeInsets.symmetric(horizontal: 12.w), - margin: EdgeInsets.only(bottom: 10.w), - child: Divider( - height: 1, - color: Colors.grey[300], - ), - ), - - // 举报原因选择 - GetBuilder( - builder: (controller) { - return Wrap( - spacing: 8.w, - runSpacing: 8.h, - children: [ - _buildReportChip( - 'harmfulUnsafe'.tr, isDarkMode, controller), // 有害/不安全 - _buildReportChip('pornographicVulgar'.tr, isDarkMode, - controller), // 色情低俗 - _buildReportChip('misleadingAttack'.tr, isDarkMode, - controller), // 误导攻击 - _buildReportChip( - 'illegalCriminal'.tr, isDarkMode, controller), // 违法犯罪 - _buildReportChip( - 'privacyRelated'.tr, isDarkMode, controller), // 隐私相关 - _buildReportChip('copyrightViolation'.tr, isDarkMode, - controller), // 侵犯我的著作权、名誉权等 - _buildReportChip( - 'other'.tr, isDarkMode, controller), // 其他 - ], - ); - }, - ), - - SizedBox(height: 16.h), - - // 描述输入框 - Container( - padding: EdgeInsets.all(12.w), - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.grey[200], - borderRadius: BorderRadius.circular(8.r), - ), - child: TextField( - maxLines: 3, - onChanged: controller.updateDescription, - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, - ), - decoration: InputDecoration( - hintText: - 'reportDescriptionHint'.tr, // 我们想知道你举报的原因,你可以描述你遇到的问题。 - hintStyle: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - fontSize: 14.sp, - ), - border: InputBorder.none, - ), - ), - ), - - SizedBox(height: 14.h), - - // 提交按钮 - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric(vertical: 12.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.r), - ), - ), - child: Text('submit'.tr), // 提交 - onPressed: () => controller.submitReport(), - ), - - SizedBox(height: 8.h), - ], - ), - ), - ), - ); - } - - /// 构建举报原因芯片 - Widget _buildReportChip( - String label, - bool isDarkMode, - MessageActionController controller, - ) { - final isSelected = controller.selectedReasons.contains(label); - - return GestureDetector( - onTap: () => controller.toggleReason(label), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - decoration: BoxDecoration( - color: isSelected - ? Colors.red - : (isDarkMode ? Colors.grey[800] : Colors.grey[200]), - borderRadius: BorderRadius.circular(16.r), - border: Border.all( - color: isSelected - ? Colors.red[700]! - : (isDarkMode ? Colors.grey[700]! : Colors.grey[300]!), - width: isSelected ? 1.0 : 0.5, - ), - ), - child: Text( - label, - style: TextStyle( - color: isSelected - ? Colors.white - : (isDarkMode ? Colors.white : Colors.black87), - fontSize: 14.sp, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - ), - ), - ); - } -} diff --git a/apps/client/lib/modules/agent/views/agent_view.dart b/apps/client/lib/modules/agent/views/agent_view.dart deleted file mode 100644 index 82016a48..00000000 --- a/apps/client/lib/modules/agent/views/agent_view.dart +++ /dev/null @@ -1,915 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter_intro/flutter_intro.dart'; -import 'package:get/get.dart'; -import '../../../core/widgets/intro_overlay_builder.dart'; -import '../../../data/models/channel_app_model.dart'; -import '../../../data/services/version_update_service.dart'; -import '../controllers/agent_controller.dart'; -import '../controllers/message_action_controller.dart'; -import 'message_bubble.dart'; -import '../widgets/sound_bar_widget.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; - -class AgentView extends StatefulWidget { - const AgentView({Key? key}) : super(key: key); - - @override - State createState() => _AgentViewState(); -} - -class _AgentViewState extends State with WidgetsBindingObserver { - late AgentController controller; - late MessageActionController messageActionController; - double _keyboardHeight = 0; - - void _showTtsSpeedDialog() { - final primaryColor = Theme.of(context).primaryColor; - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final RxInt tempRate = controller.ttsRatePercent.value.obs; - - Get.dialog( - AlertDialog( - backgroundColor: isDarkMode ? Colors.grey[850] : Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.w), - ), - title: Text( - 'ttsSpeed'.tr, // 语速 - style: TextStyle(fontSize: 16.sp), - ), - content: Obx(() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - 'ttsSpeedDefaultHint'.tr, // 默认 +25% - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - SizedBox(height: 12.h), - Text( - '+${tempRate.value}%', - style: TextStyle( - fontSize: 20.sp, - fontWeight: FontWeight.w600, - color: primaryColor, - ), - ), - SizedBox(height: 8.h), - Slider( - value: tempRate.value.toDouble(), - min: 0, - max: 100, - divisions: 20, - label: '+${tempRate.value}%', - activeColor: primaryColor, - onChanged: (value) { - tempRate.value = value.round(); - }, - ), - ], - ); - }), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text('cancel'.tr), - ), - TextButton( - onPressed: () async { - Get.back(); - await controller.updateTtsRate(tempRate.value); - }, - child: Text('confirm'.tr), - ), - ], - ), - barrierDismissible: true, - ); - } - - @override - void initState() { - super.initState(); - controller = Get.find(); - messageActionController = Get.find(); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeMetrics() { - super.didChangeMetrics(); - final bottomInset = View.of(context).viewInsets.bottom; - final newKeyboardHeight = bottomInset / View.of(context).devicePixelRatio; - - if (_keyboardHeight != newKeyboardHeight) { - _keyboardHeight = newKeyboardHeight; - // 更新键盘可见状态 - controller.updateKeyboardVisibility(_keyboardHeight > 0); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final primaryColor = theme.primaryColor; - final backgroundColor = theme.scaffoldBackgroundColor; - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final isOfficialChannel = Get.isRegistered() && - VersionUpdateService.to.cachedChannel == UserChannel.OFFICIAL; - - return Scaffold( - appBar: AppBar( - elevation: 1, - backgroundColor: backgroundColor, - leading: IconButton( - icon: Icon(Icons.arrow_back_ios, - color: isDarkMode ? Colors.white : Colors.black87, size: 20.sp), - onPressed: () => Get.back(), - ), - titleSpacing: 0, - title: Row( - children: [ - Container( - width: 40.w, - height: 40.w, - decoration: BoxDecoration( - color: primaryColor, - borderRadius: BorderRadius.circular(20.w), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 3.w, - offset: const Offset(0, 1), - ), - ], - ), - child: Icon( - Icons.support_agent, - color: Colors.white, - size: 22.sp, - ), - ), - SizedBox(width: 12.w), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - // 机器人名字(品牌名,不做多语言翻译) - 'Emai', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black87, - ), - ), - Row( - children: [ - Container( - width: 6.w, - height: 6.w, - decoration: const BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - ), - ), - SizedBox(width: 4.w), - Text( - 'online'.tr, - style: TextStyle( - fontSize: 12.sp, - color: Colors.green, - fontWeight: FontWeight.w500, - ), - ), - // Obx(() { - // final vipexptime = controller.vipExpiryTime.value; - // DateTime? expiry; - // if (vipexptime > 0) { - // expiry = vipexptime > 1000000000000 - // ? DateTime.fromMillisecondsSinceEpoch(vipexptime) - // : DateTime.fromMillisecondsSinceEpoch( - // vipexptime * 1000); - // } - // final text = expiry != null - // ? '${'expiryLabel'.tr}${DateFormat('yyyy-MM-dd').format(expiry)}' - // : 'notActivated'.tr; - // return Text( - // text, - // style: TextStyle( - // fontSize: 12.sp, - // color: Colors.green, - // fontWeight: FontWeight.w500, - // ), - // ); - // }), - ], - ), - ], - ), - ], - ), - actions: [ - // TTS开关 - Obx(() => IconButton( - icon: Icon( - controller.isTtsEnabled.value - ? Icons.volume_up - : Icons.volume_off, - size: 24.sp, - color: controller.isTtsEnabled.value - ? primaryColor - : isDarkMode - ? Colors.grey[400] - : Colors.grey, - ), - onPressed: () => controller.toggleTts(), - )), - - // 更多选项 - Container( - width: 34.w, - height: 34.w, - margin: EdgeInsets.only(right: 8.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.1) - : Colors.grey[100], - shape: BoxShape.circle, - boxShadow: [ - if (!isDarkMode) - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 2.w, - spreadRadius: 0.5.w, - ), - ], - ), - child: PopupMenuButton( - icon: Icon(Icons.more_vert, - color: isDarkMode ? Colors.white : Colors.black87, - size: 20.sp), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.w), - ), - color: isDarkMode ? Colors.grey[800] : Colors.white, - itemBuilder: (context) => [ - if (isOfficialChannel) - PopupMenuItem( - child: Obx(() => Row( - children: [ - Icon( - controller.isShowToolInterruptEnabled.value - ? Icons.build_circle - : Icons.build_circle_outlined, - color: controller.isShowToolInterruptEnabled.value - ? primaryColor - : (isDarkMode - ? Colors.grey[400] - : Colors.grey), - size: 20.sp, - ), - SizedBox(width: 8.w), - Text( - "显示工具", - style: TextStyle(fontSize: 14.sp), - ), - const Spacer(), - Switch( - value: - controller.isShowToolInterruptEnabled.value, - onChanged: (value) => - controller.toggleShowAIToolInterrupt(), - activeColor: primaryColor, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ), - ], - )), - onTap: () => controller.toggleTtsInterrupt(), - ), - - // 播报打断开关选项 - PopupMenuItem( - child: Obx(() => Row( - children: [ - Icon( - controller.isTtsInterruptEnabled.value - ? Icons.voice_over_off - : Icons.voice_over_off_outlined, - color: controller.isTtsInterruptEnabled.value - ? primaryColor - : (isDarkMode ? Colors.grey[400] : Colors.grey), - size: 20.sp, - ), - SizedBox(width: 8.w), - Text( - //'ttsInterruptToggle'.tr, - "voice_interrupt".tr, // 语音打断 - style: TextStyle(fontSize: 14.sp), - ), - const Spacer(), - Switch( - value: controller.isTtsInterruptEnabled.value, - onChanged: (value) => - controller.toggleTtsInterrupt(), - activeColor: primaryColor, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ), - ], - )), - onTap: () => controller.toggleTtsInterrupt(), - ), - - PopupMenuItem( - child: Row( - children: [ - Icon(Icons.speed, color: primaryColor, size: 20.sp), - SizedBox(width: 8.w), - Text('ttsSpeed'.tr, // 语速 - style: TextStyle(fontSize: 14.sp)), - ], - ), - onTap: () => Future.delayed( - const Duration(milliseconds: 100), - () => _showTtsSpeedDialog(), - ), - ), - - // 清除聊天记录选项 - PopupMenuItem( - child: Row( - children: [ - Icon(Icons.delete_outline, - color: primaryColor, size: 20.sp), - SizedBox(width: 8.w), - Text('clearChatHistory'.tr, - style: TextStyle(fontSize: 14.sp)), - ], - ), - onTap: () => Future.delayed( - const Duration(milliseconds: 100), - () => controller.clearChat(), - ), - ), - ], - ), - ), - ], - ), - body: SafeArea( - top: false, - child: Stack( - children: [ - IntroStepBuilder( - group: 'ai', - order: 3, - getOverlayPosition: IntroOverlayBuilder.overlayPosition, - overlayBuilder: (params) { - return Obx( - () => IntroOverlayBuilder.homeBuilder( - params, - false, - title: controller.newAIReplyCompleted.value - ? 'aiReplyCompleted'.tr - : 'aiThinking'.tr, - onNext: controller.newAIReplyCompleted.value ? () {} : null, - ), - ); - }, - builder: (BuildContext context, GlobalKey key) => SizedBox( - key: key, - ), - ), - // 主要聊天界面(占满整个 body,底部按钮浮在其上) - GestureDetector( - onTap: () { - // 点击聊天区域时,收起键盘并切换工具显示状态 - FocusScope.of(context).unfocus(); - if (controller.isPlusOptionsExpanded.value) { - controller.collapsePlusOptions(); - } - }, - child: Obx(() { - return Align( - alignment: Alignment.topCenter, - child: ListView.builder( - controller: controller.scrollController, - shrinkWrap: true, - reverse: true, - padding: EdgeInsets.only( - top: 16.h, - // 预留浮动通话/挂断按钮的高度,避免最后一条消息被遮挡 - bottom: 140.h, - left: 16.w, - right: 16.w, - ), - itemCount: controller.messages.length + - (controller.hasMoreMessages.value ? 1 : 0), - itemBuilder: (context, index) { - // 如果是最后一项且有更多消息,显示加载指示器 - if (index == controller.messages.length && - controller.hasMoreMessages.value) { - return _buildLoadMoreIndicator(); - } - - final message = controller - .messages[controller.messages.length - 1 - index]; - return index == 0 - ? IntroStepBuilder( - group: 'ai', - order: 4, - getOverlayPosition: - IntroOverlayBuilder.overlayPosition, - overlayBuilder: (params) { - return IntroOverlayBuilder.aiBuilder( - params, - '${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['endKey']}' - .tr, - onFinish: () async { - if (controller.noviceGuideService.aiStep < - controller.noviceGuideService - .aiStepsList.length - - 1) { - await Future.delayed( - const Duration(milliseconds: 300)); - controller.noviceGuideService.aiStep++; - Intro.of(Get.context!) - .start(group: 'ai', reset: true); - } - }, - onTap: () async { - params.onFinish(); - if (controller.noviceGuideService.aiStep < - controller.noviceGuideService - .aiStepsList.length - - 1) { - await Future.delayed( - const Duration(milliseconds: 300)); - controller.noviceGuideService.aiStep++; - Intro.of(Get.context!) - .start(group: 'ai', reset: true); - } - }, - ); - }, - builder: (BuildContext context, GlobalKey key) => - Padding( - key: key, - padding: EdgeInsets.only(bottom: 8.h), - child: GestureDetector( - onLongPressStart: - (LongPressStartDetails details) { - messageActionController.onLongPressMessage( - context, isDarkMode, details, message); - }, - child: MessageBubble( - sessionid: message.sessionid, - isUser: message.isUser, - message: message.text, - timestamp: message.timestamp, - isRecognizing: message.isRecognizing, - hasImage: message.hasImage, - imagePath: message.imagePath, - meta: message.meta, - functionCalls: message.functionCalls, - isToolCallsExpanded: - message.isToolCallsExpanded, - ), - ), - ), - ) - : Padding( - padding: EdgeInsets.only(bottom: 8.h), - child: GestureDetector( - onLongPressStart: - (LongPressStartDetails details) { - messageActionController.onLongPressMessage( - context, isDarkMode, details, message); - }, - child: MessageBubble( - sessionid: message.sessionid, - isUser: message.isUser, - message: message.text, - timestamp: message.timestamp, - isRecognizing: message.isRecognizing, - hasImage: message.hasImage, - imagePath: message.imagePath, - meta: message.meta, - functionCalls: message.functionCalls, - isToolCallsExpanded: - message.isToolCallsExpanded, - ), - ), - ); - }, - ), - ); - }), - ), - // 底部输入栏:仿微信「键盘/语音」双模式切换,只保留打字与语音输入 - Align( - alignment: Alignment.bottomCenter, - child: Obx(() { - if (controller.isDeviceCallMode.value) { - return const SizedBox.shrink(); - } - return _buildInputBar(context, isDarkMode); - }), - ), - - // 打电话模式覆盖层 - Obx(() { - if (controller.isPhoneCallMode.value) { - return _buildPhoneCallOverlay(context, isDarkMode); - } - return const SizedBox.shrink(); - }), - - // 设备通话模式覆盖层 - Obx(() { - if (controller.isDeviceCallMode.value) { - return _buildDeviceCallOverlay(context, isDarkMode); - } - return const SizedBox.shrink(); - }), - ], - ), - ), - ); - } - - // 底部输入栏:仿微信的「键盘 / 语音」双模式。 - // 左侧圆形按钮切换模式:文本模式显示麦克风图标(点它切到语音), - // 语音模式显示键盘图标(点它切回打字)。中间随模式变成输入框或「按住 说话」。 - // 只做打字 + 语音输入两件事,不含表情/图片/更多面板。 - Widget _buildInputBar(BuildContext context, bool isDarkMode) { - final Color barBg = isDarkMode ? const Color(0xFF1C1C1E) : Colors.white; - final Color fieldBg = - isDarkMode ? const Color(0xFF2C2C2E) : const Color(0xFFF2F3F5); - final Color iconColor = isDarkMode ? Colors.white70 : Colors.black54; - final Color textColor = isDarkMode ? Colors.white : Colors.black87; - - return Container( - decoration: BoxDecoration( - color: barBg, - border: Border( - top: BorderSide( - color: - isDarkMode ? const Color(0xFF2C2C2E) : const Color(0xFFE5E5E5), - width: 0.5, - ), - ), - ), - // 外层 body 已经包了 SafeArea(top:false),这里不再重复处理底部安全区 - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // 模式切换按钮 - GestureDetector( - onTap: controller.toggleInputMode, - behavior: HitTestBehavior.opaque, - child: Padding( - padding: EdgeInsets.only(right: 8.w, bottom: 4.h), - child: Icon( - controller.isTextInputMode.value - ? Icons.keyboard_voice_outlined - : Icons.keyboard_outlined, - size: 28.sp, - color: iconColor, - ), - ), - ), - // 中间:输入框 / 按住说话 - Expanded( - child: controller.isTextInputMode.value - ? _buildTextField(fieldBg, textColor) - : _buildHoldToTalkButton(fieldBg, textColor), - ), - // 发送按钮:仅文本模式且有内容时出现 - if (controller.isTextInputMode.value && - controller.hasInputText.value) - Padding( - padding: EdgeInsets.only(left: 8.w, bottom: 2.h), - child: GestureDetector( - onTap: controller.sendMessage, - behavior: HitTestBehavior.opaque, - child: Container( - width: 40.h, - height: 40.h, - decoration: const BoxDecoration( - color: Color(0xFF07C160), // 微信绿 - shape: BoxShape.circle, - ), - child: Icon( - Icons.arrow_upward_rounded, - size: 22.sp, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildTextField(Color fieldBg, Color textColor) { - return Container( - constraints: BoxConstraints(minHeight: 40.h, maxHeight: 120.h), - decoration: BoxDecoration( - color: fieldBg, - borderRadius: BorderRadius.circular(6.r), - ), - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - child: TextField( - controller: controller.textController, - style: TextStyle(fontSize: 15.sp, color: textColor), - minLines: 1, - maxLines: 4, - textInputAction: TextInputAction.send, - onSubmitted: (_) => controller.sendMessage(), - decoration: const InputDecoration( - isDense: true, - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - ), - ), - ); - } - - /// 按住说话:按下开始识别、松开结束。识别结果由 AgentService 事件回流成消息, - /// 与原来语音链路一致,这里只负责手势与视觉反馈。 - Widget _buildHoldToTalkButton(Color fieldBg, Color textColor) { - return GestureDetector( - onLongPressStart: (_) => controller.startVoiceInput(), - onLongPressEnd: (_) => controller.stopVoiceInput(), - onLongPressCancel: () => controller.stopVoiceInput(), - behavior: HitTestBehavior.opaque, - child: Obx(() { - final talking = controller.isListening.value; - return Container( - height: 40.h, - alignment: Alignment.center, - decoration: BoxDecoration( - color: talking ? const Color(0xFFD9D9D9) : fieldBg, - borderRadius: BorderRadius.circular(6.r), - ), - child: Text( - talking ? 'releaseToSend'.tr : 'holdToTalk'.tr, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w500, - color: talking ? Colors.black87 : textColor, - ), - ), - ); - }), - ); - } - - // 构建打电话模式覆盖层 - Widget _buildPhoneCallOverlay(BuildContext context, bool isDarkMode) { - return Container( - color: Colors.transparent, // 设置整个覆盖层为透明 - child: Column( - children: [ - // 中间空白区域 - 完全透明 - const Expanded( - child: SizedBox(), // 空的占位符,完全透明 - ), - - // 底部控制按钮 - 包含音频波纹 - Container( - color: isDarkMode ? Colors.black : Colors.white, - child: _buildPhoneCallControls(isDarkMode), - ), - ], - ), - ); - } - - // 构建打电话模式控制按钮 - Widget _buildPhoneCallControls(bool isDarkMode) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 24.h), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.center, // 确保垂直居中对齐 - children: [ - // 录音控制按钮(左侧) - 控制录音输入功能 - Obx(() => _buildPhoneControlButton( - icon: controller.isListening.value - ? Icons.mic // 录音中显示麦克风图标 - : Icons.mic_off, // 未录音显示麦克风关闭图标 - onTap: () => controller.togglePhoneCallRecording(), - isDarkMode: isDarkMode, - isActive: controller.isListening.value, - // backgroundColor: controller.isListening.value - // ? Colors.red // 录音中显示红色背景 - // : Theme.of(context).primaryColor, // 未录音显示主题色 - )), - - // 音频波纹组件(中间) - 仅显示动态波形效果,无其他UI元素 - Container( - width: 120.w, // 设置固定宽度 - height: 64.h, // 与按钮高度一致 - alignment: Alignment.center, // 确保波纹居中 - child: const AgentSoundBarsWidget(barCount: 12), // 适应宽度的波纹条数 - ), - - // 关闭打电话模式按钮(右侧) - 退出打电话模式 - _buildPhoneControlButton( - icon: Icons.close, - onTap: () => controller.endPhoneCall(), - isDarkMode: isDarkMode, - isActive: false, - //backgroundColor: Colors.red, - iconColor: Colors.redAccent, - ), - ], - ), - ); - } - - // 构建打电话控制按钮 - Widget _buildPhoneControlButton({ - required IconData icon, - required VoidCallback onTap, - required bool isDarkMode, - required bool isActive, - Color? backgroundColor, - Color? iconColor, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 64.w, // 按钮尺寸 - height: 64.h, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: backgroundColor ?? - (isDarkMode ? Colors.grey[800] : Colors.grey[250]), - border: Border.all(color: Colors.blue, width: 2.w), - boxShadow: [ - BoxShadow( - color: (backgroundColor ?? Colors.grey).withValues(alpha: 0.3), - blurRadius: 8.w, - spreadRadius: 1.w, - offset: Offset(0, 2.h), - ), - ], - ), - child: Icon( - icon, - color: iconColor ?? (isDarkMode ? Colors.white : Colors.black), - size: 28.sp, // 图标尺寸 - ), - ), - ); - } - - // ─── 设备通话覆盖层(接听后仅一个挂断按钮,悬浮在聊天界面之上)───────────────────── - Widget _buildDeviceCallOverlay(BuildContext context, bool isDarkMode) { - return Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: EdgeInsets.only(bottom: 16.h), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onTap: () => controller.endDeviceCall(), - child: Container( - width: 68.w, - height: 68.w, - decoration: BoxDecoration( - color: const Color(0xFFEF4444), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: const Color(0xFFEF4444).withValues(alpha: 0.35), - blurRadius: 16.w, - offset: Offset(0, 6.h), - ), - ], - ), - child: Transform.rotate( - angle: 2.356, - child: Icon( - Icons.call_end, - color: Colors.white, - size: 30.sp, - ), - ), - ), - ), - SizedBox(height: 8.h), - Text( - '挂断', - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.grey[300] : Colors.grey[700], - ), - ), - ], - ), - ), - ); - } - - // 构建加载更多指示器 - Widget _buildLoadMoreIndicator() { - return Obx( - () { - if (controller.isLoadingMore.value) { - // 正在加载状态 - return Container( - padding: EdgeInsets.symmetric(vertical: 16.h), - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 16.w, - height: 16.h, - child: CircularProgressIndicator( - strokeWidth: 2.w, - ), - ), - SizedBox(width: 8.w), - Text( - 'loadMoreMessages'.tr, - style: TextStyle( - fontSize: 14.sp, - color: Colors.grey, - ), - ), - ], - ), - ), - ); - } else if (controller.isLoadingMoreError.value) { - // 加载失败状态 - return Container( - padding: EdgeInsets.symmetric(vertical: 16.h), - child: Center( - child: GestureDetector( - onTap: () => controller.loadMoreMessages(), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.refresh, - size: 16.sp, - color: Colors.grey, - ), - SizedBox(width: 8.w), - Text( - 'loadFailedClickRetry'.tr, - style: TextStyle( - fontSize: 14.sp, - color: Colors.grey, - ), - ), - ], - ), - ), - ), - ); - } else { - // 默认状态(有更多消息但未加载) - return Container( - padding: EdgeInsets.symmetric(vertical: 8.h), - child: Center( - child: Text( - 'swipeUpForMore'.tr, - style: TextStyle( - fontSize: 12.sp, - color: Colors.grey, - ), - ), - ), - ); - } - }, - ); - } -} diff --git a/apps/client/lib/modules/agent/views/google_navigation_card.dart b/apps/client/lib/modules/agent/views/google_navigation_card.dart deleted file mode 100644 index 177d6832..00000000 --- a/apps/client/lib/modules/agent/views/google_navigation_card.dart +++ /dev/null @@ -1,447 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../controllers/agent_controller.dart'; - -String formatDistance(String distanceStr) { - final distance = double.tryParse(distanceStr); - if (distance == null) return distanceStr; // 如果无法解析,则返回原始字符串 - - if (distance >= 1000) { - final km = distance / 1000; - return '${km.toStringAsFixed(1)} KM'; - } else { - return '${distance.toStringAsFixed(0)} M'; - } -} - -String formatDuration(String secondsStr) { - int totalSeconds = int.tryParse(secondsStr) ?? 0; - Duration duration = Duration(seconds: totalSeconds); - - int hours = duration.inHours; - int minutes = duration.inMinutes.remainder(60); - int seconds = duration.inSeconds.remainder(60); - - List parts = []; - if (hours > 0) parts.add('$hours${'hours'.tr}'); // 小时 - if (minutes > 0) parts.add('$minutes${'minutes'.tr}'); // 分钟 - //不需要秒 - //if (seconds > 0 || parts.isEmpty) parts.add('$seconds${'seconds'.tr}'); // 秒 - - return parts.join(''); -} - -/// 导航卡片组件 -/// -/// 各元素含义分析: -/// - [start]: 起点坐标 (经纬度格式) -/// - [end]: 终点坐标 (经纬度格式) -/// - [destination]: 目的地名称显示 -/// - [modes]: 导航方式列表,包含驾车、步行、公交等选项 -/// - [navigation]: 当前导航状态信息(可选) -/// - [height]: 卡片高度 -Widget googlenavigationCard( - BuildContext context, { - required String start, - required String end, - required String destination, - required List> modes, - Map? navigation, - double? height, // 改为可选参数,允许自适应高度 -}) { - int currentIndex = 0; - final controller = Get.find(); - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Container( - height: height?.h ?? 350.h, // 如果没有指定高度,使用350作为默认高度 - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.r), - color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white, - boxShadow: [ - BoxShadow( - blurRadius: 12, - color: Colors.black.withValues(alpha: isDarkMode ? 0.4 : 0.08), - offset: const Offset(0, 2), - ), - ], - ), - child: StatefulBuilder( - builder: (context, setInnerState) { - return Column( - children: [ - // 顶部:路线预览图片区域 - Expanded( - flex: 4, // 增加图片区域的flex值,让图片占更多空间 - child: ClipRRect( - borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)), - child: Stack( - children: [ - // 路线图片 - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: Image.network( - modes[currentIndex]["image"] as String, - key: ValueKey( - modes[currentIndex]["ntype"] as String), - height: double.infinity, - width: double.infinity, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - color: isDarkMode - ? Colors.grey[800] - : Colors.grey[200], - child: Icon( - Icons.map_outlined, - size: 48.sp, - color: isDarkMode - ? Colors.grey[600] - : Colors.grey[400], - ), - ); - }, - ), - ), - // 导航状态覆盖层 - if (navigation != null) - Positioned( - top: 12.h, - left: 12.w, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 8.w, vertical: 4.h), - decoration: BoxDecoration( - color: - Theme.of(context).primaryColor.withOpacity(0.9), - borderRadius: BorderRadius.circular(12.r), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.navigation, - color: Colors.white, - size: 14.sp, - ), - SizedBox(width: 4.w), - Text( - 'navigating'.tr, // 导航中 - style: TextStyle( - color: Colors.white, - fontSize: 12.sp, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - - // 中部:导航方式选择器 - Container( - padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), - child: Row( - children: List.generate(modes.length, (index) { - final mode = modes[index]; - final isSelected = index == currentIndex; - final isEnabled = mode['enable'] as bool; - - return Expanded( - child: GestureDetector( - onTap: isEnabled - ? () { - setInnerState(() => currentIndex = index); - } - : null, - child: Container( - margin: EdgeInsets.symmetric(horizontal: 4.w), - padding: EdgeInsets.symmetric( - vertical: 8.h, horizontal: 12.w), - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).primaryColor.withOpacity(0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(8.r), - border: Border.all( - color: isSelected - ? Theme.of(context).primaryColor - : (isDarkMode - ? Colors.grey[700]! - : Colors.grey[300]!), - width: isSelected ? 2 : 1, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _getNavigationIcon(mode["ntype"] as String), - color: isEnabled - ? (isSelected - ? Theme.of(context).primaryColor - : (isDarkMode - ? Colors.white70 - : Colors.black87)) - : Colors.grey, - size: 20.sp, - ), - ], - ), - ), - ), - ); - }), - ), - ), - - // 底部:目的地信息和操作按钮 - Container( - padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 16.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 第一层:起点 → 目的地信息(独占整个宽度) - Row( - crossAxisAlignment: CrossAxisAlignment.center, // 确保垂直居中对齐 - children: [ - // 起点图标 - 统一图标容器宽度,确保对齐 - SizedBox( - width: 16.sp, // 固定图标容器宽度 - child: Icon( - Icons.my_location, - size: 15.sp, // 统一图标大小 - color: Theme.of(context).primaryColor, - ), - ), - SizedBox(width: 6.w), // 统一间距 - // 当前位置文本 - Text( - 'currentLocation'.tr, // 当前位置 - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white70 : Colors.black54, - height: 1.3, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - SizedBox(width: 8.w), - // 箭头图标 - Icon( - Icons.arrow_forward, - size: 15.sp, // 统一图标大小 - color: isDarkMode ? Colors.white54 : Colors.black45, - ), - SizedBox(width: 8.w), - // 目的地图标 - Icon( - Icons.location_on, - size: 15.sp, // 统一图标大小 - color: Colors.red, - ), - SizedBox(width: 6.w), // 统一间距 - // 目的地名称 - 使用Expanded确保自动换行 - Expanded( - child: Text( - destination, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black87, - height: 1.2, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - ), - ], - ), - - SizedBox(height: 4.h), // 第一层和第二层之间的间距 - - // 第二层:距离时间信息(左侧)+ 导航按钮(右侧) - Row( - crossAxisAlignment: CrossAxisAlignment.end, // 底部对齐 - children: [ - // 左侧:距离和时间信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 距离信息 - Row( - crossAxisAlignment: - CrossAxisAlignment.center, // 确保垂直居中对齐 - children: [ - // 距离图标 - 统一图标容器宽度,确保与第一层对齐 - SizedBox( - width: 16.sp, // 与第一层图标容器宽度一致 - child: Icon( - Icons.straighten, - size: 15.sp, // 统一图标大小 - color: Theme.of(context).primaryColor, - ), - ), - SizedBox(width: 6.w), // 与第一层间距一致 - Flexible( - child: Text( - formatDistance( - modes[currentIndex]["distance"]), - style: TextStyle( - fontSize: 14.sp, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - height: 1.3, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - ), - ], - ), - SizedBox(height: 4.h), - // 时间信息 - Row( - crossAxisAlignment: - CrossAxisAlignment.center, // 确保垂直居中对齐 - children: [ - // 时间图标 - 统一图标容器宽度,确保与第一层对齐 - SizedBox( - width: 16.sp, // 与第一层图标容器宽度一致 - child: Icon( - Icons.access_time, - size: 15.sp, // 统一图标大小 - color: Theme.of(context).primaryColor, - ), - ), - SizedBox(width: 6.w), // 与第一层间距一致 - Flexible( - child: Text( - '${'approximate'.tr}${formatDuration(modes[currentIndex]["duration"])}', // 约 - style: TextStyle( - fontSize: 14.sp, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - height: 1.3, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - ), - ], - ), - // 导航状态信息 - if (navigation != null) ...[ - SizedBox(height: 6.h), - Container( - padding: EdgeInsets.symmetric( - horizontal: 8.w, vertical: 4.h), - decoration: BoxDecoration( - color: Theme.of(context) - .primaryColor - .withOpacity(0.1), - borderRadius: BorderRadius.circular(6.r), - ), - child: Text( - "${navigation["distanceText"]}${'enterRoad'.tr}${navigation["nextRoad"]}", // 进入 - style: TextStyle( - fontSize: 12.sp, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - height: 1.3, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - ), - ], - ], - ), - ), - - SizedBox(width: 16.w), // 左右内容之间的间距 - - // 右侧:开始导航按钮 - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).primaryColor, - Theme.of(context).primaryColor.withOpacity(0.8), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Theme.of(context) - .primaryColor - .withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12.r), - onTap: () { - controller.openGoogleMap( - end, modes[currentIndex]["ntype"] as String); - }, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 16.w, - vertical: 12.h, - ), - child: Icon( - Icons.navigation, - color: Colors.white, - size: 20.sp, - ), - ), - ), - ), - ), - ], - ), - ], - ), - ), - ], - ); - }, - ), - ); -} - -/// 根据导航类型获取对应图标 -IconData _getNavigationIcon(String ntype) { - switch (ntype.toLowerCase()) { - case 'driving': - case '驾车': - return Icons.directions_car; - case 'walking': - case '步行': - return Icons.directions_walk; - case 'transit': - case 'tsransit': // 修正拼写错误 - case '公交': - return Icons.directions_transit; - case 'bicycling': // 修正为正确的英文值 - case 'cycling': - case '骑行': - return Icons.directions_bike; - default: - return Icons.navigation; - } -} diff --git a/apps/client/lib/modules/agent/views/message_bubble.dart b/apps/client/lib/modules/agent/views/message_bubble.dart deleted file mode 100644 index e23024f1..00000000 --- a/apps/client/lib/modules/agent/views/message_bubble.dart +++ /dev/null @@ -1,942 +0,0 @@ -import '/core/utils/logger.dart'; -import '/modules/agent/views/google_navigation_card.dart'; -import '/modules/agent/views/music_card.dart'; -import '/modules/agent/views/qqmusic_card.dart'; -import '/modules/agent/views/weather_baidu.dart'; -import '/modules/agent/views/weather_hefeng.dart'; - -import '../../../data/models/song_model.dart'; -import '../../../modules/agent/views/navigation_card.dart'; -import 'weather_card.dart'; -import 'travel_card.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; // 添加这个导入 -import 'package:get/get.dart'; -import 'package:intl/intl.dart'; -import 'dart:io'; -import 'package:flutter_markdown/flutter_markdown.dart'; - -import '../controllers/agent_controller.dart'; - -/// 消息气泡组件 -class MessageBubble extends GetView { - final String sessionid; - final bool isUser; - final String message; - final DateTime timestamp; - final bool isRecognizing; - final bool hasImage; - final String? imagePath; - final Map? meta; - final Map>? functionCalls; - final bool isToolCallsExpanded; - const MessageBubble({ - Key? key, - required this.sessionid, - required this.isUser, - required this.message, - required this.timestamp, - this.isRecognizing = false, - this.hasImage = false, - this.imagePath, - this.meta, - this.functionCalls, - this.isToolCallsExpanded = false, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: Column( - crossAxisAlignment: - isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - Stack( - children: [ - Container( - padding: (hasImage && message == '[图片]') - ? EdgeInsets.zero - : const EdgeInsets.all(12), - decoration: BoxDecoration( - color: _getBubbleColor(context), - borderRadius: BorderRadius.only( - topLeft: isUser - ? const Radius.circular(18) - : const Radius.circular(4), - topRight: isUser - ? const Radius.circular(4) - : const Radius.circular(18), - bottomLeft: const Radius.circular(18), - bottomRight: const Radius.circular(18), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: Padding( - padding: (controller.isShowToolInterruptEnabled.value && - !isUser && - functionCalls != null && - functionCalls!.isNotEmpty) - ? const EdgeInsets.only(top: 30) - : EdgeInsets.zero, - child: _buildMessageContent(context), - ), - ), - //工具调用流程显示(仅在AI消息且有functionCalls时显示) - if (controller.isShowToolInterruptEnabled.value && - !isUser && - functionCalls != null && - functionCalls!.isNotEmpty) - _buildFunctionCallsWidget(context), - ], - ), - - const SizedBox(height: 4), - // 识别中的消息不显示时间 - if (!isRecognizing) - Row( - mainAxisAlignment: isUser - ? MainAxisAlignment.end - : MainAxisAlignment.spaceBetween, - children: [ - // 时间戳(左对齐或右对齐) - Text( - _formatTimestamp(timestamp), - style: TextStyle( - fontSize: 8, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[400] - : Colors.grey[500], - fontWeight: FontWeight.w400, - ), - ), - // AI标识(仅在AI消息时显示) - if (!isUser) - Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - "generatedByAi".tr, // 由AI生成 - style: TextStyle( - fontSize: 8, - color: Colors.blue[600], - fontWeight: FontWeight.bold, - fontStyle: FontStyle.italic, - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } - - /// 获取气泡颜色 - Color _getBubbleColor(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - if (isUser) { - return isRecognizing - ? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色 - : Color(0xFF3B82F6); // 使用更明亮的蓝色 - } else { - return isDarkMode - ? Color(0xFF2A2A2A) - : Color(0xFFF1F5F9); // 根据主题调整AI消息背景色 - } - } - - /// 构建消息内容 - Widget _buildMessageContent(BuildContext context) { - // 如果是识别中的消息,添加特殊样式 - if (isRecognizing) { - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - message, - style: const TextStyle( - color: Colors.white, - fontStyle: FontStyle.italic, - fontSize: 14, - height: 1.4, - ), - // 允许自动换行,不再强制单行 - softWrap: true, - maxLines: null, - ), - ), - const SizedBox(width: 8), - _buildTypingIndicator(), - ], - ); - } - - if (!isUser && - message.isEmpty && - (functionCalls == null || - !controller.isShowToolInterruptEnabled.value) && - meta == null) { - return _buildTypingIndicator(); - } - - // 如果只有工具调用而没有文本内容,显示一个占位符确保气泡有足够宽度 - if (!isUser && - message.isEmpty && - functionCalls != null && - controller.isShowToolInterruptEnabled.value && - meta == null) { - return Container( - constraints: const BoxConstraints( - minWidth: 200, // 确保最小宽度 - minHeight: 20, // 确保最小高度 - ), - child: const SizedBox.shrink(), // 透明占位符 - ); - } - - // 如果是图片消息,显示图片 - if (hasImage && imagePath != null) { - // 如果消息是默认的[图片]标记,只显示图片 - if (message == '[图片]') { - return Container( - padding: const EdgeInsets.all(8), - child: GestureDetector( - onTap: () { - // 点击图片时打开图片查看器 - Get.find() - .openImageViewer(context, imagePath ?? ''); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - File('${controller.AppDir.value}$imagePath'), - width: 200, - height: 200, - fit: BoxFit.cover, - ), - ), - ), - ); - } - - // 如果消息是自定义文本,显示文本+图片 - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - message, - style: TextStyle( - color: isUser ? Colors.white : Colors.black87, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: () { - // 点击图片时打开图片查看器 - Get.find() - .openImageViewer(context, imagePath ?? ''); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - File('${controller.AppDir.value}$imagePath'), - width: 200, - height: 200, - fit: BoxFit.cover, - ), - ), - ), - ], - ); - } - - // 判断消息内容,显示特殊卡片 - if (!isUser && meta != null) { - if (meta!.keys.contains('music_searchandplay_songs')) { - final music_search_songs = - meta!['music_searchandplay_songs'] as Map; - final data = music_search_songs['data']; - if (data != null && data is List) { - final rawList = data as List? ?? []; - final playlist = - rawList.map((e) => Map.from(e)).toList(); - //音乐播放列表 - return buildMusicContent(context, playlist); - } - } else if (meta!.keys.contains('qqmusic_search_songs')) { - final qqmusic_search_songs = - meta!['qqmusic_search_songs'] as Map; - final data = qqmusic_search_songs['data']; - if (data != null && data is List) { - //qq音乐 - final rawList = data as List? ?? []; - final songList = rawList - .map((item) { - try { - return SongModel.fromJson(item); - } catch (e) { - Logger.e('QQMusicPlaylist', '解析歌曲数据失败: $e, 数据: $item'); - return null; - } - }) - .where((song) => song != null) - .cast() - .toList(); - //音乐播放列表 - return buildQQMusicContent(context, songList); - } - } else if (meta!.keys.contains('hefeng_weather')) { - //高德的 - final hefeng_weather = meta!['hefeng_weather'] as Map; - final weather_city = hefeng_weather['city']; - final weather_day = hefeng_weather['day']; - final weather_data = hefeng_weather['data']; - if (weather_city != null) { - final rawList = weather_data as List? ?? []; - final daylist = - rawList.map((e) => Map.from(e)).toList(); - // final now = daylist.isNotEmpty ? daylist[0] : {}; - final now = daylist.firstWhere( - (day) => day['fxDate'] == weather_day, - orElse: () => daylist.isNotEmpty ? daylist[0] : {}, - ); - //天气 - return hefengWeatherContent( - context, - weather_city, - now, - daylist, - ); - } - } else if (meta!.keys.contains('maps_weather')) { - //高德的 - final maps_weather = meta!['maps_weather'] as Map; - final city = maps_weather['city']; - if (city != null) { - final rawList = maps_weather['forecasts'] as List? ?? []; - final daylist = - rawList.map((e) => Map.from(e)).toList(); - final now = daylist.isNotEmpty ? daylist[0] : {}; - //天气 - return buildWeatherContent( - context, - city, - now, - daylist, - ); - } - } else if (meta!.keys.contains('map_weather')) { - //百度的 - final map_weather = meta!['map_weather'] as Map; - final status = map_weather['status']; - if (status == 0) { - final result = map_weather['result'] as Map; - final city = result["location"]["city"] as String? ?? ''; - final rawList = result['forecasts'] as List? ?? []; - final daylist = - rawList.map((e) => Map.from(e)).toList(); - final now = daylist.isNotEmpty ? daylist[0] : {}; - //天气 - return buildWeatherBaiduContent( - context, - city, - now, - daylist, - ); - } - } else if (meta!.keys.contains('card_travel')) { - final rawList = meta!['card_travel']['items'] as List? ?? []; - final travellist = - rawList.map((e) => Map.from(e)).toList(); - // 旅游 - return buildTravelContent(context, travellist); - } else if (meta!.keys.contains('maps_route_navigation')) { - final maps_route_navigation = meta!['maps_route_navigation']; - if (maps_route_navigation is Map) { - final card_navigation = - maps_route_navigation["card_navigation"] as Map; - final start = card_navigation['start'] as String? ?? ''; - final end = card_navigation['end'] as String? ?? ''; - final end_longitude = card_navigation["end_longitude"] as String; - final end_latitude = card_navigation["end_latitude"] as String; - final destination = card_navigation['destination'] as String? ?? ''; - final rawList = card_navigation['items'] as List? ?? null; - if (rawList != null) { - final list = - rawList.map((e) => Map.from(e)).toList(); - final rawNavigation = card_navigation['navigation']; - final Map? navigation = - rawNavigation is Map ? rawNavigation : null; - // print("导航实时数据 ${rawNavigation}"); - // 导航 - return navigationCard(context, - start: start, - end: end, - endlatitude: end_latitude, - endlongitude: end_longitude, - destination: destination, - modes: list, - navigation: navigation); - } - } - } else if (meta!.keys.contains('maps_google_route_navigation')) { - final maps_google_route_navigation = - meta!['maps_google_route_navigation']; - if (maps_google_route_navigation is Map) { - final data = maps_google_route_navigation['data']; - if (data != null) { - final origin = data['origin'] as String? ?? ''; - final destination = data['destination'] as String? ?? ''; - final end = data['end'] as String? ?? ''; - final rawList = data['items'] as List? ?? null; - if (rawList != null) { - final list = - rawList.map((e) => Map.from(e)).toList(); - final rawNavigation = data['navigation']; - final Map? navigation = - rawNavigation is Map ? rawNavigation : null; - // print("导航实时数据 ${rawNavigation}"); - // 导航 - return googlenavigationCard(context, - start: origin, - end: destination, - destination: end, - modes: list, - navigation: navigation); - } - } - } - } - - // if (message.contains('播放列表') || message.contains('音乐')) { - // return _buildMusicContent(context); - // } else if (message.contains('景点') || message.contains('旅游')) { - // return _buildTravelContent(context); - // } else if (message.contains('天气') || - // message.contains('气温') || - // message.contains('预报')) { - // return _buildWeatherContent(context); - // } - } - - // 创建消息内容Widget - Widget messageContent; - - // 使用Markdown渲染AI消息,用户消息仍然使用普通文本 - if (!isUser) { - messageContent = MarkdownBody( - data: message, - styleSheet: MarkdownStyleSheet( - p: TextStyle( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.9) - : Colors.black87, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - h1: TextStyle( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - h2: TextStyle( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - h3: TextStyle( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, - fontSize: 15, - fontWeight: FontWeight.bold, - ), - code: TextStyle( - backgroundColor: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[800] - : Colors.grey[200], - fontFamily: 'monospace', - fontSize: 13, - ), - codeblockDecoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[800] - : Colors.grey[200], - borderRadius: BorderRadius.circular(4), - ), - blockquote: TextStyle( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[400] - : Colors.grey[700], - fontSize: 14, - fontStyle: FontStyle.italic, - ), - blockquoteDecoration: BoxDecoration( - border: Border( - left: BorderSide( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[600]! - : Colors.grey[400]!, - width: 4, - ), - ), - ), - listBullet: TextStyle( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.9) - : Colors.black87, - fontSize: 14, - ), - ), - //selectable: true, - ); - } else { - // 用户消息仍然使用普通文本 - messageContent = Text( - message, - style: TextStyle( - color: isUser - ? Colors.white - : (Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.9) - : Colors.black87), - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ); - } - - // 如果是AI消息且有工具调用,确保最小宽度以防止工具调用列表越界 - if (!isUser && functionCalls != null && functionCalls!.isNotEmpty) { - return Container( - constraints: const BoxConstraints( - minWidth: 200, // 确保最小宽度,防止工具调用列表越界 - ), - child: messageContent, - ); - } - - return messageContent; - } - - /// 构建输入中的指示器 - Widget _buildTypingIndicator() { - return SizedBox( - width: 40, - height: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildDot(100), - _buildDot(300), - _buildDot(500), - ], - ), - ); - } - - /// 构建动画点 - Widget _buildDot(int milliseconds) { - return _AnimatedDot(delayMilliseconds: milliseconds); - } - - /// 智能格式化时间戳 - String _formatTimestamp(DateTime messageTime) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final yesterday = today.subtract(const Duration(days: 1)); - final messageDate = - DateTime(messageTime.year, messageTime.month, messageTime.day); - - // 计算时间差 - final difference = now.difference(messageTime); - - // 1分钟内显示"刚刚" - if (difference.inMinutes < 1) { - return 'justNow'.tr; // 刚刚 - } - - // 1小时内显示"X分钟前" - if (difference.inHours < 1) { - return 'minutesAgo' - .trParams({'minutes': difference.inMinutes.toString()}); // X分钟前 - } - - // 今天的消息显示时间 - if (messageDate == today) { - return DateFormat('HH:mm').format(messageTime); - } - - // 昨天的消息显示"昨天 HH:mm" - if (messageDate == yesterday) { - return 'yesterday'.tr + - ' ' + - DateFormat('HH:mm').format(messageTime); // 昨天 HH:mm - } - - // 本周内的消息显示"星期X HH:mm" - if (difference.inDays < 7) { - final weekday = _getWeekdayName(messageTime.weekday); - return weekday + ' ' + DateFormat('HH:mm').format(messageTime); - } - - // 今年的消息显示"MM-dd HH:mm" - if (messageTime.year == now.year) { - return DateFormat('MM-dd HH:mm').format(messageTime); - } - - // 其他情况显示完整日期"yyyy-MM-dd HH:mm" - return DateFormat('yyyy-MM-dd HH:mm').format(messageTime); - } - - /// 获取星期名称 - String _getWeekdayName(int weekday) { - switch (weekday) { - case 1: - return 'monday'.tr; // 星期一 - case 2: - return 'tuesday'.tr; // 星期二 - case 3: - return 'wednesday'.tr; // 星期三 - case 4: - return 'thursday'.tr; // 星期四 - case 5: - return 'friday'.tr; // 星期五 - case 6: - return 'saturday'.tr; // 星期六 - case 7: - return 'sunday'.tr; // 星期日 - default: - return ''; - } - } - - /// 构建工具调用流程组件 - Widget _buildFunctionCallsWidget(BuildContext context) { - return Positioned( - top: 0, - left: 0, - right: 0, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 0, vertical: 0), // 减少垂直padding - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 标题行 - Container( - height: 40, // 增加固定高度,给字体更多空间 - padding: EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.dark - ? const Color.fromARGB(255, 71, 68, 68)?.withOpacity(0.8) - : Colors.grey[100]?.withOpacity(0.9), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), - border: Border.all( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.1) - : Colors.black.withOpacity(0.1), - width: 0.5, - ), - ), - child: Row( - children: [ - Icon( - Icons.build, // 工具icon - size: 20, // 减小图标大小 - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.7) - : Colors.black54, - ), - const SizedBox(width: 4), - // 显示最后一个工具名称 - 可点击展开 - Expanded( - child: GestureDetector( - onTap: () async { - await controller.updateToolCallsExpanded( - sessionid, - !isToolCallsExpanded, - ); - }, - child: Text( - functionCalls != null && functionCalls!.isNotEmpty - ? functionCalls!.keys.last - : 'toolCall'.tr, - style: TextStyle( - fontSize: 16, // 适当增大字体 - color: - Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.7) - : Colors.black54, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - const SizedBox(width: 4), - // 拷贝按钮 - GestureDetector( - onTap: () { - _copyFunctionCallsToClipboard(); - }, - child: Container( - padding: const EdgeInsets.all(2), - child: Icon( - Icons.copy, - size: 20, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.7) - : Colors.black54, - ), - ), - ), - const SizedBox(width: 4), - // 展开按钮 - GestureDetector( - onTap: () async { - await controller.updateToolCallsExpanded( - sessionid, - !isToolCallsExpanded, - ); - }, - child: Container( - padding: const EdgeInsets.all(2), // 增加 padding 从 2 到 8 - child: Icon( - isToolCallsExpanded - ? Icons.keyboard_arrow_down // 展开时显示向上箭头 - : Icons.keyboard_arrow_up, // 收起时显示向下箭头 - size: 20, // 增加图标大小从 14 到 16 - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.7) - : Colors.black54, - ), - ), - ), - ], - ), - ), - // 展开的工具调用列表 - if (isToolCallsExpanded && functionCalls != null) - Container( - margin: const EdgeInsets.only(top: 2), // 减少顶部间距 - padding: const EdgeInsets.all(6), // 减少内边距 - constraints: const BoxConstraints( - maxHeight: 120, // 限制最大高度,避免超界 - ), - decoration: BoxDecoration( - // 添加更强的背景色来遮挡下层UI - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[900]?.withOpacity(0.95) // 深色模式下使用深灰色背景 - : Colors.white.withOpacity(0.95), // 浅色模式下使用白色背景 - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(6), - bottomRight: Radius.circular(6), - ), - border: Border.all( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.2) - : Colors.black.withOpacity(0.2), - width: 0.5, - ), - // 添加阴影增强遮挡效果 - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: SingleChildScrollView( - // 添加滚动,防止超界 - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: functionCalls!.entries.map((entry) { - String toolName = entry.key; - dynamic toolResult = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: 8), // 减少间距 - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 工具名称 - 固定宽度 - SizedBox( - width: 80, // 固定宽度 - child: Text( - toolName, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).brightness == - Brightness.dark - ? Colors.white.withOpacity(0.6) - : Colors.black45, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), // 增加间距 - // 工具结果 - 剩余空间 - Expanded( - child: Text( - toolResult?.toString() ?? 'noResult'.tr, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).brightness == - Brightness.dark - ? Colors.white.withOpacity(0.8) - : Colors.black87, - height: 1.1, // 减小行高 - ), - ), - ), - ], - ), - ); - }).toList(), - ), - ), - ), - ], - ), - )); - } - - /// 拷贝工具调用内容到剪贴板 - void _copyFunctionCallsToClipboard() { - if (functionCalls == null || functionCalls!.isEmpty) { - Get.snackbar( - 'tip'.tr, - 'noToolCallContentToCopy'.tr, - snackPosition: SnackPosition.BOTTOM, - duration: const Duration(seconds: 2), - ); - return; - } - - StringBuffer buffer = StringBuffer(); - buffer.writeln('toolCallDetails'.tr); - buffer.writeln('=' * 30); - - functionCalls!.forEach((toolName, toolData) { - buffer.writeln('🔧 工具名称: $toolName'); - if (toolData is Map) { - toolData.forEach((key, value) { - buffer.writeln(' $key: $value'); - }); - } else { - buffer.writeln(' 结果: $toolData'); - } - buffer.writeln('-' * 20); - }); - - Clipboard.setData(ClipboardData(text: buffer.toString())); - - Get.snackbar( - 'success'.tr, - 'toolCallContentCopied'.tr, - snackPosition: SnackPosition.BOTTOM, - duration: const Duration(seconds: 2), - ); - } -} - -/// 动画点组件 -class _AnimatedDot extends StatefulWidget { - final int delayMilliseconds; - - const _AnimatedDot({required this.delayMilliseconds}); - - @override - _AnimatedDotState createState() => _AnimatedDotState(); -} - -class _AnimatedDotState extends State<_AnimatedDot> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _animation; - - @override - void initState() { - super.initState(); - - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - ); - - _animation = Tween(begin: 0, end: 6).animate( - CurvedAnimation( - parent: _controller, - curve: Interval(0.0, 0.5, curve: Curves.easeOut), - reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn), - ), - )..addListener(() { - setState(() {}); - }); - - Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () { - if (mounted) { - _controller.repeat(); - } - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(3), - boxShadow: [ - BoxShadow( - color: Colors.white.withOpacity(0.5), - blurRadius: _animation.value, - spreadRadius: _animation.value / 2, - ), - ], - ), - ); - } -} diff --git a/apps/client/lib/modules/agent/views/music_card.dart b/apps/client/lib/modules/agent/views/music_card.dart deleted file mode 100644 index b60025ee..00000000 --- a/apps/client/lib/modules/agent/views/music_card.dart +++ /dev/null @@ -1,275 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../../../routes/app_routes.dart'; -import '../controllers/agent_controller.dart'; -import '../widgets/circular_progress_button.dart'; - -/// 构建音乐项目 -Widget buildMusicItem( - BuildContext context, int id, String name, String singer, String image) { - final controller = Get.find(); // 获取控制器 - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - Map song = { - 'id': id, - 'title': name, - 'artist': singer, - 'coverUrl': image, - }; - - return Container( - padding: EdgeInsets.all(14.w), - decoration: BoxDecoration( - color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03), - blurRadius: 3, - spreadRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), - child: Row( - children: [ - Container( - width: 50.w, - height: 50.h, - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.grey[100], - borderRadius: BorderRadius.circular(8.r), - ), - child: Stack( - children: [ - // 主图片 - Container( - width: 50.w, - height: 50.h, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.r), - image: image.isNotEmpty - ? DecorationImage( - image: NetworkImage(image), - fit: BoxFit.cover, - ) - : null, - ), - child: image.isEmpty - ? Icon( - Icons.music_note, - size: 18.sp, - color: Theme.of(context).primaryColor, - ) - : null, - ), - // 右下角音乐图标 - if (image.isNotEmpty) - Positioned( - right: 2.w, - bottom: 2.w, - child: Container( - width: 16.w, - height: 16.w, - // decoration: BoxDecoration( - // color: Colors.white, - // borderRadius: BorderRadius.circular(2.r), - // boxShadow: [ - // BoxShadow( - // color: Colors.black.withOpacity(0.2), - // blurRadius: 1, - // offset: const Offset(0, 0.5), - // ), - // ], - // ), - child: ClipRRect( - borderRadius: BorderRadius.circular(2.r), - child: Image.asset( - 'assets/images/qqmusic.png', - fit: BoxFit.contain, - ), - ), - ), - ), - ], - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w500, - color: isDarkMode - ? Colors.white.withValues(alpha: 0.9) - : Colors.grey[800], - ), - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 2.h), - Text( - singer, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Obx(() { - final isThisPlaying = - controller.isPlaying.value && controller.currentId.value == id; - - // 只有当前播放的歌曲才显示进度 - final currentProgress = - isThisPlaying ? controller.progress.value : 0.0; - - return CircularProgressButton( - isPlaying: isThisPlaying, - progress: currentProgress, - primaryColor: Theme.of(context).primaryColor, - backgroundColor: Colors.grey, - size: 36.0, - strokeWidth: 2.0, - onPlayPause: () async { - try { - if (controller.isPlaying.value) { - if (controller.currentId.value == id) { - // 当前歌曲正在播放,暂停它 - await controller.musicPause(); - } else { - // 其他歌曲正在播放,播放这首歌 - await controller.musicPlay(song); - } - } else { - if (controller.currentId.value == id) { - // 当前歌曲已暂停,恢复播放 - await controller.musicResume(); - } else { - // 播放新歌曲 - await controller.musicPlay(song); - } - } - } catch (e) { - // 静默处理错误,避免影响用户体验 - } - }, - onSeek: isThisPlaying - ? (seekProgress) { - // 只有当前播放的歌曲才允许跳转 - final targetPosition = - (controller.duration.value * seekProgress).round(); - controller.seekToPosition(targetPosition); - } - : null, - ); - }), - ], - ), - ); -} - -/// 构建音乐内容卡片 -Widget buildMusicContent( - BuildContext context, List> playlist) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final controller = Get.find(); // 获取控制器 - controller.updateCurrentPlaylistContext(playlist, controller.currentId.value); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'recommendedMusic'.tr, //以下是一些推荐的音乐: - style: TextStyle( - color: - isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 18.w, - height: 18.w, - child: Image.asset( - 'assets/images/qqmusic.png', - fit: BoxFit.contain, - ), - ), - const SizedBox(width: 6), - Text( - 'playList'.tr, //播放列表 - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[300] - : Colors.grey[700], - ), - ), - ], - ), - const SizedBox(height: 10), - - // 动态生成音乐条目 - ...playlist.asMap().entries.take(4).map((entry) { - final index = entry.key; - final item = entry.value; - final id = item["id"]; - final name = item["name"] ?? "未知歌曲"; - final singer = item["singer"] ?? "未知歌手"; - final image = item["image"] ?? ""; - return Column( - children: [ - buildMusicItem(context, id, name, singer, image), - if (index != playlist.length - 1) - Divider( - height: 1, - thickness: 1, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[800] - : Colors.grey[100], - ), - ], - ); - }).toList(), - const SizedBox(height: 8), - Center( - child: TextButton( - onPressed: () { - // 跳转到完整歌单页面,传递完整的歌单数据 - Get.toNamed(Routes.musicPlaylist, arguments: playlist); - }, - style: TextButton.styleFrom( - minimumSize: Size.zero, - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - 'moreMusic'.tr, //更多音乐, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - ], - ); -} diff --git a/apps/client/lib/modules/agent/views/navigation_card.dart b/apps/client/lib/modules/agent/views/navigation_card.dart deleted file mode 100644 index 31b900a5..00000000 --- a/apps/client/lib/modules/agent/views/navigation_card.dart +++ /dev/null @@ -1,472 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../controllers/agent_controller.dart'; - -String formatDistance(String distanceStr) { - final distance = double.tryParse(distanceStr); - if (distance == null) return distanceStr; // 如果无法解析,则返回原始字符串 - - if (distance >= 1000) { - final km = distance / 1000; - return '${km.toStringAsFixed(1)} KM'; - } else { - return '${distance.toStringAsFixed(0)} M'; - } -} - -String formatDuration(String secondsStr) { - int totalSeconds = int.tryParse(secondsStr) ?? 0; - Duration duration = Duration(seconds: totalSeconds); - - int hours = duration.inHours; - int minutes = duration.inMinutes.remainder(60); - int seconds = duration.inSeconds.remainder(60); - - List parts = []; - if (hours > 0) parts.add('$hours${'hours'.tr}'); // 小时 - if (minutes > 0) parts.add('$minutes${'minutes'.tr}'); // 分钟 - //不需要秒 - //if (seconds > 0 || parts.isEmpty) parts.add('$seconds${'seconds'.tr}'); // 秒 - - return parts.join(''); -} - -/// 导航卡片组件 -/// -/// 各元素含义分析: -/// - [start]: 起点坐标 (经纬度格式) -/// - [end]: 终点坐标 (经纬度格式) -/// - [destination]: 目的地名称显示 -/// - [modes]: 导航方式列表,包含驾车、步行、公交等选项 -/// - [navigation]: 当前导航状态信息(可选) -/// - [height]: 卡片高度 -Widget navigationCard( - BuildContext context, { - required String start, - required String end, - required String endlatitude, - required String endlongitude, - required String destination, - required List> modes, - Map? navigation, - double? height, // 改为可选参数,允许自适应高度 -}) { - int currentIndex = 0; - final controller = Get.find(); - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Container( - height: height?.h ?? 350.h, // 如果没有指定高度,使用350作为默认高度 - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.r), - color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white, - boxShadow: [ - BoxShadow( - blurRadius: 12, - color: Colors.black.withValues(alpha: isDarkMode ? 0.4 : 0.08), - offset: const Offset(0, 2), - ), - ], - ), - child: StatefulBuilder( - builder: (context, setInnerState) { - return Column( - children: [ - // 顶部:路线预览图片区域 - Expanded( - flex: 4, // 增加图片区域的flex值,让图片占更多空间 - child: ClipRRect( - borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)), - child: Stack( - children: [ - // 路线图片 - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: Image.network( - modes[currentIndex]["image"] as String, - key: ValueKey( - modes[currentIndex]["ntype"] as String), - height: double.infinity, - width: double.infinity, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - color: isDarkMode - ? Colors.grey[800] - : Colors.grey[200], - child: Icon( - Icons.map_outlined, - size: 48.sp, - color: isDarkMode - ? Colors.grey[600] - : Colors.grey[400], - ), - ); - }, - ), - ), - // 导航状态覆盖层 - if (navigation != null) - Positioned( - top: 12.h, - left: 12.w, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 8.w, vertical: 4.h), - decoration: BoxDecoration( - color: - Theme.of(context).primaryColor.withOpacity(0.9), - borderRadius: BorderRadius.circular(12.r), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.navigation, - color: Colors.white, - size: 14.sp, - ), - SizedBox(width: 4.w), - Text( - 'navigating'.tr, // 导航中 - style: TextStyle( - color: Colors.white, - fontSize: 12.sp, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - - // 中部:导航方式选择器 - Container( - padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), - child: Row( - children: List.generate(modes.length, (index) { - final mode = modes[index]; - final isSelected = index == currentIndex; - final isEnabled = mode['enable'] as bool; - - return Expanded( - child: GestureDetector( - onTap: isEnabled - ? () { - setInnerState(() => currentIndex = index); - } - : null, - child: Container( - margin: EdgeInsets.symmetric(horizontal: 4.w), - padding: EdgeInsets.symmetric( - vertical: 8.h, horizontal: 12.w), - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).primaryColor.withOpacity(0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(8.r), - border: Border.all( - color: isSelected - ? Theme.of(context).primaryColor - : (isDarkMode - ? Colors.grey[700]! - : Colors.grey[300]!), - width: isSelected ? 2 : 1, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _getNavigationIcon(mode["ntype"] as String), - color: isEnabled - ? (isSelected - ? Theme.of(context).primaryColor - : (isDarkMode - ? Colors.white70 - : Colors.black87)) - : Colors.grey, - size: 20.sp, - ), - // SizedBox(height: 4.h), - // Text( - // (mode["ntype"] as String).tr, - // textAlign: TextAlign.center, - // style: TextStyle( - // color: isEnabled - // ? (isSelected - // ? Theme.of(context).primaryColor - // : (isDarkMode - // ? Colors.white70 - // : Colors.black87)) - // : Colors.grey, - // fontSize: 11.sp, - // fontWeight: isSelected - // ? FontWeight.w600 - // : FontWeight.w400, - // ), - // ), - ], - ), - ), - ), - ); - }), - ), - ), - - // 底部:目的地信息和操作按钮 - Container( - padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 16.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 第一层:起点 → 目的地信息(独占整个宽度) - Row( - crossAxisAlignment: CrossAxisAlignment.center, // 确保垂直居中对齐 - children: [ - // 起点图标 - 统一图标容器宽度,确保对齐 - SizedBox( - width: 16.sp, // 固定图标容器宽度 - child: Icon( - Icons.my_location, - size: 15.sp, // 统一图标大小 - color: Theme.of(context).primaryColor, - ), - ), - SizedBox(width: 6.w), // 统一间距 - // 当前位置文本 - Text( - 'currentLocation'.tr, // 当前位置 - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white70 : Colors.black54, - height: 1.3, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - SizedBox(width: 8.w), - // 箭头图标 - Icon( - Icons.arrow_forward, - size: 15.sp, // 统一图标大小 - color: isDarkMode ? Colors.white54 : Colors.black45, - ), - SizedBox(width: 8.w), - // 目的地图标 - Icon( - Icons.location_on, - size: 15.sp, // 统一图标大小 - color: Colors.red, - ), - SizedBox(width: 6.w), // 统一间距 - // 目的地名称 - 使用Expanded确保自动换行 - Expanded( - child: Text( - destination, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black87, - height: 1.2, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - ), - ], - ), - - SizedBox(height: 4.h), // 第一层和第二层之间的间距 - - // 第二层:距离时间信息(左侧)+ 导航按钮(右侧) - Row( - crossAxisAlignment: CrossAxisAlignment.end, // 底部对齐 - children: [ - // 左侧:距离和时间信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 距离信息 - Row( - crossAxisAlignment: - CrossAxisAlignment.center, // 确保垂直居中对齐 - children: [ - // 距离图标 - 统一图标容器宽度,确保与第一层对齐 - SizedBox( - width: 16.sp, // 与第一层图标容器宽度一致 - child: Icon( - Icons.straighten, - size: 15.sp, // 统一图标大小 - color: Theme.of(context).primaryColor, - ), - ), - SizedBox(width: 6.w), // 与第一层间距一致 - Flexible( - child: Text( - formatDistance( - modes[currentIndex]["distance"]), - style: TextStyle( - fontSize: 14.sp, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - height: 1.3, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - ), - ], - ), - SizedBox(height: 4.h), - // 时间信息 - Row( - crossAxisAlignment: - CrossAxisAlignment.center, // 确保垂直居中对齐 - children: [ - // 时间图标 - 统一图标容器宽度,确保与第一层对齐 - SizedBox( - width: 16.sp, // 与第一层图标容器宽度一致 - child: Icon( - Icons.access_time, - size: 15.sp, // 统一图标大小 - color: Theme.of(context).primaryColor, - ), - ), - SizedBox(width: 6.w), // 与第一层间距一致 - Flexible( - child: Text( - '${'approximate'.tr}${formatDuration(modes[currentIndex]["duration"])}', // 约 - style: TextStyle( - fontSize: 14.sp, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - height: 1.3, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - ), - ], - ), - // 导航状态信息 - if (navigation != null) ...[ - SizedBox(height: 6.h), - Container( - padding: EdgeInsets.symmetric( - horizontal: 8.w, vertical: 4.h), - decoration: BoxDecoration( - color: Theme.of(context) - .primaryColor - .withOpacity(0.1), - borderRadius: BorderRadius.circular(6.r), - ), - child: Text( - "${navigation["distanceText"]}${'enterRoad'.tr}${navigation["nextRoad"]}", // 进入 - style: TextStyle( - fontSize: 12.sp, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - height: 1.3, // 设置行高,提升多行文本可读性 - ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - ), - ), - ], - ], - ), - ), - - SizedBox(width: 16.w), // 左右内容之间的间距 - - // 右侧:开始导航按钮 - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).primaryColor, - Theme.of(context).primaryColor.withOpacity(0.8), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Theme.of(context) - .primaryColor - .withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12.r), - onTap: () { - controller.openNavigation( - true, - start, - destination, - endlatitude, - endlongitude, - modes[currentIndex]["ntype"] as String); - }, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 16.w, - vertical: 12.h, - ), - child: Icon( - Icons.navigation, - color: Colors.white, - size: 20.sp, - ), - ), - ), - ), - ), - ], - ), - ], - ), - ), - ], - ); - }, - ), - ); -} - -/// 根据导航类型获取对应图标 -IconData _getNavigationIcon(String ntype) { - switch (ntype.toLowerCase()) { - case 'driving': - case '驾车': - return Icons.directions_car; - case 'walking': - case '步行': - return Icons.directions_walk; - case 'transit': - case 'tsransit': // 修正拼写错误 - case '公交': - return Icons.directions_transit; - case 'bicycling': // 修正为正确的英文值 - case 'cycling': - case '骑行': - return Icons.directions_bike; - default: - return Icons.navigation; - } -} diff --git a/apps/client/lib/modules/agent/views/qqmusic_card.dart b/apps/client/lib/modules/agent/views/qqmusic_card.dart deleted file mode 100644 index 5e4086c9..00000000 --- a/apps/client/lib/modules/agent/views/qqmusic_card.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '/modules/agent/widgets/circular_progress_button.dart'; -import '../../../routes/app_routes.dart'; -import '../../../data/services/qq_music.dart'; -import '../../../data/models/song_model.dart'; - -/// 构建音乐项目 -Widget buildQQMusicItem( - BuildContext context, List songs, SongModel song, int index) { - final qqMusicService = QqMusicService.to; // 使用QQ音乐全局服务 - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Container( - padding: EdgeInsets.all(14.w), - decoration: BoxDecoration( - color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03), - blurRadius: 3, - spreadRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), - child: Row( - children: [ - Container( - width: 50.w, - height: 50.h, - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.grey[100], - borderRadius: BorderRadius.circular(8.r), - ), - child: Stack( - children: [ - // 主图片 - Container( - width: 50.w, - height: 50.h, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.r), - image: song.albumPic!.isNotEmpty - ? DecorationImage( - image: NetworkImage(song.albumPic!), - fit: BoxFit.cover, - ) - : null, - ), - child: song.albumPic!.isEmpty - ? Icon( - Icons.music_note, - size: 18.sp, - color: Theme.of(context).primaryColor, - ) - : null, - ), - // 右下角音乐图标 - if (song.albumPic!.isNotEmpty) - Positioned( - right: 0.w, - bottom: 0.w, - child: Container( - width: 12.w, - height: 12.w, - child: Image.asset( - 'assets/images/qqmusic.png', - fit: BoxFit.contain, - ), - ), - ), - ], - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - song.name, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w500, - color: isDarkMode - ? Colors.white.withValues(alpha: 0.9) - : Colors.grey[800], - ), - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 2.h), - Text( - song.artist, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Obx(() { - // 使用QQ音乐服务的全局状态 - final isThisPlaying = qqMusicService.isPlaying.value && - qqMusicService.currentSong.value?.mid == song.mid; - - // 计算播放进度 - final currentProgress = - isThisPlaying && qqMusicService.totalDuration.value > 0 - ? qqMusicService.currentPosition.value / - qqMusicService.totalDuration.value - : 0.0; - - return CircularProgressButton( - isPlaying: isThisPlaying, - progress: currentProgress, - primaryColor: Theme.of(context).primaryColor, - backgroundColor: Colors.grey, - size: 36.0, - strokeWidth: 2.0, - onPlayPause: () { - try { - if (qqMusicService.isPlaying.value) { - if (qqMusicService.currentSong.value?.mid == song.mid) { - // 当前歌曲正在播放,暂停它 - qqMusicService.pauseMusic(); - } else { - final songMids = songs.map((s) => s.mid).toList(); - // 其他歌曲正在播放,播放这首歌 - qqMusicService.playSongs( - songMids: songMids, - index: index, - songList: [song], - currentSongModel: song, - ); - } - } else { - if (qqMusicService.currentSong.value?.mid == song.mid) { - // 当前歌曲已暂停,恢复播放 - qqMusicService.resumeMusic(); - } else { - final songMids = songs.map((s) => s.mid).toList(); - // 播放新歌曲 - qqMusicService.playSongs( - songMids: songMids, - index: index, - songList: [song], - currentSongModel: song, - ); - } - } - } catch (e) { - // 静默处理错误,避免影响用户体验 - print('播放控制失败: $e'); - } - }, - onSeek: isThisPlaying - ? (seekProgress) { - // QQ音乐暂不支持拖拽进度,可以在这里添加相关逻辑 - // 或者显示提示信息 - print('QQ音乐暂不支持拖拽进度'); - } - : null, - ); - }), - ], - ), - ); -} - -/// 构建音乐内容卡片 -Widget buildQQMusicContent(BuildContext context, List songs) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'recommendedMusic'.tr, //以下是一些推荐的音乐: - style: TextStyle( - color: - isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 18.w, - height: 18.w, - child: Image.asset( - 'assets/images/qqmusic.png', - fit: BoxFit.contain, - ), - ), - const SizedBox(width: 6), - Text( - 'playList'.tr, //播放列表 - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[300] - : Colors.grey[700], - ), - ), - ], - ), - const SizedBox(height: 10), - - // 动态生成音乐条目 - ...songs.asMap().entries.take(4).map((entry) { - final index = entry.key; - final item = entry.value; - return Column( - children: [ - buildQQMusicItem(context, songs, item, index), - if (index != songs.length - 1) - Divider( - height: 1, - thickness: 1, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[800] - : Colors.grey[100], - ), - ], - ); - }).toList(), - const SizedBox(height: 8), - Center( - child: TextButton( - onPressed: () { - // 跳转到完整歌单页面,传递完整的歌单数据 - Get.toNamed(Routes.qqmusic, arguments: {"playlist": songs}); - }, - style: TextButton.styleFrom( - minimumSize: Size.zero, - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - 'moreMusic'.tr, //更多音乐, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - ], - ); -} diff --git a/apps/client/lib/modules/agent/views/travel_card.dart b/apps/client/lib/modules/agent/views/travel_card.dart deleted file mode 100644 index be62ccda..00000000 --- a/apps/client/lib/modules/agent/views/travel_card.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; - -/// 构建旅游内容卡片 -Widget buildTravelContent( - BuildContext context, List> travellist) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '以下是一些推荐搜索结果:', - style: TextStyle( - color: isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87, - fontSize: 14.sp, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - SizedBox(height: 12.h), - Container( - padding: EdgeInsets.all(12.w), - decoration: BoxDecoration( - color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03), - blurRadius: 3, - spreadRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Row( - // children: [ - // Icon( - // Icons.place, - // size: 16, - // color: Color(0xFFE11D48), - // ), - // const SizedBox(width: 6), - // Text( - // '热门景点 · 东京', - // style: TextStyle( - // fontSize: 13, - // fontWeight: FontWeight.w600, - // color: Colors.grey[700], - // ), - // ), - // ], - // ), - // const SizedBox(height: 10), - - // 动态生成旅游景点条目 - ...travellist.asMap().entries.map((entry) { - final index = entry.key; - final item = entry.value; - final name = item["name"] ?? "未知景点"; //名字 - final addr = item["addr"] ?? ""; //地址 - // final tag = item["tag"] ?? "未知标签"; //标签 - // final rating = item["rating"] ?? ""; //评分 - // final distance = item["distance"] ?? ""; //距离 - // final images = item["images"] ?? []; // 图片列表 - return Column( - children: [ - buildLocationItem( - context, - name, - addr, - ), - if (index != travellist.length - 1) - Divider( - height: 1.h, - thickness: 1, - color: isDarkMode ? Colors.grey[700] : Colors.grey[100] - ), - ], - ); - }).toList(), - // // 景点列表 - // _buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context), - // Divider(height: 1, thickness: 1, color: Colors.grey[100]), - // _buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context), - // Divider(height: 1, thickness: 1, color: Colors.grey[100]), - // _buildLocationItem('浅草寺', '东京最古老的寺庙', context), - - SizedBox(height: 8.h), - Center( - child: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - minimumSize: Size.zero, - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - '查看更多景点', - style: TextStyle( - fontSize: 12.sp, - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - ), - SizedBox(height: 10.h), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - icon: Icon(Icons.map, size: 16.sp), - label: const Text('查看地图'), - style: OutlinedButton.styleFrom( - foregroundColor: Theme.of(context).primaryColor, - side: BorderSide( - color: Theme.of(context).primaryColor.withValues(alpha: 0.5)), - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - textStyle: TextStyle(fontSize: 12.sp), - ), - ), - ), - SizedBox(width: 8.w), - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - icon: Icon(Icons.bookmark_border, size: 16.sp), - label: const Text('保存到收藏'), - style: OutlinedButton.styleFrom( - foregroundColor: isDarkMode ? Colors.grey[400] : Colors.grey[700], - side: BorderSide(color: isDarkMode ? Colors.grey[600]! : Colors.grey[400]!), - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - textStyle: TextStyle(fontSize: 12.sp), - ), - ), - ), - ], - ), - ], - ); -} - -/// 构建地点项目 -Widget buildLocationItem( - BuildContext context, String name, String description) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Padding( - padding: EdgeInsets.symmetric(vertical: 8.h), - child: Row( - children: [ - Container( - width: 38.w, - height: 38.h, - decoration: BoxDecoration( - color: isDarkMode - ? const Color(0xFF2A2A2A) - : Theme.of(context).primaryColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8.r), - ), - child: Icon( - Icons.place, - size: 18.sp, - color: Theme.of(context).primaryColor, - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w500, - color: isDarkMode - ? Colors.white.withValues(alpha: 0.9) - : Colors.grey[800], - ), - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 2.h), - Text( - description, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - IconButton( - onPressed: () {}, - icon: Icon( - Icons.arrow_forward, - color: isDarkMode ? Colors.grey[500] : Colors.grey[400] - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - iconSize: 20.sp, - ), - ], - ), - ); -} diff --git a/apps/client/lib/modules/agent/views/weather_baidu.dart b/apps/client/lib/modules/agent/views/weather_baidu.dart deleted file mode 100644 index 585eae20..00000000 --- a/apps/client/lib/modules/agent/views/weather_baidu.dart +++ /dev/null @@ -1,562 +0,0 @@ -import 'package:common_utils/common_utils.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -/// 构建天气内容卡片 -Widget buildWeatherBaiduContent(BuildContext context, String city, - Map now, List> days) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'weatherQuery'.tr, // 翻译键代表的中文文本内容:查询天气情况: - style: TextStyle( - color: - isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87, - fontSize: 14.sp, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - SizedBox(height: 12.h), - Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - gradient: _getWeatherGradient(now['text_day'] ?? '', isDarkMode), - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDarkMode ? 0.3 : 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "${city} ${now["date"]}", - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - SizedBox(height: 4.w), - Text( - _getWeatherDescriptionTranslation(now["text_day"]), - style: TextStyle( - fontSize: 14.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - SizedBox(height: 8.h), - Row( - children: [ - Text( - "${now["high"]}°", - style: TextStyle( - fontSize: 36.sp, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - SizedBox(width: 8.w), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${'highest'.tr}: ${now["high"]}°', // 翻译键代表的中文文本内容:最高 - style: TextStyle( - fontSize: 12.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - Text( - '${'lowest'.tr}: ${now["low"]}°', // 翻译键代表的中文文本内容:最低 - style: TextStyle( - fontSize: 12.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - ], - ), - ], - ), - ], - ), - Icon( - getWeatherIcon(now['text_day']), - size: 48.sp, - color: Colors.white, - ), - ], - ), - SizedBox(height: 16.h), - Container( - height: 1, - color: Colors.white.withValues(alpha: isDarkMode ? 0.15 : 0.2), - ), - SizedBox(height: 16.h), - Text( - 'futureForecast'.tr, // 翻译键代表的中文文本内容:未来预报 - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - SizedBox(height: 8.h), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate( - days.length, - (index) { - String dateStr = days[index]['date']; - DateTime? date = DateUtil.getDateTime(dateStr); - IconData weatherIcon = - getWeatherIcon(days[index]['text_day']); - - // 获取星期几的翻译键 - String weekdayKey = _getWeekdayTranslationKey(date); - String weekday = weekdayKey.tr; // 翻译键代表的中文文本内容:对应的星期几 - - String temp = "${days[index]['high']}°"; - return _buildForecastDay( - weekday, - weatherIcon, - temp, - ); - }, - ), - ), - SizedBox(height: 16.h), - _buildWeatherTip(context, now, isDarkMode), - ], - ), - ), - ], - ); -} - -/// 获取天气描述的翻译 -String _getWeatherDescriptionTranslation(String weatherDescription) { - final description = weatherDescription.trim(); - - // 天气描述映射表 - final Map weatherMap = { - // 晴天类 - '晴': 'sunny'.tr, // 翻译键代表的中文文本内容:晴 - '少云': 'partlyCloudy'.tr, // 翻译键代表的中文文本内容:少云 - '晴间多云': 'partlySunny'.tr, // 翻译键代表的中文文本内容:晴间多云 - - // 多云类 - '多云': 'cloudy'.tr, // 翻译键代表的中文文本内容:多云 - '阴': 'overcast'.tr, // 翻译键代表的中文文本内容:阴 - - // 雨天类 - '阵雨': 'shower'.tr, // 翻译键代表的中文文本内容:阵雨 - '雷阵雨': 'thundershower'.tr, // 翻译键代表的中文文本内容:雷阵雨 - '小雨': 'lightRain'.tr, // 翻译键代表的中文文本内容:小雨 - '中雨': 'moderateRain'.tr, // 翻译键代表的中文文本内容:中雨 - '大雨': 'heavyRain'.tr, // 翻译键代表的中文文本内容:大雨 - '暴雨': 'rainstorm'.tr, // 翻译键代表的中文文本内容:暴雨 - - // 雪天类 - '阵雪': 'snowShower'.tr, // 翻译键代表的中文文本内容:阵雪 - '小雪': 'lightSnow'.tr, // 翻译键代表的中文文本内容:小雪 - '中雪': 'moderateSnow'.tr, // 翻译键代表的中文文本内容:中雪 - '大雪': 'heavySnow'.tr, // 翻译键代表的中文文本内容:大雪 - '暴雪': 'blizzard'.tr, // 翻译键代表的中文文本内容:暴雪 - - // 雾霾类 - '雾': 'fog'.tr, // 翻译键代表的中文文本内容:雾 - '霾': 'haze'.tr, // 翻译键代表的中文文本内容:霾 - - // 风类 - '大风': 'strongWind'.tr, // 翻译键代表的中文文本内容:大风 - }; - - // 返回翻译后的描述,如果没有找到对应翻译则返回原文 - return weatherMap[description] ?? description; -} - -/// 获取星期几的翻译键 -String _getWeekdayTranslationKey(DateTime? date) { - if (date == null) return 'unknown'; - - switch (date.weekday) { - case 1: - return 'monday'; // 翻译键代表的中文文本内容:周一 - case 2: - return 'tuesday'; // 翻译键代表的中文文本内容:周二 - case 3: - return 'wednesday'; // 翻译键代表的中文文本内容:周三 - case 4: - return 'thursday'; // 翻译键代表的中文文本内容:周四 - case 5: - return 'friday'; // 翻译键代表的中文文本内容:周五 - case 6: - return 'saturday'; // 翻译键代表的中文文本内容:周六 - case 7: - return 'sunday'; // 翻译键代表的中文文本内容:周日 - default: - return 'unknown'; - } -} - -/// 构建智能天气提示 -Widget _buildWeatherTip( - BuildContext context, Map now, bool isDarkMode) { - final weatherTip = _generateWeatherTip(now); - - return Container( - padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 12.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.15) - : Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8.r), - ), - child: Row( - children: [ - Icon( - weatherTip.icon, - size: 16.sp, - color: Colors.white, - ), - SizedBox(width: 8.w), - Flexible( - child: Text( - weatherTip.message, - style: TextStyle( - fontSize: 12.sp, - color: Colors.white, - ), - ), - ), - ], - ), - ); -} - -/// 天气提示数据类 -class WeatherTip { - final IconData icon; - final String message; - - const WeatherTip({required this.icon, required this.message}); -} - -/// 根据天气数据生成智能提示 -WeatherTip _generateWeatherTip(Map now) { - final dayWeather = (now['dayweather'] as String? ?? '').trim(); - final dayTemp = int.tryParse(now['daytemp']?.toString() ?? '0') ?? 0; - final nightTemp = int.tryParse(now['nighttemp']?.toString() ?? '0') ?? 0; - - // 高温提示 (>= 35°C) - if (dayTemp >= 35) { - return WeatherTip( - icon: Icons.local_fire_department, - message: 'hotWeatherTip'.tr, // 翻译键代表的中文文本内容:今日高温炎热,请注意防暑降温,多喝水避免中暑 - ); - } - - // 低温提示 (<= 5°C) - if (dayTemp <= 5) { - return WeatherTip( - icon: Icons.ac_unit, - message: 'coldWeatherTip'.tr, // 翻译键代表的中文文本内容:今日气温较低,外出请注意保暖,小心感冒 - ); - } - - // 雨天提示 - if (dayWeather.contains('雨') || - ['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨'] - .any((rain) => dayWeather.contains(rain))) { - return WeatherTip( - icon: Icons.umbrella, - message: 'rainyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雨,外出请携带雨具,注意出行安全 - ); - } - - // 雪天提示 - if (dayWeather.contains('雪') || - ['阵雪', '小雪', '中雪', '大雪', '暴雪'].any((snow) => dayWeather.contains(snow))) { - return WeatherTip( - icon: Icons.ac_unit, - message: 'snowyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雪,路面湿滑请注意安全,做好保暖措施 - ); - } - - // 雾霾提示 - if (dayWeather.contains('雾') || - dayWeather.contains('霾') || - ['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => dayWeather.contains(fog))) { - return WeatherTip( - icon: Icons.blur_on, - message: 'foggyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雾霾,能见度较低,外出请佩戴口罩 - ); - } - - // 大风提示 - if (dayWeather.contains('风') || - ['大风', '强风', '疾风', '烈风'].any((wind) => dayWeather.contains(wind))) { - return WeatherTip( - icon: Icons.air, - message: 'windyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日风力较大,外出请注意安全,固定好物品 - ); - } - - // 晴天紫外线提示 (温度 >= 25°C) - if (['晴', '少云', '晴间多云'].any((sunny) => dayWeather.contains(sunny)) && - dayTemp >= 25) { - return WeatherTip( - icon: Icons.wb_sunny, - message: 'sunnyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日阳光充足紫外线较强,外出请做好防晒 - ); - } - - // 温差大提示 (>= 10°C) - if ((dayTemp - nightTemp).abs() >= 10) { - return WeatherTip( - icon: Icons.thermostat, - message: 'temperatureDifferenceTip'.tr, // 翻译键代表的中文文本内容:今日昼夜温差较大,请适时增减衣物 - ); - } - - // 默认舒适天气提示 - return WeatherTip( - icon: Icons.wb_sunny_outlined, - message: 'comfortableWeatherTip'.tr, // 翻译键代表的中文文本内容:今日天气适宜,是外出活动的好时光 - ); -} - -/// 构建单日天气预报 -Widget _buildForecastDay(String day, IconData icon, String temp) { - return Column( - children: [ - Text( - day, - style: TextStyle( - fontSize: 12.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - SizedBox(height: 4.h), - Icon( - icon, - size: 20.sp, - color: Colors.white, - ), - SizedBox(height: 4.h), - Text( - temp, - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ], - ); -} - -/// 获取天气图标 -IconData getWeatherIcon(String description) { - description = description.trim(); - - if (["晴", "少云", "晴间多云"].contains(description)) { - return Icons.wb_sunny; - } - - if (["多云", "阴"].contains(description)) { - return Icons.cloud; - } - - if (description.contains("雾") || - ["雾", "浓雾", "强浓雾", "轻雾", "大雾", "特强浓雾", "霾", "中度霾", "重度霾", "严重霾"] - .contains(description)) { - return Icons.blur_on; - } - - if (description.contains("风") || - [ - "有风", - "平静", - "微风", - "和风", - "清风", - "强风/劲风", - "疾风", - "大风", - "烈风", - "风暴", - "狂爆风", - "飓风", - "热带风暴" - ].contains(description)) { - return Icons.air; - } - - if (description.contains("雨") || - [ - "阵雨", - "雷阵雨", - "雷阵雨并伴有冰雹", - "小雨", - "中雨", - "大雨", - "暴雨", - "大暴雨", - "特大暴雨", - "强阵雨", - "强雷阵雨", - "极端降雨", - "毛毛雨/细雨", - "雨", - "小雨-中雨", - "中雨-大雨", - "大雨-暴雨", - "暴雨-大暴雨", - "大暴雨-特大暴雨" - ].contains(description)) { - return Icons.grain; // 或使用 community icon 包:weather_icons 中的 rain - } - - if (description.contains("雪") || - [ - "雨雪天气", - "雨夹雪", - "阵雨夹雪", - "冻雨", - "雪", - "阵雪", - "小雪", - "中雪", - "大雪", - "暴雪", - "小雪-中雪", - "中雪-大雪", - "大雪-暴雪" - ].contains(description)) { - return Icons.ac_unit; - } - - if (["浮尘", "扬沙", "沙尘暴", "强沙尘暴"].contains(description)) { - return Icons.filter_drama; - } - - if (["龙卷风"].contains(description)) { - return Icons.waves; - } - - if (["热"].contains(description)) { - return Icons.local_fire_department; - } - - if (["冷"].contains(description)) { - return Icons.ac_unit; - } - - // 默认或未知天气 - return Icons.help_outline; -} - -/// 根据天气类型获取背景渐变颜色 -LinearGradient _getWeatherGradient(String weather, bool isDarkMode) { - final weatherType = weather.trim(); - - // 晴天 - 温暖的橙黄色 - if (['晴', '少云', '晴间多云'].any((sunny) => weatherType.contains(sunny))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFFFF8A00), Color(0xFFE65100)] // 深色模式:深橙色 - : [Color(0xFFFFB74D), Color(0xFFFF9800)], // 浅色模式:亮橙色 - ); - } - - // 多云/阴天 - 灰蓝色 - if (['多云', '阴'].any((cloudy) => weatherType.contains(cloudy))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF455A64), Color(0xFF37474F)] // 深色模式:深灰蓝 - : [Color(0xFF78909C), Color(0xFF607D8B)], // 浅色模式:灰蓝色 - ); - } - - // 雨天 - 深蓝色 - if (weatherType.contains('雨') || - ['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨'] - .any((rain) => weatherType.contains(rain))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF1565C0), Color(0xFF0D47A1)] // 深色模式:深蓝色 - : [Color(0xFF42A5F5), Color(0xFF1976D2)], // 浅色模式:亮蓝色 - ); - } - - // 雪天 - 冷色调蓝白色 - if (weatherType.contains('雪') || - ['阵雪', '小雪', '中雪', '大雪', '暴雪'] - .any((snow) => weatherType.contains(snow))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF0277BD), Color(0xFF01579B)] // 深色模式:深冷蓝 - : [Color(0xFF81D4FA), Color(0xFF29B6F6)], // 浅色模式:浅蓝色 - ); - } - - // 雾霾天 - 暗黄灰色 - if (weatherType.contains('雾') || - weatherType.contains('霾') || - ['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => weatherType.contains(fog))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF5D4037), Color(0xFF3E2723)] // 深色模式:深棕色 - : [Color(0xFF8D6E63), Color(0xFF6D4C41)], // 浅色模式:棕灰色 - ); - } - - // 大风天 - 青绿色 - if (weatherType.contains('风') || - ['大风', '强风', '疾风', '烈风'].any((wind) => weatherType.contains(wind))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF00695C), Color(0xFF004D40)] // 深色模式:深青色 - : [Color(0xFF26A69A), Color(0xFF00796B)], // 浅色模式:青绿色 - ); - } - - // 默认 - 原来的蓝色 - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF1E40AF), Color(0xFF1E3A8A)] - : [Color(0xFF60A5FA), Color(0xFF3B82F6)], - ); -} diff --git a/apps/client/lib/modules/agent/views/weather_card.dart b/apps/client/lib/modules/agent/views/weather_card.dart deleted file mode 100644 index ce502699..00000000 --- a/apps/client/lib/modules/agent/views/weather_card.dart +++ /dev/null @@ -1,562 +0,0 @@ -import 'package:common_utils/common_utils.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -/// 构建天气内容卡片 -Widget buildWeatherContent(BuildContext context, String city, - Map now, List> days) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'weatherQuery'.tr, // 翻译键代表的中文文本内容:查询天气情况: - style: TextStyle( - color: - isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87, - fontSize: 14.sp, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - SizedBox(height: 12.h), - Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - gradient: _getWeatherGradient(now['dayweather'] ?? '', isDarkMode), - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDarkMode ? 0.3 : 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "${city} ${now["date"]}", - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - SizedBox(height: 4.w), - Text( - _getWeatherDescriptionTranslation(now["dayweather"]), - style: TextStyle( - fontSize: 14.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - SizedBox(height: 8.h), - Row( - children: [ - Text( - "${now["daytemp"]}°", - style: TextStyle( - fontSize: 36.sp, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - SizedBox(width: 8.w), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${'highest'.tr}: ${now["daytemp"]}°', // 翻译键代表的中文文本内容:最高 - style: TextStyle( - fontSize: 12.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - Text( - '${'lowest'.tr}: ${now["nighttemp"]}°', // 翻译键代表的中文文本内容:最低 - style: TextStyle( - fontSize: 12.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - ], - ), - ], - ), - ], - ), - Icon( - getWeatherIcon(now['dayweather']), - size: 48.sp, - color: Colors.white, - ), - ], - ), - SizedBox(height: 16.h), - Container( - height: 1, - color: Colors.white.withValues(alpha: isDarkMode ? 0.15 : 0.2), - ), - SizedBox(height: 16.h), - Text( - 'futureForecast'.tr, // 翻译键代表的中文文本内容:未来预报 - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - SizedBox(height: 8.h), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate( - days.length, - (index) { - String dateStr = days[index]['date']; - DateTime? date = DateUtil.getDateTime(dateStr); - IconData weatherIcon = - getWeatherIcon(days[index]['dayweather']); - - // 获取星期几的翻译键 - String weekdayKey = _getWeekdayTranslationKey(date); - String weekday = weekdayKey.tr; // 翻译键代表的中文文本内容:对应的星期几 - - String temp = "${days[index]['daytemp']}°"; - return _buildForecastDay( - weekday, - weatherIcon, - temp, - ); - }, - ), - ), - SizedBox(height: 16.h), - _buildWeatherTip(context, now, isDarkMode), - ], - ), - ), - ], - ); -} - -/// 获取天气描述的翻译 -String _getWeatherDescriptionTranslation(String weatherDescription) { - final description = weatherDescription.trim(); - - // 天气描述映射表 - final Map weatherMap = { - // 晴天类 - '晴': 'sunny'.tr, // 翻译键代表的中文文本内容:晴 - '少云': 'partlyCloudy'.tr, // 翻译键代表的中文文本内容:少云 - '晴间多云': 'partlySunny'.tr, // 翻译键代表的中文文本内容:晴间多云 - - // 多云类 - '多云': 'cloudy'.tr, // 翻译键代表的中文文本内容:多云 - '阴': 'overcast'.tr, // 翻译键代表的中文文本内容:阴 - - // 雨天类 - '阵雨': 'shower'.tr, // 翻译键代表的中文文本内容:阵雨 - '雷阵雨': 'thundershower'.tr, // 翻译键代表的中文文本内容:雷阵雨 - '小雨': 'lightRain'.tr, // 翻译键代表的中文文本内容:小雨 - '中雨': 'moderateRain'.tr, // 翻译键代表的中文文本内容:中雨 - '大雨': 'heavyRain'.tr, // 翻译键代表的中文文本内容:大雨 - '暴雨': 'rainstorm'.tr, // 翻译键代表的中文文本内容:暴雨 - - // 雪天类 - '阵雪': 'snowShower'.tr, // 翻译键代表的中文文本内容:阵雪 - '小雪': 'lightSnow'.tr, // 翻译键代表的中文文本内容:小雪 - '中雪': 'moderateSnow'.tr, // 翻译键代表的中文文本内容:中雪 - '大雪': 'heavySnow'.tr, // 翻译键代表的中文文本内容:大雪 - '暴雪': 'blizzard'.tr, // 翻译键代表的中文文本内容:暴雪 - - // 雾霾类 - '雾': 'fog'.tr, // 翻译键代表的中文文本内容:雾 - '霾': 'haze'.tr, // 翻译键代表的中文文本内容:霾 - - // 风类 - '大风': 'strongWind'.tr, // 翻译键代表的中文文本内容:大风 - }; - - // 返回翻译后的描述,如果没有找到对应翻译则返回原文 - return weatherMap[description] ?? description; -} - -/// 获取星期几的翻译键 -String _getWeekdayTranslationKey(DateTime? date) { - if (date == null) return 'unknown'; - - switch (date.weekday) { - case 1: - return 'monday'; // 翻译键代表的中文文本内容:周一 - case 2: - return 'tuesday'; // 翻译键代表的中文文本内容:周二 - case 3: - return 'wednesday'; // 翻译键代表的中文文本内容:周三 - case 4: - return 'thursday'; // 翻译键代表的中文文本内容:周四 - case 5: - return 'friday'; // 翻译键代表的中文文本内容:周五 - case 6: - return 'saturday'; // 翻译键代表的中文文本内容:周六 - case 7: - return 'sunday'; // 翻译键代表的中文文本内容:周日 - default: - return 'unknown'; - } -} - -/// 构建智能天气提示 -Widget _buildWeatherTip( - BuildContext context, Map now, bool isDarkMode) { - final weatherTip = _generateWeatherTip(now); - - return Container( - padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 12.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.15) - : Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8.r), - ), - child: Row( - children: [ - Icon( - weatherTip.icon, - size: 16.sp, - color: Colors.white, - ), - SizedBox(width: 8.w), - Flexible( - child: Text( - weatherTip.message, - style: TextStyle( - fontSize: 12.sp, - color: Colors.white, - ), - ), - ), - ], - ), - ); -} - -/// 天气提示数据类 -class WeatherTip { - final IconData icon; - final String message; - - const WeatherTip({required this.icon, required this.message}); -} - -/// 根据天气数据生成智能提示 -WeatherTip _generateWeatherTip(Map now) { - final dayWeather = (now['dayweather'] as String? ?? '').trim(); - final dayTemp = int.tryParse(now['daytemp']?.toString() ?? '0') ?? 0; - final nightTemp = int.tryParse(now['nighttemp']?.toString() ?? '0') ?? 0; - - // 高温提示 (>= 35°C) - if (dayTemp >= 35) { - return WeatherTip( - icon: Icons.local_fire_department, - message: 'hotWeatherTip'.tr, // 翻译键代表的中文文本内容:今日高温炎热,请注意防暑降温,多喝水避免中暑 - ); - } - - // 低温提示 (<= 5°C) - if (dayTemp <= 5) { - return WeatherTip( - icon: Icons.ac_unit, - message: 'coldWeatherTip'.tr, // 翻译键代表的中文文本内容:今日气温较低,外出请注意保暖,小心感冒 - ); - } - - // 雨天提示 - if (dayWeather.contains('雨') || - ['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨'] - .any((rain) => dayWeather.contains(rain))) { - return WeatherTip( - icon: Icons.umbrella, - message: 'rainyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雨,外出请携带雨具,注意出行安全 - ); - } - - // 雪天提示 - if (dayWeather.contains('雪') || - ['阵雪', '小雪', '中雪', '大雪', '暴雪'].any((snow) => dayWeather.contains(snow))) { - return WeatherTip( - icon: Icons.ac_unit, - message: 'snowyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雪,路面湿滑请注意安全,做好保暖措施 - ); - } - - // 雾霾提示 - if (dayWeather.contains('雾') || - dayWeather.contains('霾') || - ['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => dayWeather.contains(fog))) { - return WeatherTip( - icon: Icons.blur_on, - message: 'foggyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雾霾,能见度较低,外出请佩戴口罩 - ); - } - - // 大风提示 - if (dayWeather.contains('风') || - ['大风', '强风', '疾风', '烈风'].any((wind) => dayWeather.contains(wind))) { - return WeatherTip( - icon: Icons.air, - message: 'windyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日风力较大,外出请注意安全,固定好物品 - ); - } - - // 晴天紫外线提示 (温度 >= 25°C) - if (['晴', '少云', '晴间多云'].any((sunny) => dayWeather.contains(sunny)) && - dayTemp >= 25) { - return WeatherTip( - icon: Icons.wb_sunny, - message: 'sunnyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日阳光充足紫外线较强,外出请做好防晒 - ); - } - - // 温差大提示 (>= 10°C) - if ((dayTemp - nightTemp).abs() >= 10) { - return WeatherTip( - icon: Icons.thermostat, - message: 'temperatureDifferenceTip'.tr, // 翻译键代表的中文文本内容:今日昼夜温差较大,请适时增减衣物 - ); - } - - // 默认舒适天气提示 - return WeatherTip( - icon: Icons.wb_sunny_outlined, - message: 'comfortableWeatherTip'.tr, // 翻译键代表的中文文本内容:今日天气适宜,是外出活动的好时光 - ); -} - -/// 构建单日天气预报 -Widget _buildForecastDay(String day, IconData icon, String temp) { - return Column( - children: [ - Text( - day, - style: TextStyle( - fontSize: 12.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - SizedBox(height: 4.h), - Icon( - icon, - size: 20.sp, - color: Colors.white, - ), - SizedBox(height: 4.h), - Text( - temp, - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ], - ); -} - -/// 获取天气图标 -IconData getWeatherIcon(String description) { - description = description.trim(); - - if (["晴", "少云", "晴间多云"].contains(description)) { - return Icons.wb_sunny; - } - - if (["多云", "阴"].contains(description)) { - return Icons.cloud; - } - - if (description.contains("雾") || - ["雾", "浓雾", "强浓雾", "轻雾", "大雾", "特强浓雾", "霾", "中度霾", "重度霾", "严重霾"] - .contains(description)) { - return Icons.blur_on; - } - - if (description.contains("风") || - [ - "有风", - "平静", - "微风", - "和风", - "清风", - "强风/劲风", - "疾风", - "大风", - "烈风", - "风暴", - "狂爆风", - "飓风", - "热带风暴" - ].contains(description)) { - return Icons.air; - } - - if (description.contains("雨") || - [ - "阵雨", - "雷阵雨", - "雷阵雨并伴有冰雹", - "小雨", - "中雨", - "大雨", - "暴雨", - "大暴雨", - "特大暴雨", - "强阵雨", - "强雷阵雨", - "极端降雨", - "毛毛雨/细雨", - "雨", - "小雨-中雨", - "中雨-大雨", - "大雨-暴雨", - "暴雨-大暴雨", - "大暴雨-特大暴雨" - ].contains(description)) { - return Icons.grain; // 或使用 community icon 包:weather_icons 中的 rain - } - - if (description.contains("雪") || - [ - "雨雪天气", - "雨夹雪", - "阵雨夹雪", - "冻雨", - "雪", - "阵雪", - "小雪", - "中雪", - "大雪", - "暴雪", - "小雪-中雪", - "中雪-大雪", - "大雪-暴雪" - ].contains(description)) { - return Icons.ac_unit; - } - - if (["浮尘", "扬沙", "沙尘暴", "强沙尘暴"].contains(description)) { - return Icons.filter_drama; - } - - if (["龙卷风"].contains(description)) { - return Icons.waves; - } - - if (["热"].contains(description)) { - return Icons.local_fire_department; - } - - if (["冷"].contains(description)) { - return Icons.ac_unit; - } - - // 默认或未知天气 - return Icons.help_outline; -} - -/// 根据天气类型获取背景渐变颜色 -LinearGradient _getWeatherGradient(String weather, bool isDarkMode) { - final weatherType = weather.trim(); - - // 晴天 - 温暖的橙黄色 - if (['晴', '少云', '晴间多云'].any((sunny) => weatherType.contains(sunny))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFFFF8A00), Color(0xFFE65100)] // 深色模式:深橙色 - : [Color(0xFFFFB74D), Color(0xFFFF9800)], // 浅色模式:亮橙色 - ); - } - - // 多云/阴天 - 灰蓝色 - if (['多云', '阴'].any((cloudy) => weatherType.contains(cloudy))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF455A64), Color(0xFF37474F)] // 深色模式:深灰蓝 - : [Color(0xFF78909C), Color(0xFF607D8B)], // 浅色模式:灰蓝色 - ); - } - - // 雨天 - 深蓝色 - if (weatherType.contains('雨') || - ['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨'] - .any((rain) => weatherType.contains(rain))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF1565C0), Color(0xFF0D47A1)] // 深色模式:深蓝色 - : [Color(0xFF42A5F5), Color(0xFF1976D2)], // 浅色模式:亮蓝色 - ); - } - - // 雪天 - 冷色调蓝白色 - if (weatherType.contains('雪') || - ['阵雪', '小雪', '中雪', '大雪', '暴雪'] - .any((snow) => weatherType.contains(snow))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF0277BD), Color(0xFF01579B)] // 深色模式:深冷蓝 - : [Color(0xFF81D4FA), Color(0xFF29B6F6)], // 浅色模式:浅蓝色 - ); - } - - // 雾霾天 - 暗黄灰色 - if (weatherType.contains('雾') || - weatherType.contains('霾') || - ['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => weatherType.contains(fog))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF5D4037), Color(0xFF3E2723)] // 深色模式:深棕色 - : [Color(0xFF8D6E63), Color(0xFF6D4C41)], // 浅色模式:棕灰色 - ); - } - - // 大风天 - 青绿色 - if (weatherType.contains('风') || - ['大风', '强风', '疾风', '烈风'].any((wind) => weatherType.contains(wind))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF00695C), Color(0xFF004D40)] // 深色模式:深青色 - : [Color(0xFF26A69A), Color(0xFF00796B)], // 浅色模式:青绿色 - ); - } - - // 默认 - 原来的蓝色 - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF1E40AF), Color(0xFF1E3A8A)] - : [Color(0xFF60A5FA), Color(0xFF3B82F6)], - ); -} diff --git a/apps/client/lib/modules/agent/views/weather_hefeng.dart b/apps/client/lib/modules/agent/views/weather_hefeng.dart deleted file mode 100644 index 537e715f..00000000 --- a/apps/client/lib/modules/agent/views/weather_hefeng.dart +++ /dev/null @@ -1,481 +0,0 @@ -import 'package:common_utils/common_utils.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:get/get.dart'; - -/// 构建天气内容卡片 -Widget hefengWeatherContent(BuildContext context, String city, - Map now, List> days) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'weatherQuery'.tr, // 翻译键代表的中文文本内容:查询天气情况: - style: TextStyle( - color: - isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87, - fontSize: 14.sp, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - SizedBox(height: 12.h), - Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - gradient: _getWeatherGradient(now['textDay'] ?? '', isDarkMode), - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDarkMode ? 0.3 : 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "${city} ${now["fxDate"]}", - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - SizedBox(height: 4.w), - Text( - _getWeatherDescriptionTranslation(now["textDay"]), - style: TextStyle( - fontSize: 14.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - SizedBox(height: 8.h), - Row( - children: [ - Text( - "${now["tempMax"]}°", - style: TextStyle( - fontSize: 36.sp, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - SizedBox(width: 8.w), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${'highest'.tr}: ${now["tempMax"]}°', // 翻译键代表的中文文本内容:最高 - style: TextStyle( - fontSize: 12.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - Text( - '${'lowest'.tr}: ${now["tempMin"]}°', // 翻译键代表的中文文本内容:最低 - style: TextStyle( - fontSize: 12.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - ], - ), - ], - ), - ], - ), - getWeatherSvgIcon( - now['iconDay'], - size: 48.sp, - ), - ], - ), - SizedBox(height: 16.h), - Container( - height: 1, - color: Colors.white.withValues(alpha: isDarkMode ? 0.15 : 0.2), - ), - SizedBox(height: 16.h), - Text( - 'futureForecast'.tr, // 翻译键代表的中文文本内容:未来预报 - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - SizedBox(height: 8.h), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate( - days.length, - (index) { - String dateStr = days[index]['fxDate']; - DateTime? date = DateUtil.getDateTime(dateStr); - Widget weatherIcon = - getWeatherSvgIcon(days[index]['iconDay'], size: 20.sp); - - // 获取星期几的翻译键 - String weekdayKey = _getWeekdayTranslationKey(date); - String weekday = weekdayKey.tr; // 翻译键代表的中文文本内容:对应的星期几 - - String temp = "${days[index]['tempMax']}°"; - return _buildForecastDay( - weekday, - weatherIcon, - temp, - ); - }, - ), - ), - SizedBox(height: 16.h), - _hefengWeatherTip(context, now, isDarkMode), - ], - ), - ), - ], - ); -} - -/// 获取天气描述的翻译 -String _getWeatherDescriptionTranslation(String weatherDescription) { - final description = weatherDescription.trim(); - - // 天气描述映射表 - final Map weatherMap = { - // 晴天类 - '晴': 'sunny'.tr, // 翻译键代表的中文文本内容:晴 - '少云': 'partlyCloudy'.tr, // 翻译键代表的中文文本内容:少云 - '晴间多云': 'partlySunny'.tr, // 翻译键代表的中文文本内容:晴间多云 - - // 多云类 - '多云': 'cloudy'.tr, // 翻译键代表的中文文本内容:多云 - '阴': 'overcast'.tr, // 翻译键代表的中文文本内容:阴 - - // 雨天类 - '阵雨': 'shower'.tr, // 翻译键代表的中文文本内容:阵雨 - '雷阵雨': 'thundershower'.tr, // 翻译键代表的中文文本内容:雷阵雨 - '小雨': 'lightRain'.tr, // 翻译键代表的中文文本内容:小雨 - '中雨': 'moderateRain'.tr, // 翻译键代表的中文文本内容:中雨 - '大雨': 'heavyRain'.tr, // 翻译键代表的中文文本内容:大雨 - '暴雨': 'rainstorm'.tr, // 翻译键代表的中文文本内容:暴雨 - - // 雪天类 - '阵雪': 'snowShower'.tr, // 翻译键代表的中文文本内容:阵雪 - '小雪': 'lightSnow'.tr, // 翻译键代表的中文文本内容:小雪 - '中雪': 'moderateSnow'.tr, // 翻译键代表的中文文本内容:中雪 - '大雪': 'heavySnow'.tr, // 翻译键代表的中文文本内容:大雪 - '暴雪': 'blizzard'.tr, // 翻译键代表的中文文本内容:暴雪 - - // 雾霾类 - '雾': 'fog'.tr, // 翻译键代表的中文文本内容:雾 - '霾': 'haze'.tr, // 翻译键代表的中文文本内容:霾 - - // 风类 - '大风': 'strongWind'.tr, // 翻译键代表的中文文本内容:大风 - }; - - // 返回翻译后的描述,如果没有找到对应翻译则返回原文 - return weatherMap[description] ?? description; -} - -/// 获取星期几的翻译键 -String _getWeekdayTranslationKey(DateTime? date) { - if (date == null) return 'unknown'; - - switch (date.weekday) { - case 1: - return 'monday'; // 翻译键代表的中文文本内容:周一 - case 2: - return 'tuesday'; // 翻译键代表的中文文本内容:周二 - case 3: - return 'wednesday'; // 翻译键代表的中文文本内容:周三 - case 4: - return 'thursday'; // 翻译键代表的中文文本内容:周四 - case 5: - return 'friday'; // 翻译键代表的中文文本内容:周五 - case 6: - return 'saturday'; // 翻译键代表的中文文本内容:周六 - case 7: - return 'sunday'; // 翻译键代表的中文文本内容:周日 - default: - return 'unknown'; - } -} - -/// 构建智能天气提示 -Widget _hefengWeatherTip( - BuildContext context, Map now, bool isDarkMode) { - final weatherTip = _generateWeatherTip(now); - - return Container( - padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 12.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.15) - : Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8.r), - ), - child: Row( - children: [ - Icon( - weatherTip.icon, - size: 16.sp, - color: Colors.white, - ), - SizedBox(width: 8.w), - Flexible( - child: Text( - weatherTip.message, - style: TextStyle( - fontSize: 12.sp, - color: Colors.white, - ), - ), - ), - ], - ), - ); -} - -/// 天气提示数据类 -class WeatherTip { - final IconData icon; - final String message; - - const WeatherTip({required this.icon, required this.message}); -} - -/// 根据天气数据生成智能提示 -WeatherTip _generateWeatherTip(Map now) { - final dayWeather = (now['textDay'] as String? ?? '').trim(); - final dayTemp = int.tryParse(now['tempMax']?.toString() ?? '0') ?? 0; - final nightTemp = int.tryParse(now['tempMin']?.toString() ?? '0') ?? 0; - - // 高温提示 (>= 35°C) - if (dayTemp >= 35) { - return WeatherTip( - icon: Icons.local_fire_department, - message: 'hotWeatherTip'.tr, // 翻译键代表的中文文本内容:今日高温炎热,请注意防暑降温,多喝水避免中暑 - ); - } - - // 低温提示 (<= 5°C) - if (dayTemp <= 5) { - return WeatherTip( - icon: Icons.ac_unit, - message: 'coldWeatherTip'.tr, // 翻译键代表的中文文本内容:今日气温较低,外出请注意保暖,小心感冒 - ); - } - - // 雨天提示 - if (dayWeather.contains('雨') || - ['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨'] - .any((rain) => dayWeather.contains(rain))) { - return WeatherTip( - icon: Icons.umbrella, - message: 'rainyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雨,外出请携带雨具,注意出行安全 - ); - } - - // 雪天提示 - if (dayWeather.contains('雪') || - ['阵雪', '小雪', '中雪', '大雪', '暴雪'].any((snow) => dayWeather.contains(snow))) { - return WeatherTip( - icon: Icons.ac_unit, - message: 'snowyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雪,路面湿滑请注意安全,做好保暖措施 - ); - } - - // 雾霾提示 - if (dayWeather.contains('雾') || - dayWeather.contains('霾') || - ['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => dayWeather.contains(fog))) { - return WeatherTip( - icon: Icons.blur_on, - message: 'foggyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雾霾,能见度较低,外出请佩戴口罩 - ); - } - - // 大风提示 - if (dayWeather.contains('风') || - ['大风', '强风', '疾风', '烈风'].any((wind) => dayWeather.contains(wind))) { - return WeatherTip( - icon: Icons.air, - message: 'windyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日风力较大,外出请注意安全,固定好物品 - ); - } - - // 晴天紫外线提示 (温度 >= 25°C) - if (['晴', '少云', '晴间多云'].any((sunny) => dayWeather.contains(sunny)) && - dayTemp >= 25) { - return WeatherTip( - icon: Icons.wb_sunny, - message: 'sunnyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日阳光充足紫外线较强,外出请做好防晒 - ); - } - - // 温差大提示 (>= 10°C) - if ((dayTemp - nightTemp).abs() >= 10) { - return WeatherTip( - icon: Icons.thermostat, - message: 'temperatureDifferenceTip'.tr, // 翻译键代表的中文文本内容:今日昼夜温差较大,请适时增减衣物 - ); - } - - // 默认舒适天气提示 - return WeatherTip( - icon: Icons.wb_sunny_outlined, - message: 'comfortableWeatherTip'.tr, // 翻译键代表的中文文本内容:今日天气适宜,是外出活动的好时光 - ); -} - -/// 构建单日天气预报 -Widget _buildForecastDay(String day, Widget icon, String temp) { - return Column( - children: [ - Text( - day, - style: TextStyle( - fontSize: 12.sp, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - SizedBox(height: 4.h), - icon, - SizedBox(height: 4.h), - Text( - temp, - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ], - ); -} - -/// 获取天气 SVG 图标 Widget -Widget getWeatherSvgIcon(String mappedCode, {double size = 24.0}) { - return _getWeatherSvgByCode(mappedCode, size: size); -} - -/// 根据和风天气代码获取 SVG 图标 -Widget _getWeatherSvgByCode(String code, {double size = 24.0}) { - // 检查 SVG 文件是否存在,如果不存在则使用默认图标 - final svgPath = 'assets/weather/$code.svg'; - - return SvgPicture.asset( - svgPath, - width: size, - height: size, - fit: BoxFit.contain, - colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), - // 如果 SVG 加载失败,显示默认图标 - placeholderBuilder: (context) => Icon( - Icons.help_outline, - size: size, - ), - ); -} - -/// 根据天气类型获取背景渐变颜色 -LinearGradient _getWeatherGradient(String weather, bool isDarkMode) { - final weatherType = weather.trim(); - - // 晴天 - 温暖的橙黄色 - if (['晴', '少云', '晴间多云'].any((sunny) => weatherType.contains(sunny))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFFFF8A00), Color(0xFFE65100)] // 深色模式:深橙色 - : [Color(0xFFFFB74D), Color(0xFFFF9800)], // 浅色模式:亮橙色 - ); - } - - // 多云/阴天 - 灰蓝色 - if (['多云', '阴'].any((cloudy) => weatherType.contains(cloudy))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF455A64), Color(0xFF37474F)] // 深色模式:深灰蓝 - : [Color(0xFF78909C), Color(0xFF607D8B)], // 浅色模式:灰蓝色 - ); - } - - // 雨天 - 深蓝色 - if (weatherType.contains('雨') || - ['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨'] - .any((rain) => weatherType.contains(rain))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF1565C0), Color(0xFF0D47A1)] // 深色模式:深蓝色 - : [Color(0xFF42A5F5), Color(0xFF1976D2)], // 浅色模式:亮蓝色 - ); - } - - // 雪天 - 冷色调蓝白色 - if (weatherType.contains('雪') || - ['阵雪', '小雪', '中雪', '大雪', '暴雪'] - .any((snow) => weatherType.contains(snow))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF0277BD), Color(0xFF01579B)] // 深色模式:深冷蓝 - : [Color(0xFF81D4FA), Color(0xFF29B6F6)], // 浅色模式:浅蓝色 - ); - } - - // 雾霾天 - 暗黄灰色 - if (weatherType.contains('雾') || - weatherType.contains('霾') || - ['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => weatherType.contains(fog))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF5D4037), Color(0xFF3E2723)] // 深色模式:深棕色 - : [Color(0xFF8D6E63), Color(0xFF6D4C41)], // 浅色模式:棕灰色 - ); - } - - // 大风天 - 青绿色 - if (weatherType.contains('风') || - ['大风', '强风', '疾风', '烈风'].any((wind) => weatherType.contains(wind))) { - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF00695C), Color(0xFF004D40)] // 深色模式:深青色 - : [Color(0xFF26A69A), Color(0xFF00796B)], // 浅色模式:青绿色 - ); - } - - // 默认 - 原来的蓝色 - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [Color(0xFF1E40AF), Color(0xFF1E3A8A)] - : [Color(0xFF60A5FA), Color(0xFF3B82F6)], - ); -} diff --git a/apps/client/lib/modules/agent/widgets/circular_progress_button.dart b/apps/client/lib/modules/agent/widgets/circular_progress_button.dart deleted file mode 100644 index 28729d23..00000000 --- a/apps/client/lib/modules/agent/widgets/circular_progress_button.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:math' as math; - -/// 环形进度条播放按钮组件 -class CircularProgressButton extends StatelessWidget { - final bool isPlaying; - final double progress; // 0.0 - 1.0 - final VoidCallback onPlayPause; - final Function(double)? onSeek; // 点击进度条跳转回调 - final double size; - final Color primaryColor; - final Color backgroundColor; - final double strokeWidth; - - const CircularProgressButton({ - Key? key, - required this.isPlaying, - required this.progress, - required this.onPlayPause, - this.onSeek, - this.size = 36.0, - required this.primaryColor, - this.backgroundColor = Colors.grey, - this.strokeWidth = 2.0, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - // 只保留播放/暂停功能,移除进度条点击交互 - onPlayPause(); - }, - child: SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - // 背景圆环 - CustomPaint( - size: Size(size, size), - painter: _CircularProgressPainter( - progress: 1.0, - color: backgroundColor.withValues(alpha: 0.3), - strokeWidth: strokeWidth, - ), - ), - // 进度圆环 - CustomPaint( - size: Size(size, size), - painter: _CircularProgressPainter( - progress: progress, - color: primaryColor, - strokeWidth: strokeWidth, - ), - ), - // 播放/暂停按钮 - Icon( - isPlaying ? Icons.pause : Icons.play_arrow, - color: primaryColor, - size: size * 0.5, - ), - ], - ), - ), - ); - } -} - -/// 环形进度条绘制器 -class _CircularProgressPainter extends CustomPainter { - final double progress; - final Color color; - final double strokeWidth; - - _CircularProgressPainter({ - required this.progress, - required this.color, - required this.strokeWidth, - }); - - @override - void paint(Canvas canvas, Size size) { - final center = Offset(size.width / 2, size.height / 2); - final radius = (size.width / 2) - strokeWidth / 2; - - final paint = Paint() - ..color = color - ..strokeWidth = strokeWidth - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; - - // 绘制圆弧,从顶部开始 (-π/2),顺时针绘制 - const startAngle = -math.pi / 2; - final sweepAngle = 2 * math.pi * progress; - - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - startAngle, - sweepAngle, - false, - paint, - ); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return oldDelegate is! _CircularProgressPainter || - oldDelegate.progress != progress || - oldDelegate.color != color || - oldDelegate.strokeWidth != strokeWidth; - } -} diff --git a/apps/client/lib/modules/agent/widgets/full_screen_editor.dart b/apps/client/lib/modules/agent/widgets/full_screen_editor.dart deleted file mode 100644 index 94c2bbd0..00000000 --- a/apps/client/lib/modules/agent/widgets/full_screen_editor.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../controllers/agent_controller.dart'; - -/// 底部弹出式文本编辑器组件 -class FullScreenEditor extends StatefulWidget { - const FullScreenEditor({super.key}); - - @override - State createState() => _FullScreenEditorState(); -} - -class _FullScreenEditorState extends State { - late TextEditingController _textController; - late FocusNode _focusNode; - final AgentController controller = AgentController.to; - - @override - void initState() { - super.initState(); - // 使用与主界面相同的文本控制器,保持内容同步 - _textController = controller.textController; - _focusNode = FocusNode(); - - // 延迟聚焦,确保界面完全加载后再聚焦 - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - } - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final primaryColor = Theme.of(context).primaryColor; - final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; // 获取键盘高度 - - return Container( - height: MediaQuery.of(context).size.height * 0.95, // 占屏幕高度的90% - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[900] : Colors.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20.r), - topRight: Radius.circular(20.r), - ), - ), - child: Stack( - children: [ - // 主要内容区域 - Column( - children: [ - // 主要编辑区域 - Expanded( - child: Container( - padding: EdgeInsets.fromLTRB(16.w, 60.w, 16.w, 16.w), - child: TextField( - controller: _textController, - focusNode: _focusNode, - maxLines: null, - expands: true, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - style: TextStyle( - fontSize: 16.sp, - color: isDarkMode ? Colors.white : Colors.black87, - height: 1.5, - ), - decoration: InputDecoration( - hintText: 'fullScreenEditorHint'.tr, // 在这里输入你的消息... - hintStyle: TextStyle( - color: isDarkMode ? Colors.white54 : Colors.grey[500], - fontSize: 16.sp, - ), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - ), - ), - ), - ), - - // 底部操作区域 - Container( - margin: - EdgeInsets.only(bottom: keyboardHeight), // 关键修改:添加键盘高度的边距 - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.grey[50], - border: Border( - top: BorderSide( - color: isDarkMode ? Colors.grey[700]! : Colors.grey[200]!, - width: 1, - ), - ), - ), - child: Row( - children: [ - // 字符计数 - Expanded( - child: ValueListenableBuilder( - valueListenable: _textController, - builder: (context, value, child) { - return Text( - 'characterCount'.tr.replaceAll('{count}', - '${value.text.length}'), // ${value.text.length} 字符 - style: TextStyle( - color: isDarkMode - ? Colors.white60 - : Colors.grey[600], - fontSize: 14.sp, - ), - ); - }, - ), - ), - - // 发送按钮 - 保持与主界面一致的样式 - Obx(() { - final hasText = controller.hasTextInput.value; - return GestureDetector( - onTap: hasText - ? () async { - await controller.sendMessageFromFullScreen(); - if (context.mounted) { - Navigator.of(context).pop(); - } - } - : null, - child: Container( - width: 44.w, - height: 44.h, - decoration: BoxDecoration( - color: hasText - ? primaryColor - : (isDarkMode - ? Colors.grey[800] - : Colors.grey[300]), - borderRadius: BorderRadius.circular(22.r), - ), - child: Icon( - Icons.send, - color: hasText - ? Colors.white - : (isDarkMode - ? Colors.grey[600] - : Colors.grey[500]), - size: 22.sp, - ), - ), - ); - }), - ], - ), - ), - ], - ), - - // 右上角关闭按钮 - Positioned( - top: 16.h, - right: 16.w, - child: GestureDetector( - onTap: () { - controller.closeFullScreenEdit(); - Navigator.of(context).pop(); - }, - child: Container( - width: 32.w, - height: 32.h, - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.grey[200], - borderRadius: BorderRadius.circular(16.r), - ), - child: Icon( - Icons.close, - color: isDarkMode ? Colors.white : Colors.black87, - size: 20.sp, - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/apps/client/lib/modules/agent/widgets/sound_bar_widget.dart b/apps/client/lib/modules/agent/widgets/sound_bar_widget.dart deleted file mode 100644 index 9028f713..00000000 --- a/apps/client/lib/modules/agent/widgets/sound_bar_widget.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import '../controllers/agent_controller.dart'; - -/// 声音柱动画组件 (已优化) -/// 用于显示语音输入时的音频波形效果 -class SoundBarWidget extends StatefulWidget { - final double delay; - final bool isActive; - - const SoundBarWidget({ - Key? key, - required this.delay, - required this.isActive, - }) : super(key: key); - - @override - State createState() => _SoundBarWidgetState(); -} - -class _SoundBarWidgetState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _animation; - - @override - void initState() { - super.initState(); - - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 500), - ); - - _animation = Tween(begin: 3, end: 18).animate( - CurvedAnimation( - parent: _controller, - curve: Curves.easeInOut, - ), - ); - // 移除了 .addListener(() { setState(() {}); }) - - _startOrStopAnimation(); - } - - @override - void didUpdateWidget(SoundBarWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive != oldWidget.isActive) { - _startOrStopAnimation(); - } - } - - /// 开始或停止动画 - void _startOrStopAnimation() { - // 确保组件仍然挂载在树上 - if (!mounted) return; - - if (widget.isActive) { - Future.delayed(Duration(milliseconds: (widget.delay * 1000).toInt()), () { - if (mounted && widget.isActive) { - _controller.repeat(reverse: true); - } - }); - } else { - // 停止并平滑地过渡到初始状态 - _controller.stop(); - _controller.animateTo(0.0, - duration: const Duration(milliseconds: 200), curve: Curves.easeOut); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final primaryColor = Theme.of(context).primaryColor; - - // 使用 AnimatedBuilder 来优化性能 - return AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Container( - width: 2.5, - // 当动画不活跃时,高度固定为3;活跃时使用动画值 - height: widget.isActive ? _animation.value : 3, - margin: const EdgeInsets.symmetric(horizontal: 1), - decoration: BoxDecoration( - color: widget.isActive - ? primaryColor.withOpacity(0.8) - : Colors.grey.withOpacity(0.4), - borderRadius: BorderRadius.circular(1.5), - ), - ); - }, - ); - } -} - -/// 声音柱组合组件 (无需改动) -/// 包含多个声音柱的组合显示 -class SoundBarsWidget extends StatelessWidget { - final bool isActive; - final int barCount; - - const SoundBarsWidget({ - Key? key, - required this.isActive, - this.barCount = 10, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(barCount, (index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: SoundBarWidget( - delay: index * 0.1, - isActive: isActive, - ), - ); - }), - ); - } -} - -/// 带控制器集成的声音柱组件 (无需改动) -/// 自动从AgentController获取状态 -class AgentSoundBarsWidget extends StatelessWidget { - final int barCount; - - const AgentSoundBarsWidget({ - Key? key, - this.barCount = 10, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Obx(() { - final controller = Get.find(); - final shouldShowAnimation = - controller.isListening.value || controller.isPushToTalkActive.value; - return SoundBarsWidget( - isActive: shouldShowAnimation, - barCount: barCount, - ); - }); - } -} diff --git a/apps/client/lib/modules/ble/bindings/ble_binding.dart b/apps/client/lib/modules/ble/bindings/ble_binding.dart deleted file mode 100644 index 4fc2cc5c..00000000 --- a/apps/client/lib/modules/ble/bindings/ble_binding.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/ble_controller.dart'; - -class BleBinding extends Bindings { - @override - void dependencies() { - Get.put(BleController()); - } -} \ No newline at end of file diff --git a/apps/client/lib/modules/ble/controllers/ble_controller.dart b/apps/client/lib/modules/ble/controllers/ble_controller.dart deleted file mode 100644 index 3af4089b..00000000 --- a/apps/client/lib/modules/ble/controllers/ble_controller.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; -import 'package:permission_handler/permission_handler.dart'; -import '../../../core/utils/permission_util.dart'; - -class BleController extends GetxController { - // 扫描状态 - final RxBool isScanning = false.obs; - final RxBool isBluetoothOn = false.obs; - final RxBool hasPermissions = false.obs; - - // 发现的设备列表 - final RxList scanResults = [].obs; - - // 连接的设备 - final Rx connectedDevice = - Rx(null); - - // 错误信息 - final RxString errorMessage = ''.obs; - - @override - void onInit() { - super.onInit(); - _initBluetooth(); - } - - @override - void onClose() { - stopScan(); - super.onClose(); - } - - // 初始化蓝牙 - Future _initBluetooth() async { - try { - // 先检查权限 - await _checkPermissions(); - - // 检查蓝牙适配器状态 - fbp.FlutterBluePlus.adapterState.listen((state) { - isBluetoothOn.value = state == fbp.BluetoothAdapterState.on; - if (state == fbp.BluetoothAdapterState.on) { - errorMessage.value = ''; - } else { - errorMessage.value = 'pleaseEnableBluetooth'.tr; - stopScan(); - } - }); - - // 监听扫描结果 - fbp.FlutterBluePlus.scanResults.listen((results) { - scanResults.value = results; - }); - - // 监听扫描状态 - fbp.FlutterBluePlus.isScanning.listen((scanning) { - isScanning.value = scanning; - }); - } catch (e) { - errorMessage.value = 'bleInitFailedWithError'.trParams({'error': e.toString()}); - } - } - - // 检查权限(只查状态,绝不发起申请) - // - // 合规(场景 1/2):进页面就自动弹权限框是明确禁止的。这里原来虽然叫 - // "不请求",实际却调了 .request(),用户一进 BLE 页就被弹蓝牙+定位框, - // 拒绝之后每次重进页面还会再弹。现在只读状态,申请交给用户点"扫描"时触发。 - Future _checkPermissions() async { - try { - bool allGranted = true; - for (final p in _blePermissions) { - if (!await p.status.isGranted) { - allGranted = false; - break; - } - } - hasPermissions.value = allGranted; - - if (!allGranted) { - errorMessage.value = 'needBluetoothAndLocationPermission'.tr; - } - } catch (e) { - print('检查权限失败: $e'); - hasPermissions.value = false; - } - } - - // 扫描需要的权限(成套,缺一个都扫不出设备) - static const List _blePermissions = [ - Permission.bluetoothScan, - Permission.bluetoothConnect, - Permission.location, - ]; - - // 请求权限(仅由用户点击"扫描"触发) - // - // 合规(场景 1):拒绝之后不再弹任何框、不跳设置页——原来这里在检测到 - // permanentlyDenied 时会立刻弹一个带"前往设置"的自定义框,正是要禁掉的行为。 - // 现在统一走 PermissionUtil,拒绝即进入 48h 冷却,返回 false 后由 UI 置灰扫描按钮。 - Future _requestPermissions() async { - try { - final allGranted = await PermissionUtil.instance.requestPermissions( - permissions: _blePermissions, - permissionName: 'bluetooth'.tr, - explanationText: 'needBluetoothAndLocationPermission'.tr, - ); - hasPermissions.value = allGranted; - - if (!allGranted) { - // 只做静态提示,不引导、不跳转 - errorMessage.value = 'needBluetoothAndLocationPermission'.tr; - } - - return allGranted; - } catch (e) { - print('请求权限失败: $e'); - errorMessage.value = 'permissionRequestFailedWithError'.trParams({'error': e.toString()}); - return false; - } - } - - /// 权限被拒且仍在冷却期 —— 视图据此把扫描入口置灰 - bool get isPermissionCoolingDown => _blePermissions - .any((p) => PermissionUtil.instance.isDenialCoolingDown(p)); - - // 显示蓝牙开启对话框 - void _showBluetoothDialog() { - Get.dialog( - AlertDialog( - title: Text('needEnableBluetooth'.tr), - content: Text('enableBluetoothDialogContent'.tr), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text('cancel'.tr), - ), - TextButton( - onPressed: () { - Get.back(); - // 尝试开启蓝牙 - fbp.FlutterBluePlus.turnOn(); - }, - child: Text('enableBluetooth'.tr), - ), - ], - ), - ); - } - - // 开始扫描 - Future startScan() async { - try { - errorMessage.value = ''; - - // 检查权限 - if (!hasPermissions.value) { - if (!await _requestPermissions()) { - return; - } - } - - // 检查蓝牙状态 - if (!isBluetoothOn.value) { - _showBluetoothDialog(); - return; - } - - // 清空之前的结果 - scanResults.clear(); - - // 开始扫描 - await fbp.FlutterBluePlus.startScan( - timeout: const Duration(seconds: 30), - androidUsesFineLocation: true, - ); - } catch (e) { - errorMessage.value = 'bleScanFailedWithError'.trParams({'error': e.toString()}); - } - } - - // 停止扫描 - Future stopScan() async { - try { - await fbp.FlutterBluePlus.stopScan(); - } catch (e) { - print('停止扫描失败: $e'); - } - } - - // 连接设备 - Future connectToDevice(fbp.BluetoothDevice device) async { - try { - errorMessage.value = ''; - - // 停止扫描 - await stopScan(); - - // 连接设备 - await device.connect(timeout: const Duration(seconds: 15)); - connectedDevice.value = device; - - Get.snackbar( - 'bleConnectionSuccess'.tr, - 'bleConnectedToDevice'.trParams({'device': device.platformName.isNotEmpty ? device.platformName : 'unknownEquipment'.tr}), - backgroundColor: Colors.green, - colorText: Colors.white, - ); - } catch (e) { - errorMessage.value = 'bleConnectionFailedWithError'.trParams({'error': e.toString()}); - Get.snackbar( - 'connectionFailed'.tr, - e.toString(), - backgroundColor: Colors.red, - colorText: Colors.white, - ); - } - } - - // 断开连接 - Future disconnectDevice() async { - try { - if (connectedDevice.value != null) { - await connectedDevice.value!.disconnect(); - connectedDevice.value = null; - - Get.snackbar( - 'bleDisconnected'.tr, - 'bleDeviceDisconnected'.tr, - backgroundColor: Colors.orange, - colorText: Colors.white, - ); - } - } catch (e) { - errorMessage.value = 'bleDisconnectFailedWithError'.trParams({'error': e.toString()}); - } - } - - // 获取设备名称 - String getDeviceName(fbp.ScanResult result) { - if (result.device.platformName.isNotEmpty) { - return result.device.platformName; - } - if (result.advertisementData.localName.isNotEmpty) { - return result.advertisementData.localName; - } - return 'unknownEquipment'.tr; - } - - // 获取信号强度描述 - String getSignalStrengthText(int rssi) { - if (rssi >= -50) return 'signalStrong'.tr; - if (rssi >= -70) return 'signalMedium'.tr; - if (rssi >= -90) return 'signalWeak'.tr; - return 'signalVeryWeak'.tr; - } - - // 获取信号强度图标 - IconData getSignalStrengthIcon(int rssi) { - if (rssi >= -50) return Icons.signal_wifi_4_bar; - if (rssi >= -70) return Icons.signal_wifi_4_bar; - if (rssi >= -90) return Icons.signal_wifi_4_bar; - return Icons.signal_wifi_4_bar; - } -} diff --git a/apps/client/lib/modules/ble/views/ble_view.dart b/apps/client/lib/modules/ble/views/ble_view.dart deleted file mode 100644 index 1b8d7d3c..00000000 --- a/apps/client/lib/modules/ble/views/ble_view.dart +++ /dev/null @@ -1,374 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; -import '../controllers/ble_controller.dart'; - -class BleView extends GetView { - const BleView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.white, - appBar: AppBar( - title: Text( - 'bleScanTitle'.tr, - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, - fontSize: 18.sp, - fontWeight: FontWeight.bold, - ), - ), - backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, - elevation: 0, - iconTheme: IconThemeData( - color: isDarkMode ? Colors.white : Colors.black, - ), - actions: [ - // 合规(场景 1/2):用户拒绝过蓝牙/定位权限且仍在 48h 冷却期内时, - // 扫描按钮置灰——既不能再弹权限框,也不该让用户点了毫无反应。 - Obx(() { - // 读一下 hasPermissions 让 Obx 建立订阅:isPermissionCoolingDown - // 读的是 GetStorage 不是 Rx,单靠它不会触发重建 - controller.hasPermissions.value; - final blocked = controller.isPermissionCoolingDown; - return IconButton( - onPressed: blocked - ? null - : (controller.isScanning.value - ? controller.stopScan - : controller.startScan), - icon: Icon( - controller.isScanning.value ? Icons.stop : Icons.refresh, - color: blocked - ? Colors.grey - : (controller.isScanning.value - ? Colors.red - : (isDarkMode ? Colors.white : Colors.black)), - ), - ); - }), - ], - ), - body: SafeArea( - child: Column( - children: [ - // 状态栏 - _buildStatusBar(isDarkMode), - - // 连接的设备信息 - _buildConnectedDevice(isDarkMode), - - // 设备列表 - Expanded( - child: _buildDeviceList(isDarkMode), - ), - ], - ), - ), - floatingActionButton: Obx(() { - controller.hasPermissions.value; // 同上:建立订阅,权限申请结束后重建 - final blocked = controller.isPermissionCoolingDown; - return FloatingActionButton( - onPressed: blocked - ? null - : (controller.isScanning.value - ? controller.stopScan - : controller.startScan), - backgroundColor: blocked - ? Colors.grey - : (controller.isScanning.value ? Colors.red : Colors.blue), - child: Icon( - controller.isScanning.value - ? Icons.stop - : Icons.bluetooth_searching, - color: Colors.white, - ), - ); - }), - ); - } - - // 状态栏 - Widget _buildStatusBar(bool isDarkMode) { - return Obx(() => Container( - width: double.infinity, - padding: EdgeInsets.all(16.w), - margin: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.grey[100], - borderRadius: BorderRadius.circular(12.r), - ), - child: Column( - children: [ - Row( - children: [ - Icon( - controller.isBluetoothOn.value - ? Icons.bluetooth - : Icons.bluetooth_disabled, - color: controller.isBluetoothOn.value - ? Colors.blue - : Colors.red, - size: 24.w, - ), - SizedBox(width: 8.w), - Text( - controller.isBluetoothOn.value ? 'bluetoothOn'.tr : '蓝牙已关闭', - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, - fontSize: 16.sp, - fontWeight: FontWeight.w500, - ), - ), - Spacer(), - if (controller.isScanning.value) - SizedBox( - width: 20.w, - height: 20.w, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.blue), - ), - ), - ], - ), - if (controller.errorMessage.value.isNotEmpty) ...[ - SizedBox(height: 8.h), - Text( - controller.errorMessage.value, - style: TextStyle( - color: Colors.red, - fontSize: 14.sp, - ), - ), - ], - if (controller.isScanning.value) ...[ - SizedBox(height: 8.h), - Text( - 'bleScanning'.tr, - style: TextStyle( - color: Colors.blue, - fontSize: 14.sp, - ), - ), - ], - ], - ), - )); - } - - // 已连接设备信息 - Widget _buildConnectedDevice(bool isDarkMode) { - return Obx(() { - if (controller.connectedDevice.value == null) { - return SizedBox.shrink(); - } - - final device = controller.connectedDevice.value!; - return Container( - width: double.infinity, - padding: EdgeInsets.all(16.w), - margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), - decoration: BoxDecoration( - color: Colors.green.withOpacity(0.1), - border: Border.all(color: Colors.green, width: 1), - borderRadius: BorderRadius.circular(12.r), - ), - child: Row( - children: [ - Icon( - Icons.bluetooth_connected, - color: Colors.green, - size: 24.w, - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'bleConnectedDevices'.tr, - style: TextStyle( - color: Colors.green, - fontSize: 12.sp, - fontWeight: FontWeight.w500, - ), - ), - Text( - device.platformName.isNotEmpty - ? device.platformName - : 'bleUnknownDevice'.tr, - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, - fontSize: 16.sp, - fontWeight: FontWeight.bold, - ), - ), - Text( - device.remoteId.toString(), - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - fontSize: 12.sp, - ), - ), - ], - ), - ), - IconButton( - onPressed: controller.disconnectDevice, - icon: Icon( - Icons.close, - color: Colors.red, - size: 20.w, - ), - ), - ], - ), - ); - }); - } - - // 设备列表 - Widget _buildDeviceList(bool isDarkMode) { - return Obx(() { - if (controller.scanResults.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.bluetooth_searching, - size: 64.w, - color: isDarkMode ? Colors.white54 : Colors.black54, - ), - SizedBox(height: 16.h), - Text( - controller.isScanning.value ? '正在搜索设备...' : 'bleTapToScan'.tr, - style: TextStyle( - color: isDarkMode ? Colors.white54 : Colors.black54, - fontSize: 16.sp, - ), - textAlign: TextAlign.center, - ), - ], - ), - ); - } - - return ListView.builder( - padding: EdgeInsets.symmetric(horizontal: 16.w), - itemCount: controller.scanResults.length, - itemBuilder: (context, index) { - final result = controller.scanResults[index]; - return _buildDeviceItem(result, isDarkMode); - }, - ); - }); - } - - // 设备项 - Widget _buildDeviceItem(fbp.ScanResult result, bool isDarkMode) { - final deviceName = controller.getDeviceName(result); - final isConnected = - controller.connectedDevice.value?.remoteId == result.device.remoteId; - - return Container( - margin: EdgeInsets.only(bottom: 12.h), - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.white, - borderRadius: BorderRadius.circular(12.r), - border: Border.all( - color: isConnected - ? Colors.green - : (isDarkMode ? Colors.grey[700]! : Colors.grey[300]!), - width: isConnected ? 2 : 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 8, - offset: Offset(0, 2), - ), - ], - ), - child: ListTile( - contentPadding: EdgeInsets.all(16.w), - leading: Container( - width: 48.w, - height: 48.w, - decoration: BoxDecoration( - color: isConnected - ? Colors.green.withOpacity(0.1) - : Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(24.r), - ), - child: Icon( - isConnected ? Icons.bluetooth_connected : Icons.bluetooth, - color: isConnected ? Colors.green : Colors.blue, - size: 24.w, - ), - ), - title: Text( - deviceName, - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, - fontSize: 16.sp, - fontWeight: FontWeight.bold, - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 4.h), - Text( - result.device.remoteId.toString(), - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - fontSize: 12.sp, - ), - ), - SizedBox(height: 4.h), - Row( - children: [ - Icon( - controller.getSignalStrengthIcon(result.rssi), - size: 16.w, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - SizedBox(width: 4.w), - Text( - '${result.rssi} dBm (${controller.getSignalStrengthText(result.rssi)})', - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - fontSize: 12.sp, - ), - ), - ], - ), - ], - ), - trailing: isConnected - ? Icon( - Icons.check_circle, - color: Colors.green, - size: 24.w, - ) - : IconButton( - onPressed: () => controller.connectToDevice(result.device), - icon: Icon( - Icons.connect_without_contact, - color: Colors.blue, - size: 24.w, - ), - ), - onTap: isConnected - ? null - : () => controller.connectToDevice(result.device), - ), - ); - } -} diff --git a/apps/client/lib/modules/devices/bindings/devices_binding.dart b/apps/client/lib/modules/devices/bindings/devices_binding.dart deleted file mode 100644 index 3eb4575c..00000000 --- a/apps/client/lib/modules/devices/bindings/devices_binding.dart +++ /dev/null @@ -1,11 +0,0 @@ -import '/modules/devices/controllers/devices_controller.dart'; -import 'package:get/get.dart'; - -class DevicesBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut( - () => DevicesController(), - ); - } -} diff --git a/apps/client/lib/modules/devices/controllers/devices_controller.dart b/apps/client/lib/modules/devices/controllers/devices_controller.dart deleted file mode 100644 index 97e1eb03..00000000 --- a/apps/client/lib/modules/devices/controllers/devices_controller.dart +++ /dev/null @@ -1,360 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:device_jieli/device_jieli.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import 'package:permission_handler/permission_handler.dart'; - -import '/core/utils/logger.dart'; -import '/core/utils/permission_util.dart'; -import '/data/models/device_category.dart'; -import '/data/services/agent_module_defaults.dart'; -import '/data/models/user_Info.dart'; -import '/data/services/ble_manager.dart'; -import '/data/services/network/api.dart'; -import '/data/utils/jieli_adv_parser.dart'; -import '/routes/app_routes.dart'; - -/// 设备页控制器:扫描杰理设备、绑定(首次)、连接、断开。 -class DevicesController extends GetxController { - static const String _tag = 'DevicesController'; - - late final BleManager _bleManager; - - RxList get scannedDevices => _bleManager.scannedDevices; - RxBool get isScanning => _bleManager.isScanning; - - RxInt get connectionStateRx => _bleManager.connectionStateRx; - String? get currentDeviceAddress => _bleManager.currentDeviceAddress; - - /// 正在连接的目标地址(用于 tile UI 反馈) - final RxnString connectingAddress = RxnString(); - - /// 正在调用绑定接口的目标地址(用于 tile UI 反馈) - final RxnString bindingAddress = RxnString(); - - /// 当 [User.instance.devices] 变化时触发 UI 重建。 - /// 直接读 user 对象不响应式,所以加一个 tick。 - final RxInt boundDevicesTick = 0.obs; - - @override - void onInit() { - super.onInit(); - _bleManager = Get.find(); - Future.microtask(startScan); - } - - @override - void onClose() { - _bleManager.stopScan(); - super.onClose(); - } - - Future startScan() async { - Logger.i(_tag, 'startScan 被调用'); - final granted = await _ensureBluetoothPermission(); - if (!granted) { - Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr); - return; - } - // 从「选择设备」页带过来的品类:只扫该品类产品的 scanuuid; - // 没带品类(或品类圈不出产品)时退回扫描全部 UUID - final String? categoryId = Get.arguments is Map - ? (Get.arguments as Map)['categoryId'] as String? - : null; - _categoryId = categoryId; - final uuids = DeviceCategories.scanUuidsFor(categoryId); - Logger.i(_tag, - '扫描品类=${categoryId ?? "全部"}, uuid 数=${uuids.length}'); - final ok = await _bleManager.startScan(uuids: uuids); - if (!ok) { - final err = _bleManager.lastScanError ?? 'unknown'; - Logger.w(_tag, '扫描启动失败: $err'); - Get.snackbar('error'.tr, '${'startScanFailed'.tr}: $err'); - } - } - - Future stopScan() async { - await _bleManager.stopScan(); - } - - /// 从「选择设备」页带过来的品类 id,连接成功后按它铺 AI 页默认功能 - String? _categoryId; - - /// 当前用户已绑定的设备里是否包含这个 BLE 设备(按 MAC 匹配)。 - bool isDeviceBound(JieliDevice device) { - if (!User.isLoggedIn()) return false; - return _findBoundDevice(device) != null; - } - - UserDevice? _findBoundDevice(JieliDevice device) { - final addr = device.address.replaceAll(':', '').toUpperCase(); - final edr = (device.edrAddr ?? '').replaceAll(':', '').toUpperCase(); - for (final d in User.instance.devices) { - final dm = d.devicemac.replaceAll(':', '').toUpperCase(); - if (dm.isEmpty) continue; - if (dm == addr || (edr.isNotEmpty && dm == edr)) return d; - } - return null; - } - - /// 点击"连接"或"绑定后自动连接"时调用。 - Future connect(JieliDevice device) async { - if (connectingAddress.value != null) { - Logger.i(_tag, '连接进行中,忽略重复点击: ${device.address}'); - return; - } - if (_bleManager.isConnected && - _bleManager.currentDeviceAddress == device.address) { - Logger.i(_tag, '设备已处于连接状态,直接进入首页: ${device.address}'); - Get.offAllNamed(Routes.home); - return; - } - - Logger.i(_tag, '连接设备: ${device.name} ${device.address}'); - connectingAddress.value = device.address; - await _bleManager.stopScan(); - - final completer = Completer(); - bool gotConnectionOk = false; - - final evtSub = Jielihome.instance.events.listen((evt) { - if (evt is ConnectionStateEvent) { - if (evt.address != device.address) return; - if (evt.state == ConnectionStateEvent.connectionOk) { - gotConnectionOk = true; - } else if (evt.state == ConnectionStateEvent.connectionDisconnect) { - if (!completer.isCompleted) completer.complete(false); - } - } else if (evt is RcspInitEvent) { - if (evt.address != device.address) return; - if (evt.success && gotConnectionOk) { - if (!completer.isCompleted) completer.complete(true); - } else if (!evt.success) { - if (!completer.isCompleted) completer.complete(false); - } - } - }); - - _showConnectingDialog(device); - - try { - await _bleManager.connectDevice(device); - final ok = await completer.future - .timeout(const Duration(seconds: 20), onTimeout: () => false); - - if (Get.isDialogOpen ?? false) Get.back(); - - if (ok) { - // 首次连上该品类设备 → AI 页铺开这个品类的默认功能编排 - AgentDefaults.onJieliConnected(_categoryId); - Get.offAllNamed(Routes.home); - } else { - Get.snackbar('error'.tr, 'connectionFailure'.tr); - } - } catch (e) { - Logger.e(_tag, '连接异常: $e'); - if (Get.isDialogOpen ?? false) Get.back(); - Get.snackbar('error'.tr, 'connectionFailure'.tr); - } finally { - await evtSub.cancel(); - connectingAddress.value = null; - } - } - - /// 点击"绑定"按钮:解析广播 → 调用 binddevice → 成功后自动 connect。 - Future bindAndConnect(JieliDevice device) async { - if (bindingAddress.value != null || connectingAddress.value != null) { - Logger.i(_tag, '已有绑定/连接进行中,忽略重复点击: ${device.address}'); - return; - } - if (!User.isLoggedIn()) { - Get.snackbar('tip'.tr, 'pleaseLoginFirst'.tr); - return; - } - - final info = JieliAdvParser.parse(device); - if (info == null || info.code.isEmpty) { - Logger.w(_tag, '无法从广播包解析绑定信息: ${device.address}'); - Get.snackbar('error'.tr, 'deviceUnauthorized'.tr); - return; - } - - bindingAddress.value = device.address; - try { - final response = await Api.binddevice({ - 'pid': info.pid, - 'code': info.code, - 'devicename': device.name, - 'devicemac': device.address, - }); - Logger.i(_tag, '绑定接口返回: $response'); - - if (response == null) { - Get.snackbar('error'.tr, 'deviceUnauthorized'.tr); - Get.offAllNamed(Routes.devices); - return; - } - - final deviceJson = response['device']; - if (deviceJson == null || deviceJson == '') { - Get.snackbar('error'.tr, 'deviceUnauthorized'.tr); - Get.offAllNamed(Routes.devices); - return; - } - - final newUserDevice = - UserDevice.fromJson(deviceJson as Map); - User.instance.addUserDevice(newUserDevice); - boundDevicesTick.value++; - - // 首次绑定奖励 - if (response['isgiveaway'] == true) { - _applyGiveaway(response); - } - - // 绑定成功后自动连接进入主界面 - await connect(device); - } catch (e) { - Logger.e(_tag, '绑定异常: $e'); - Get.snackbar('error'.tr, 'deviceUnauthorized'.tr); - } finally { - bindingAddress.value = null; - } - } - - void _applyGiveaway(Map response) { - User.instance.viplv = (response['viplv'] as num?)?.toInt() ?? User.instance.viplv; - User.instance.vipexptime = - (response['vipexptime'] as num?)?.toInt() ?? User.instance.vipexptime; - User.instance.aichatintegral = - (response['aichatintegral'] as num?)?.toInt() ?? User.instance.aichatintegral; - User.instance.aichattotalintegral = - (response['aichattotalintegral'] as num?)?.toInt() ?? - User.instance.aichattotalintegral; - User.instance.tradeintegral = - (response['tradeintegral'] as num?)?.toInt() ?? User.instance.tradeintegral; - User.instance.tradetotalintegral = - (response['tradetotalintegral'] as num?)?.toInt() ?? - User.instance.tradetotalintegral; - User.instance.meetintegral = - (response['meetintegral'] as num?)?.toInt() ?? User.instance.meetintegral; - User.instance.meettotalintegral = - (response['meettotalintegral'] as num?)?.toInt() ?? - User.instance.meettotalintegral; - Get.dialog( - AlertDialog( - title: Text('bindRewardTitle'.tr), - content: Text('bindRewardSimpleDesc'.tr), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text('ok'.tr), - ), - ], - ), - barrierDismissible: true, - ); - } - - void _showConnectingDialog(JieliDevice device) { - Get.dialog( - PopScope( - canPop: false, - child: Dialog( - backgroundColor: Colors.transparent, - elevation: 0, - insetPadding: EdgeInsets.symmetric(horizontal: 40.w), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 28.h), - decoration: BoxDecoration( - color: Get.isDarkMode - ? const Color(0xFF1C1C1E) - : Colors.white, - borderRadius: BorderRadius.circular(16.r), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 44.w, - height: 44.w, - child: const CircularProgressIndicator( - strokeWidth: 3, - valueColor: - AlwaysStoppedAnimation(Color(0xFF0066CC)), - ), - ), - SizedBox(height: 18.h), - Text( - 'connectingDevice'.tr, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w600, - color: Get.isDarkMode ? Colors.white : Colors.black87, - ), - ), - SizedBox(height: 6.h), - Text( - device.name.isEmpty ? device.address : device.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.sp, - color: Get.isDarkMode ? Colors.white54 : Colors.black54, - ), - ), - ], - ), - ), - ), - ), - barrierDismissible: false, - ); - } - - Future disconnect() async { - await _bleManager.disconnect(); - } - - bool isConnected(JieliDevice device) { - return _bleManager.isConnected && - _bleManager.currentDeviceAddress == device.address; - } - - /// 扫描设备需要的权限(Android) - static const List _scanPermissions = [ - Permission.bluetoothScan, - Permission.bluetoothConnect, - Permission.locationWhenInUse, - ]; - - /// 权限被拒且仍在 48h 冷却期 —— 视图据此把"扫描设备"入口置灰 - bool get isBluetoothPermissionCoolingDown { - if (Platform.isIOS) return false; - return _scanPermissions - .any((p) => PermissionUtil.instance.isDenialCoolingDown(p)); - } - - Future _ensureBluetoothPermission() async { - // iOS: CoreBluetooth 首次扫描时由系统自动弹出蓝牙权限对话框 - // (依赖 Info.plist 的 NSBluetoothAlwaysUsageDescription), - // permission_handler 的 bluetoothScan/bluetoothConnect 是 Android 专属, - // 在 iOS 上调用永远返回 denied,会误拦截扫描。 - if (Platform.isIOS) return true; - try { - // 合规(场景 1/2):走统一网关,用户拒绝一次后 48 小时内不再申请, - // 返回 false 时调用方置灰扫描入口,不得再弹窗或跳设置页。 - return await PermissionUtil.instance.requestPermissions( - permissions: _scanPermissions, - permissionName: 'bluetooth'.tr, - explanationText: 'bluetoothScanPermissionDesc'.tr, - ); - } catch (e) { - Logger.error('请求蓝牙权限失败: $e'); - return false; - } - } -} diff --git a/apps/client/lib/modules/devices/views/devices_view.dart b/apps/client/lib/modules/devices/views/devices_view.dart deleted file mode 100644 index ef45b4ea..00000000 --- a/apps/client/lib/modules/devices/views/devices_view.dart +++ /dev/null @@ -1,512 +0,0 @@ -import 'package:device_jieli/device_jieli.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -import '../controllers/devices_controller.dart'; -import '../../../core/theme/app_palette.dart'; - -class DevicesView extends StatefulWidget { - const DevicesView({Key? key}) : super(key: key); - - @override - State createState() => _DevicesViewState(); -} - -class _DevicesViewState extends State - with TickerProviderStateMixin { - late AnimationController _pulseController; - late AnimationController _rotationController; - late Animation _pulseAnimation; - late Animation _rotationAnimation; - - DevicesController get controller => Get.find(); - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1500), - vsync: this, - )..repeat(reverse: true); - _rotationController = AnimationController( - duration: const Duration(milliseconds: 2000), - vsync: this, - )..repeat(); - _pulseAnimation = - Tween(begin: 0.8, end: 1.2).animate(CurvedAnimation( - parent: _pulseController, - curve: Curves.easeInOut, - )); - _rotationAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _rotationController, curve: Curves.linear), - ); - } - - @override - void dispose() { - _pulseController.dispose(); - _rotationController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Scaffold( - backgroundColor: isDark ? Colors.black : Colors.white, - body: SafeArea( - child: Stack( - children: [ - Column( - children: [ - _buildTopSection(isDark), - Expanded(child: _buildDeviceListSection(isDark)), - _buildBottomActions(isDark), - ], - ), - // 返回箭头(左上角,悬浮不挤压居中标题) - Positioned( - left: 12.w, - top: 8.h, - child: GestureDetector( - onTap: () => Get.back(), - child: Container( - width: 40.w, - height: 40.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.04), - ), - child: Icon( - Icons.arrow_back_ios_new, - size: 18.sp, - color: isDark ? Colors.white : AppPalette.brand900, - ), - ), - ), - ), - ], - ), - ), - ); - } - - // ─── 顶部:标题 + 扫描动画 + 状态文字 ──────────────────── - Widget _buildTopSection(bool isDark) { - return Padding( - padding: EdgeInsets.only(top: 40.h, left: 24.w, right: 24.w), - child: Column( - children: [ - Text( - 'Eaimar', - style: TextStyle( - fontSize: 28.sp, - fontWeight: FontWeight.bold, - color: isDark ? Colors.white : Colors.black, - ), - ), - SizedBox(height: 6.h), - Text( - 'homeSlogan'.tr, - style: TextStyle( - fontSize: 14.sp, - color: isDark ? Colors.white60 : Colors.black54, - ), - ), - SizedBox(height: 24.h), - _buildScanAnimation(isDark), - SizedBox(height: 16.h), - Obx(() { - final scanning = controller.isScanning.value; - return Text( - scanning ? 'scanning'.tr : 'scanIdle'.tr, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w500, - color: isDark ? Colors.white70 : Colors.black87, - ), - ); - }), - SizedBox(height: 6.h), - Text( - 'ensureHeadsetOnAndPairingMode'.tr, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12.sp, - color: isDark ? Colors.white38 : Colors.black45, - ), - ), - ], - ), - ); - } - - Widget _buildScanAnimation(bool isDark) { - const primaryColor = Color(0xFF0066CC); - const secondaryColor = Color(0xFF5E5CE6); - return SizedBox( - width: 180.w, - height: 180.w, - child: Obx(() { - final scanning = controller.isScanning.value; - return Stack( - alignment: Alignment.center, - children: [ - if (scanning) _buildPulse(primaryColor, secondaryColor), - if (scanning) _buildRotatingBorder(primaryColor), - Container( - width: 92.w, - height: 92.w, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [primaryColor, secondaryColor], - ), - shape: BoxShape.circle, - ), - child: Icon( - Icons.headphones, - color: Colors.white, - size: 44.w, - ), - ), - ], - ); - }), - ); - } - - Widget _buildPulse(Color primary, Color secondary) { - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (_, __) { - final v = _pulseAnimation.value; - final opacity = 0.4 - (_pulseController.value * 0.3); - return Container( - width: 160.w * v, - height: 160.w * v, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - primary.withValues(alpha: opacity), - secondary.withValues(alpha: opacity), - ], - ), - shape: BoxShape.circle, - ), - ); - }, - ); - } - - Widget _buildRotatingBorder(Color primary) { - return AnimatedBuilder( - animation: _rotationAnimation, - builder: (_, __) { - return Transform.rotate( - angle: _rotationAnimation.value * 2 * 3.14159, - child: SizedBox( - width: 110.w, - height: 110.w, - child: CircularProgressIndicator( - strokeWidth: 3.w, - valueColor: AlwaysStoppedAnimation(primary), - backgroundColor: Colors.grey.withValues(alpha: 0.18), - value: 0.7, - ), - ), - ); - }, - ); - } - - // ─── 底部:扫描结果列表 ──────────────────────────────── - Widget _buildDeviceListSection(bool isDark) { - return Container( - margin: EdgeInsets.only(top: 20.h, left: 16.w, right: 16.w), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.03) - : const Color(0xFFF5F6FA), - borderRadius: BorderRadius.circular(16.r), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: - EdgeInsets.only(left: 16.w, right: 16.w, top: 14.h, bottom: 8.h), - child: Row( - children: [ - Text( - 'discoveredDevices'.tr, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white70 : Colors.black87, - ), - ), - const Spacer(), - Obx(() => Text( - '${controller.scannedDevices.length}', - style: TextStyle( - fontSize: 12.sp, - color: isDark ? Colors.white54 : Colors.black54, - ), - )), - ], - ), - ), - Expanded( - child: Obx(() { - final devices = controller.scannedDevices; - if (devices.isEmpty) { - return Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 32.h), - child: Text( - 'noDevicesFound'.tr, - style: TextStyle( - fontSize: 13.sp, - color: isDark ? Colors.white38 : Colors.black45, - ), - ), - ), - ); - } - return ListView.separated( - padding: EdgeInsets.fromLTRB(8.w, 4.h, 8.w, 12.h), - itemCount: devices.length, - separatorBuilder: (_, __) => SizedBox(height: 6.h), - itemBuilder: (_, i) => _buildDeviceTile(devices[i], isDark), - ); - }), - ), - ], - ), - ); - } - - Widget _buildDeviceTile(JieliDevice device, bool isDark) { - return Obx(() { - // 订阅连接状态、当前连接地址、正在连接/绑定的目标 + 已绑定列表 tick,让 tile 实时刷新 - controller.connectionStateRx.value; - controller.boundDevicesTick.value; - final connected = controller.isConnected(device); - final connectingAddr = controller.connectingAddress.value; - final bindingAddr = controller.bindingAddress.value; - final connecting = connectingAddr == device.address; - final binding = bindingAddr == device.address; - final disabled = (connectingAddr != null && !connecting) || - (bindingAddr != null && !binding); - final bound = controller.isDeviceBound(device); - return _buildDeviceTileContent( - device, - isDark, - connected: connected, - connecting: connecting, - binding: binding, - disabled: disabled, - bound: bound, - ); - }); - } - - Widget _buildDeviceTileContent( - JieliDevice device, - bool isDark, { - required bool connected, - required bool connecting, - required bool binding, - required bool disabled, - required bool bound, - }) { - const connectedColor = Color(0xFF22C55E); - final canTap = !connecting && !binding && !disabled; - void onPrimaryTap() { - if (bound) { - controller.connect(device); - } else { - controller.bindAndConnect(device); - } - } - - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12.r), - onTap: canTap ? onPrimaryTap : null, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.r), - color: isDark - ? Colors.white.withValues(alpha: 0.04) - : Colors.white, - border: Border.all( - color: connected - ? connectedColor.withValues(alpha: 0.3) - : (isDark - ? Colors.white.withValues(alpha: 0.05) - : Colors.black.withValues(alpha: 0.05)), - ), - ), - child: Row( - children: [ - Container( - width: 36.w, - height: 36.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: connected - ? connectedColor.withValues(alpha: 0.15) - : const Color(0xFF0066CC).withValues(alpha: 0.12), - ), - child: Icon( - Icons.headphones, - size: 18.sp, - color: connected - ? connectedColor - : const Color(0xFF0066CC), - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - device.name.isEmpty - ? 'unnamedDevice'.tr - : device.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.black87, - ), - ), - ), - if (connected) - Container( - padding: EdgeInsets.symmetric( - horizontal: 6.w, vertical: 2.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6.r), - color: connectedColor.withValues(alpha: 0.12), - ), - child: Text( - 'connected'.tr, - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w600, - color: connectedColor, - ), - ), - ), - ], - ), - SizedBox(height: 2.h), - Text( - '${device.address}${device.rssi != null ? ' RSSI ${device.rssi}' : ''}', - style: TextStyle( - fontSize: 10.sp, - color: isDark ? Colors.white38 : Colors.black45, - fontFamily: 'Menlo', - ), - ), - ], - ), - ), - if (connected) - TextButton( - onPressed: controller.disconnect, - style: TextButton.styleFrom( - minimumSize: Size(0, 28.h), - padding: EdgeInsets.symmetric(horizontal: 8.w), - ), - child: Text( - 'disconnect'.tr, - style: TextStyle(fontSize: 11.sp, color: Colors.red), - ), - ) - else if (connecting || binding) - SizedBox( - width: 18.w, - height: 18.w, - child: const CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(Color(0xFF0066CC)), - ), - ) - else - ElevatedButton( - onPressed: disabled ? null : onPrimaryTap, - style: ElevatedButton.styleFrom( - backgroundColor: bound - ? const Color(0xFF0066CC) - : const Color(0xFF8B5CF6), - foregroundColor: Colors.white, - minimumSize: Size(0, 30.h), - padding: EdgeInsets.symmetric(horizontal: 12.w), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.r), - ), - elevation: 0, - ), - child: Text( - bound ? 'connect'.tr : 'bind'.tr, - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ), - ); - } - - // ─── 底部:扫描操作按钮 ──────────────────────────────── - Widget _buildBottomActions(bool isDark) { - return Padding( - padding: EdgeInsets.fromLTRB(24.w, 12.h, 24.w, 20.h), - child: Obx(() { - final scanning = controller.isScanning.value; - return SizedBox( - width: double.infinity, - height: 48.h, - child: ElevatedButton.icon( - onPressed: scanning ? controller.stopScan : controller.startScan, - icon: Icon(scanning ? Icons.stop : Icons.refresh, size: 18.sp), - label: Text( - scanning ? 'stopScan'.tr : 'startScan'.tr, - style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w600), - ), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0066CC), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14.r), - ), - elevation: 0, - ), - ), - ); - }), - ); - } -} diff --git a/apps/client/lib/modules/eq_settings/bindings/eq_settings_binding.dart b/apps/client/lib/modules/eq_settings/bindings/eq_settings_binding.dart deleted file mode 100644 index 1f2884bd..00000000 --- a/apps/client/lib/modules/eq_settings/bindings/eq_settings_binding.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:get/get.dart'; - -import '../controllers/eq_settings_controller.dart'; - -class EqSettingsBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => EqSettingsController()); - } -} diff --git a/apps/client/lib/modules/eq_settings/controllers/eq_settings_controller.dart b/apps/client/lib/modules/eq_settings/controllers/eq_settings_controller.dart deleted file mode 100644 index 3b7e2541..00000000 --- a/apps/client/lib/modules/eq_settings/controllers/eq_settings_controller.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; - -import '../../../core/utils/logger.dart'; -import '../../../routes/app_routes.dart'; -import '../views/widgets/row_confirm_widget.dart'; -import '../views/widgets/row_equalizer_widget.dart'; -import '../views/widgets/row_grade_widget.dart'; -import '../views/widgets/row_input_widget.dart'; -import '../views/widgets/row_keypress_widget.dart'; -import '../views/widgets/row_radio_widget.dart'; -import '../views/widgets/row_switch_widget.dart'; -import '../views/widgets/row_timer_widget.dart'; - -class EqSettingsController extends GetxController { - final RxList uiList = [ - { - 'title': '音效', - 'templates': [ - { - 'type': 'equalizer', - 'name': '均衡器', - 'value': [], - 'options': [ - { - 'label': '默认', - 'value': [1, 2, 3, 4, 5, 4, 3, 2, 1, 0], - 'default': true, - }, - { - 'label': '流行', - 'value': [1, 0, 3, 4, 5, 4, 1, 2, 1, 0], - 'default': true, - }, - { - 'label': '摇滚', - 'value': [2, 3, 4, 5, 4, 3, 2, 1, 0, -1], - 'default': true, - }, - { - 'label': '爵士', - 'value': [3, 4, 0, 4, 3, 2, 1, 0, -1, -2], - 'default': true, - }, - { - 'label': '经典', - 'value': [3, 4, 2, 4, 3, 2, 1, 0, -1, -2], - 'default': true, - }, - { - 'label': '乡村', - 'value': [3, 4, 1, 4, 3, 2, 1, 0, -1, -2], - 'default': true, - }, - ], - 'xAxis': [ - '8', - '31', - '62', - '125', - '250', - '500', - '1K', - '2K', - '4K', - '16K' - ], - 'onCallback': (List value) { - Logger.d('equalizer', '均衡器:$value'); - }, - } - ], - }, - { - 'title': '按键功能', - 'templates': [ - { - 'type': 'keypress', - 'data': [ - {'key': 'key1', 'label': '单击', 'left': 1, 'right': 1}, - {'key': 'key2', 'label': '双击', 'left': 1, 'right': 1}, - {'key': 'key3', 'label': '三击', 'left': 1, 'right': 1}, - {'key': 'key4', 'label': '长按', 'left': 1, 'right': 1}, - ], - 'options': [ - {'label': '无作用', 'value': 0}, - {'label': '回拨电话', 'value': 1}, - {'label': '语音助手', 'value': 2}, - {'label': '上一曲', 'value': 3}, - {'label': '下一曲', 'value': 4}, - {'label': '音量加', 'value': 5}, - {'label': '音量减', 'value': 6}, - {'label': '播放暂停', 'value': 7}, - {'label': '游戏模式', 'value': 8}, - {'label': '降噪设置', 'value': 9}, - {'label': 'EQ切换', 'value': 10}, - ], - 'onCallback': (Map value) { - Logger.d('keypress', '按键功能:$value'); - }, - }, - ], - }, - { - 'title': '耳机功能', - 'templates': [ - { - 'type': 'input', - 'name': '修改名称', - 'value': 'eaimar', - 'onCallback': (String value) { - Logger.d('input', '修改名称:$value'); - }, - }, - { - 'type': 'radio', - 'name': '工作模式', - 'value': 1, - 'options': [ - {'label': '游戏模式', 'value': 1}, - {'label': '娱乐模式', 'value': 2}, - {'label': '睡眠模式', 'value': 3}, - ], - 'onCallback': (int value) { - Logger.d('radio', '工作模式:$value'); - }, - }, - { - 'type': 'switch', - 'name': '闪灯开关', - 'value': false, - 'onCallback': (bool value) { - Logger.d('switch', '闪灯开关:$value'); - }, - }, - { - 'type': 'switch', - 'name': '查找设备', - 'value': false, - 'description': '在设备上播放声音', - 'onCallback': (bool value) { - Logger.d('switch', '查找设备:$value'); - }, - }, - { - 'type': 'grade', - 'name': '降噪等级', - 'value': 0, - 'onCallback': (int value) { - Logger.d('grade', '降噪等级:$value'); - }, - }, - { - 'type': 'timer', - 'name': '定时关机', - 'value': 0, - 'onCallback': (int value) { - Logger.d('timer', '定时关机:$value'); - }, - }, - ], - }, - { - 'templates': [ - { - 'type': 'confirm', - 'name': '升级耳机固件', - 'onCallback': () => Get.toNamed(Routes.OTA_UPGRADE), - }, - { - 'type': 'confirm', - 'name': '恢复出厂设置', - 'tips': '确定要恢复出厂设置吗?', - 'onCallback': () { - Logger.d('confirm', '恢复出厂设置'); - }, - }, - { - 'type': 'confirm', - 'name': '清除配对记录', - 'tips': '确定要清除配对记录吗?', - 'onCallback': () { - Logger.d('confirm', '清除配对记录'); - }, - }, - ], - } - ].obs; - - final GetStorage _storage = GetStorage(); - - @override - void onInit() { - super.onInit(); - final List equalizerOptions = _storage.read('equalizer_options') ?? []; - if (equalizerOptions.isNotEmpty) { - List options = - equalizerOptions.map((e) => Map.from(e)).toList(); - uiList[0]['templates'][0]['options'].addAll(options); - } - } - - Widget getTemplate(Map data) { - return switch (data['type']) { - 'equalizer' => RowEqualizerWidget(data), - 'keypress' => RowKeypressWidget(data), - 'input' => RowInputWidget(data), - 'radio' => RowRadioWidget(data), - 'switch' => RowSwitchWidget(data), - 'grade' => RowGradeWidget(data), - 'timer' => RowTimerWidget(data), - 'confirm' => RowConfirmWidget(data), - _ => const SizedBox.shrink(), - }; - } -} diff --git a/apps/client/lib/modules/eq_settings/views/eq_settings_view.dart b/apps/client/lib/modules/eq_settings/views/eq_settings_view.dart deleted file mode 100644 index 87f3cddd..00000000 --- a/apps/client/lib/modules/eq_settings/views/eq_settings_view.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -import '../controllers/eq_settings_controller.dart'; - -class EqSettingsView extends GetView { - const EqSettingsView({super.key}); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - appBar: _buildAppBar(isDarkMode), - body: SafeArea( - top: false, - child: _buildBody(isDarkMode), - ), - ); - } - - // 构建应用栏 - PreferredSizeWidget _buildAppBar(bool isDarkMode) { - return AppBar( - backgroundColor: isDarkMode ? Colors.black : Colors.white, - surfaceTintColor: Colors.transparent, - elevation: 0.5, - leading: IconButton( - icon: Icon( - Icons.arrow_back_ios, - color: isDarkMode ? Colors.white : Colors.black, - size: 20.sp, - ), - onPressed: () => Get.back(), - ), - title: Text( - '设备设置', - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - centerTitle: true, - ); - } - - // 构建主体内容 - Widget _buildBody(bool isDarkMode) { - return Obx( - () => ListView.builder( - itemCount: controller.uiList.length, - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.w), - itemBuilder: (BuildContext context, int index) { - Map data = controller.uiList[index]; - return _item(isDarkMode, data); - }, - ), - ); - } - - Widget _item(bool isDarkMode, Map data) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w), - margin: EdgeInsets.symmetric(vertical: 5.w), - decoration: BoxDecoration( - color: isDarkMode ? Colors.white.withValues(alpha: 0.05) : Colors.white, - borderRadius: BorderRadius.circular(8.w), - boxShadow: [ - BoxShadow( - color: isDarkMode - ? Colors.black.withValues(alpha: 0.2) - : Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (data['title'] != null) - Text( - data['title'], - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ...data['templates'].map((e) => controller.getTemplate(e)).toList(), - ], - ), - ); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/row_confirm_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/row_confirm_widget.dart deleted file mode 100644 index ce680b1d..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/row_confirm_widget.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -class RowConfirmWidget extends StatefulWidget { - final Map templateData; - - const RowConfirmWidget(this.templateData, {super.key}); - - @override - State createState() => _RowConfirmWidgetState(); -} - -class _RowConfirmWidgetState extends State { - Map get _templateData => widget.templateData; - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return GestureDetector( - onTap: () { - if (_templateData['tips'] != null) { - _alertDialog(isDarkMode); - } else { - _templateData['onCallback'](); - } - }, - behavior: HitTestBehavior.translucent, - child: Padding( - padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), - child: Row( - children: [ - Expanded( - child: Text( - _templateData['name'], - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - Icon( - Icons.chevron_right, - size: 26.w, - color: Colors.blue, - ), - ], - ), - ), - ); - } - - void _alertDialog(bool isDarkMode) { - Get.dialog( - AlertDialog( - backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, - title: Text( - _templateData['name'], - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black87, - ), - ), - content: Text( - _templateData['tips'], - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text( - 'cancel'.tr, // 取消 - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ), - TextButton( - onPressed: () { - _templateData['onCallback'](); - Get.back(); - }, - child: Text( - 'confirm'.tr, // 确定 - style: TextStyle( - color: isDarkMode ? Colors.red[300] : Colors.red[600], - ), - ), - ), - ], - ), - ); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/row_equalizer_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/row_equalizer_widget.dart deleted file mode 100644 index cccd2473..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/row_equalizer_widget.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -import 'select_equalizer_widget.dart'; - -class RowEqualizerWidget extends StatefulWidget { - final Map templateData; - - const RowEqualizerWidget(this.templateData, {super.key}); - - @override - State createState() => _RowEqualizerWidgetState(); -} - -class _RowEqualizerWidgetState extends State { - Map get _templateData => widget.templateData; - - String _label = ''; - - @override - void initState() { - super.initState(); - _label = _templateData['options'].firstWhere( - (option) => listEquals(_templateData['value'], option['value']), - orElse: () => {'label': ''}, - )['label']; - } - - void _setLabel() { - setState(() { - _label = _templateData['options'].firstWhere( - (option) => listEquals(_templateData['value'], option['value']), - orElse: () => {'label': ''}, - )['label']; - }); - } - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return GestureDetector( - onTap: () async { - var result = await Get.to(() => SelectEqualizerWidget(_templateData)); - _setLabel(); - if (result != null) { - _templateData['onCallback'](_templateData['value']); - } - }, - behavior: HitTestBehavior.translucent, - child: Padding( - padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), - child: Row( - children: [ - Expanded( - child: Text( - _templateData['name'], - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - Text( - _label, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white : Colors.grey, - ), - ), - Icon( - Icons.chevron_right, - size: 26.w, - color: Colors.blue, - ), - ], - ), - ), - ); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/row_grade_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/row_grade_widget.dart deleted file mode 100644 index 8abd2d32..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/row_grade_widget.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; - -class RowGradeWidget extends StatefulWidget { - final Map templateData; - - const RowGradeWidget(this.templateData, {super.key}); - - @override - State createState() => _RowGradeWidgetState(); -} - -class _RowGradeWidgetState extends State { - Map get _templateData => widget.templateData; - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Padding( - padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), - child: Row( - children: [ - Expanded( - child: Text( - _templateData['name'], - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - Slider( - value: _templateData['value'].toDouble(), - min: 0, - max: 10, - divisions: 10, - activeColor: Colors.blue, - onChanged: (value) { - setState(() { - _templateData['value'] = value.toInt(); - }); - }, - onChangeEnd: (value) { - _templateData['onCallback'](_templateData['value']); - }, - ), - SizedBox( - width: 15.w, - child: Text( - _templateData['value'].toString(), - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white : Colors.grey, - ), - ), - ), - ], - ), - ); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/row_input_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/row_input_widget.dart deleted file mode 100644 index 339b79ce..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/row_input_widget.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -import 'settings_input_widget.dart'; - -class RowInputWidget extends StatefulWidget { - final Map templateData; - - const RowInputWidget(this.templateData, {super.key}); - - @override - State createState() => _RowInputWidgetState(); -} - -class _RowInputWidgetState extends State { - Map get _templateData => widget.templateData; - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return GestureDetector( - onTap: () => _inputBottomSheet(isDarkMode), - behavior: HitTestBehavior.translucent, - child: Padding( - padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), - child: Row( - children: [ - Expanded( - child: Text( - _templateData['name'], - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - Text( - _templateData['value'], - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white : Colors.grey, - ), - ), - Icon( - Icons.chevron_right, - size: 26.w, - color: Colors.blue, - ), - ], - ), - ), - ); - } - - void _inputBottomSheet(bool isDarkMode) { - Get.bottomSheet( - SettingsInputWidget(_templateData['name'], _templateData['value']), - isScrollControlled: true, - backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.r), - ), - ), - ).then((value) { - if (value != null) { - setState(() { - _templateData['value'] = value; - }); - _templateData['onCallback'](value); - } - }); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/row_keypress_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/row_keypress_widget.dart deleted file mode 100644 index 2e772201..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/row_keypress_widget.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -class RowKeypressWidget extends StatefulWidget { - final Map templateData; - - const RowKeypressWidget(this.templateData, {super.key}); - - @override - State createState() => _RowKeypressWidgetState(); -} - -class _RowKeypressWidgetState extends State { - Map get _templateData => widget.templateData; - - String _getLabel(int value) { - return _templateData['options'].firstWhere((item) => item['value'] == value, - orElse: () => {'label': ''})['label']; - } - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Column( - children: [ - Padding( - padding: EdgeInsets.symmetric(vertical: 10.w), - child: Row( - children: [ - const Spacer(), - Expanded( - child: Text( - '左', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - Expanded( - child: Text( - '右', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - ], - ), - ), - ..._templateData['data'] - .map((item) => _rowItem(isDarkMode, item)) - .toList(), - ], - ); - } - - Widget _rowItem(bool isDarkMode, Map item) { - return Row( - children: [ - Expanded( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 10.w), - child: Text( - item['label'], - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - ), - Expanded( - child: GestureDetector( - onTap: () => _radioDialog(isDarkMode, item, 'left'), - behavior: HitTestBehavior.translucent, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 10.w), - child: Text( - _getLabel(item['left']), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white : Colors.grey, - ), - ), - ), - ), - ), - Expanded( - child: GestureDetector( - onTap: () => _radioDialog(isDarkMode, item, 'right'), - behavior: HitTestBehavior.translucent, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 10.w), - child: Text( - _getLabel(item['right']), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white : Colors.grey, - ), - ), - ), - ), - ), - ], - ); - } - - void _radioDialog(bool isDarkMode, Map item, String key) { - Get.dialog( - Dialog( - backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - child: Padding( - padding: EdgeInsets.all(12.w), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${item['label']}-${key == 'left' ? '左' : '右'}', - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black87, - ), - ), - SizedBox(height: 16.h), - Container( - constraints: BoxConstraints(maxHeight: 0.5.sh), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: _templateData['options'] - .map((option) => - _radioItem(isDarkMode, option, item, key)) - .toList(), - ), - ), - ), - SizedBox(height: 16.h), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Get.back(), - child: Text( - 'cancel'.tr, // 取消 - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _radioItem(bool isDarkMode, Map option, Map item, String key) { - return GestureDetector( - onTap: () { - setState(() { - item[key] = option['value']; - }); - Map data = { - 'key': item['key'], - key: option['value'], - }; - _templateData['onCallback'](data); - Get.back(); - }, - child: Container( - margin: EdgeInsets.only(bottom: 10.h), - padding: EdgeInsets.all(10.w), - decoration: BoxDecoration( - color: item[key] == option['value'] - ? (isDarkMode - ? Colors.teal[900]!.withValues(alpha: 0.3) - : Colors.teal[50]!) - : (isDarkMode - ? Colors.white.withValues(alpha: 0.05) - : Colors.grey[100]!), - borderRadius: BorderRadius.circular(12.r), - border: Border.all( - color: item[key] == option['value'] - ? (isDarkMode ? Colors.teal[300]! : Colors.teal[600]!) - : Colors.transparent, - width: 1, - ), - ), - child: Row( - children: [ - Expanded( - child: Text( - option['label'], - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white : Colors.black87, - ), - overflow: TextOverflow.ellipsis, - ), - ), - Container( - width: 24.w, - height: 24.w, - decoration: BoxDecoration( - color: item[key] == option['value'] - ? (isDarkMode ? Colors.teal[300] : Colors.teal[600]) - : (isDarkMode - ? Colors.white.withValues(alpha: 0.1) - : Colors.grey[300]), - shape: BoxShape.circle, - ), - child: item[key] == option['value'] - ? Icon( - Icons.check, - size: 16.w, - color: Colors.white, - ) - : null, - ), - ], - ), - ), - ); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/row_radio_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/row_radio_widget.dart deleted file mode 100644 index 6dbfd46f..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/row_radio_widget.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -class RowRadioWidget extends StatefulWidget { - final Map templateData; - - const RowRadioWidget(this.templateData, {super.key}); - - @override - State createState() => _RowRadioWidgetState(); -} - -class _RowRadioWidgetState extends State { - Map get _templateData => widget.templateData; - - String _label = ''; - - @override - void initState() { - super.initState(); - _label = _templateData['options'].firstWhere( - (option) => option['value'] == _templateData['value'], - orElse: () => {'label': ''}, - )['label']; - } - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return GestureDetector( - onTap: () => _radioDialog(isDarkMode), - behavior: HitTestBehavior.translucent, - child: Padding( - padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), - child: Row( - children: [ - Expanded( - child: Text( - _templateData['name'], - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - Text( - _label, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white : Colors.grey, - ), - ), - Icon( - Icons.chevron_right, - size: 26.w, - color: Colors.blue, - ), - ], - ), - ), - ); - } - - void _radioDialog(bool isDarkMode) { - Get.dialog( - Dialog( - backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - child: Padding( - padding: EdgeInsets.all(12.w), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _templateData['name'], - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black87, - ), - ), - SizedBox(height: 16.h), - Container( - constraints: BoxConstraints(maxHeight: 0.5.sh), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: _templateData['options'] - .map((option) => _item(isDarkMode, option)) - .toList(), - ), - ), - ), - SizedBox(height: 16.h), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Get.back(), - child: Text( - 'cancel'.tr, // 取消 - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _item(bool isDarkMode, Map option) { - return GestureDetector( - onTap: () { - setState(() { - _label = option['label']; - }); - _templateData['value'] = option['value']; - _templateData['onCallback'](option['value']); - Get.back(); - }, - child: Container( - margin: EdgeInsets.only(bottom: 10.h), - padding: EdgeInsets.all(10.w), - decoration: BoxDecoration( - color: _templateData['value'] == option['value'] - ? (isDarkMode - ? Colors.teal[900]!.withValues(alpha: 0.3) - : Colors.teal[50]!) - : (isDarkMode - ? Colors.white.withValues(alpha: 0.05) - : Colors.grey[100]!), - borderRadius: BorderRadius.circular(12.r), - border: Border.all( - color: _templateData['value'] == option['value'] - ? (isDarkMode ? Colors.teal[300]! : Colors.teal[600]!) - : Colors.transparent, - width: 1, - ), - ), - child: Row( - children: [ - Expanded( - child: Text( - option['label'], - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white : Colors.black87, - ), - overflow: TextOverflow.ellipsis, - ), - ), - Container( - width: 24.w, - height: 24.w, - decoration: BoxDecoration( - color: _templateData['value'] == option['value'] - ? (isDarkMode ? Colors.teal[300] : Colors.teal[600]) - : (isDarkMode - ? Colors.white.withValues(alpha: 0.1) - : Colors.grey[300]), - shape: BoxShape.circle, - ), - child: _templateData['value'] == option['value'] - ? Icon( - Icons.check, - size: 16.w, - color: Colors.white, - ) - : null, - ), - ], - ), - ), - ); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/row_switch_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/row_switch_widget.dart deleted file mode 100644 index e21223ba..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/row_switch_widget.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; - -class RowSwitchWidget extends StatefulWidget { - final Map templateData; - - const RowSwitchWidget(this.templateData, {super.key}); - - @override - State createState() => _RowSwitchWidgetState(); -} - -class _RowSwitchWidgetState extends State { - Map get _templateData => widget.templateData; - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Padding( - padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), - child: Row( - children: [ - Expanded( - child: Text( - _templateData['name'], - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - if (_templateData['description'] != null) - Text( - _templateData['description'], - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white : Colors.grey, - ), - ), - SizedBox( - height: 26.w, - child: Transform.scale( - scale: 0.8, - alignment: Alignment.centerRight, - child: Switch( - value: _templateData['value'], - onChanged: (bool value) { - setState(() { - _templateData['value'] = value; - }); - _templateData['onCallback'](value); - }, - ), - ), - ), - ], - ), - ); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/row_timer_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/row_timer_widget.dart deleted file mode 100644 index 2a950f60..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/row_timer_widget.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -import 'select_timer_widget.dart'; - -class RowTimerWidget extends StatefulWidget { - final Map templateData; - - const RowTimerWidget(this.templateData, {super.key}); - - @override - State createState() => _RowTimerWidgetState(); -} - -class _RowTimerWidgetState extends State { - Map get _templateData => widget.templateData; - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return GestureDetector( - onTap: () => _timerBottomSheet(isDarkMode), - behavior: HitTestBehavior.translucent, - child: Padding( - padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), - child: Row( - children: [ - Expanded( - child: Text( - _templateData['name'], - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - if (_templateData['value'] > 0) - Text( - '${_templateData['value']}分钟', - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white : Colors.grey, - ), - ), - Icon( - Icons.chevron_right, - size: 26.w, - color: Colors.blue, - ), - ], - ), - ), - ); - } - - void _timerBottomSheet(bool isDarkMode) { - Get.bottomSheet( - SelectTimerWidget(_templateData['name'], _templateData['value']), - isScrollControlled: true, - backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.r), - ), - ), - ).then((value) { - if (value != null) { - setState(() { - _templateData['value'] = value; - }); - _templateData['onCallback'](value); - } - }); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/select_equalizer_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/select_equalizer_widget.dart deleted file mode 100644 index 60178212..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/select_equalizer_widget.dart +++ /dev/null @@ -1,308 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; - -import 'settings_input_widget.dart'; - -class SelectEqualizerWidget extends StatefulWidget { - final Map templateData; - - const SelectEqualizerWidget(this.templateData, {super.key}); - - @override - State createState() => _SelectEqualizerWidgetState(); -} - -class _SelectEqualizerWidgetState extends State { - Map get _templateData => widget.templateData; - - List _values = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - - bool isEdit = false; - - final GetStorage _storage = GetStorage(); - List _equalizerOptions = []; - - @override - void initState() { - super.initState(); - if (_templateData['value'].isNotEmpty) { - _values = List.from(_templateData['value']); - } - _equalizerOptions = _storage.read('equalizer_options') ?? []; - } - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - appBar: _buildAppBar(isDarkMode), - body: SafeArea( - top: false, - child: _buildBody(isDarkMode), - ), - ); - } - - // 构建应用栏 - PreferredSizeWidget _buildAppBar(bool isDarkMode) { - return AppBar( - backgroundColor: isDarkMode ? Colors.black : Colors.white, - surfaceTintColor: Colors.transparent, - elevation: 0.5, - leading: IconButton( - icon: Icon( - Icons.arrow_back_ios, - color: isDarkMode ? Colors.white : Colors.black, - size: 20.sp, - ), - onPressed: () => Get.back(), - ), - title: Text( - _templateData['name'], - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - centerTitle: true, - actions: [ - GestureDetector( - onTap: () { - setState(() { - isEdit = !isEdit; - }); - }, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 12.w, - vertical: 6.w, - ), - child: Text( - isEdit ? 'cancel'.tr : 'edit'.tr, - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black87, - fontSize: 16.sp, - ), - ), - ), - ), - ], - ); - } - - // 构建主体内容 - Widget _buildBody(bool isDarkMode) { - bool isAdd = true; - - return ListView( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w), - children: [ - _equalizer(isDarkMode), - Wrap( - spacing: 10.w, - runSpacing: 10.w, - children: [ - ..._templateData['options'].map((Map option) { - bool isSelected = listEquals(_values, option['value']); - if (isSelected) { - isAdd = false; - } - return _item(isDarkMode, option, isSelected); - }).toList(), - if (isAdd) - GestureDetector( - onTap: () => _inputBottomSheet(isDarkMode), - child: Container( - width: 110.w, - height: 110.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.05) - : Colors.white, - borderRadius: BorderRadius.circular(8.w), - boxShadow: [ - BoxShadow( - color: isDarkMode - ? Colors.black.withValues(alpha: 0.2) - : Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Icon( - Icons.add, - size: 40.sp, - color: Colors.green, - ), - ), - ), - ], - ), - ], - ); - } - - Widget _equalizer(bool isDarkMode) { - return Container( - height: 220.w, - margin: EdgeInsets.only(bottom: 5.w), - child: Row( - children: List.generate( - _templateData['xAxis'].length, - (index) => Expanded( - child: Stack( - children: [ - RotatedBox( - quarterTurns: 3, - child: StatefulBuilder( - builder: (BuildContext context, setStateful) { - return Slider( - value: _values[index].toDouble(), - min: -5, - max: 5, - divisions: 10, - activeColor: Colors.green, - onChanged: (value) { - setStateful(() { - _values[index] = value.toInt(); - }); - }, - onChangeEnd: (value) { - setState(() {}); - }, - ); - }, - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Text( - _templateData['xAxis'][index], - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.grey, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _item(bool isDarkMode, Map option, bool isSelected) { - return GestureDetector( - onTap: () { - _templateData['value'] = option['value']; - Get.back(result: true); - }, - child: Stack( - children: [ - Container( - width: 110.w, - height: 110.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: isSelected - ? Colors.green - : isDarkMode - ? Colors.white.withValues(alpha: 0.05) - : Colors.white, - borderRadius: BorderRadius.circular(8.w), - boxShadow: [ - BoxShadow( - color: isDarkMode - ? Colors.black.withValues(alpha: 0.2) - : Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Text( - option['label'], - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isSelected - ? Colors.white - : isDarkMode - ? Colors.white - : Colors.black, - ), - ), - ), - if (isEdit && !option['default']) - Positioned( - top: 4.w, - right: 4.w, - child: GestureDetector( - onTap: () { - if (isSelected) { - _templateData['value'] = []; - _templateData['onCallback']([]); - } - _equalizerOptions.removeWhere( - (e) => listEquals(e['value'], option['value'])); - _storage.write('equalizer_options', _equalizerOptions); - setState(() { - _templateData['options'].remove(option); - }); - }, - child: Container( - width: 24.w, - height: 24.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(12.w), - ), - child: Icon( - Icons.close, - size: 16.sp, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ); - } - - void _inputBottomSheet(bool isDarkMode) { - Get.bottomSheet( - const SettingsInputWidget('添加均衡器', ''), - isScrollControlled: true, - backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.r), - ), - ), - ).then((value) { - if (value != null) { - Map option = { - 'label': value, - 'value': List.from(_values), - 'default': false, - }; - _equalizerOptions.add(option); - _storage.write('equalizer_options', _equalizerOptions); - setState(() { - _templateData['options'].add(option); - }); - } - }); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/select_timer_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/select_timer_widget.dart deleted file mode 100644 index ec082d4e..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/select_timer_widget.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -class SelectTimerWidget extends StatefulWidget { - final String title; - final int time; - - const SelectTimerWidget(this.title, this.time, {super.key}); - - @override - State createState() => _SelectTimerWidgetState(); -} - -class _SelectTimerWidgetState extends State { - late FixedExtentScrollController _hourScrollController; - late FixedExtentScrollController _minuteScrollController; - - int _hour = 0; - int _minute = 0; - - @override - void initState() { - super.initState(); - _hour = widget.time ~/ 60; - _minute = widget.time % 60; - _hourScrollController = FixedExtentScrollController(initialItem: _hour); - _minuteScrollController = FixedExtentScrollController(initialItem: _minute); - } - - @override - void dispose() { - super.dispose(); - _hourScrollController.dispose(); - _minuteScrollController.dispose(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: 12.w), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: EdgeInsets.symmetric(vertical: 10.w), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - widget.title, - style: TextStyle( - fontSize: 16.sp, - color: Colors.black87, - fontWeight: FontWeight.bold, - ), - ), - GestureDetector( - onTap: () => Get.back(), - child: Container( - width: 26.w, - height: 26.w, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(26.r), - ), - child: Icon( - Icons.close, - size: 18.w, - color: Colors.grey[400], - ), - ), - ) - ], - ), - ), - Divider( - height: 1, - color: Colors.grey[300], - ), - SizedBox( - height: 220.w, - child: Row( - children: [ - Expanded( - child: CupertinoPicker.builder( - itemExtent: 40.w, - diameterRatio: 1.1, - squeeze: 1.25, - childCount: 24, - selectionOverlay: _selectionOverlayWidget( - isRadiusLeft: true, - ), - scrollController: _hourScrollController, - itemBuilder: (BuildContext context, int index) { - return Container( - padding: EdgeInsets.only(right: 10.w), - alignment: Alignment.centerRight, - child: Text( - '$index时', - style: _hour == index - ? TextStyle( - color: const Color(0xFFFF5561), - fontSize: 15.sp, - fontWeight: FontWeight.w500, - ) - : TextStyle( - color: const Color(0xFF9D9D9D), - fontSize: 15.sp, - ), - ), - ); - }, - onSelectedItemChanged: (int index) { - setState(() { - _hour = index; - }); - }, - ), - ), - Expanded( - child: CupertinoPicker.builder( - itemExtent: 40.w, - diameterRatio: 1.1, - squeeze: 1.25, - childCount: 60, - selectionOverlay: _selectionOverlayWidget( - isRadiusRight: true, - ), - scrollController: _minuteScrollController, - itemBuilder: (BuildContext context, int index) { - return Container( - padding: EdgeInsets.only(left: 10.w), - alignment: Alignment.centerLeft, - child: Text( - '$index分', - style: _minute == index - ? TextStyle( - color: const Color(0xFFFF5561), - fontSize: 15.sp, - fontWeight: FontWeight.w500, - ) - : TextStyle( - color: const Color(0xFF9D9D9D), - fontSize: 15.sp, - ), - ), - ); - }, - onSelectedItemChanged: (int index) { - setState(() { - _minute = index; - }); - }, - ), - ), - ], - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 20.w), - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => Get.back(), - child: Container( - height: 48.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(12.r), - ), - child: Text( - 'cancel'.tr, // 取消 - style: TextStyle( - fontSize: 16.sp, - color: Colors.black, - ), - ), - ), - ), - ), - 10.horizontalSpace, - Expanded( - child: GestureDetector( - onTap: () { - int time = _hour * 60 + _minute; - Get.back(result: time); - }, - child: Container( - height: 48.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(12.r), - ), - child: Text( - 'confirm'.tr, // 确认 - style: TextStyle( - fontSize: 16.sp, - color: Colors.white, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _selectionOverlayWidget({ - bool isRadiusLeft = false, - bool isRadiusRight = false, - }) { - return Container( - decoration: BoxDecoration( - color: const Color.fromARGB(45, 255, 85, 97), - borderRadius: BorderRadius.horizontal( - left: isRadiusLeft ? Radius.circular(8.w) : Radius.zero, - right: isRadiusRight ? Radius.circular(8.w) : Radius.zero, - ), - ), - ); - } -} diff --git a/apps/client/lib/modules/eq_settings/views/widgets/settings_input_widget.dart b/apps/client/lib/modules/eq_settings/views/widgets/settings_input_widget.dart deleted file mode 100644 index ea41dd49..00000000 --- a/apps/client/lib/modules/eq_settings/views/widgets/settings_input_widget.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; - -class SettingsInputWidget extends StatefulWidget { - final String title; - final String inputText; - - const SettingsInputWidget(this.title, this.inputText, {super.key}); - - @override - State createState() => _SettingsInputWidgetState(); -} - -class _SettingsInputWidgetState extends State { - final TextEditingController _inputController = TextEditingController(); - - @override - void initState() { - super.initState(); - _inputController.text = widget.inputText; - } - - @override - void dispose() { - _inputController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: 12.w), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: EdgeInsets.symmetric(vertical: 10.w), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - widget.title, - style: TextStyle( - fontSize: 16.sp, - color: Colors.black87, - fontWeight: FontWeight.bold, - ), - ), - GestureDetector( - onTap: () => Get.back(), - child: Container( - width: 26.w, - height: 26.w, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(26.r), - ), - child: Icon( - Icons.close, - size: 18.w, - color: Colors.grey[400], - ), - ), - ) - ], - ), - ), - Divider( - height: 1, - color: Colors.grey[300], - ), - Container( - margin: EdgeInsets.symmetric(vertical: 20.w), - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12.w), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: TextField( - controller: _inputController, - style: TextStyle( - color: Colors.black87, - fontSize: 16.sp, - ), - decoration: InputDecoration( - hintText: 'maxCharacters'.tr, - hintStyle: TextStyle( - color: Colors.black54, - fontSize: 16.sp, - ), - isDense: true, - fillColor: Colors.transparent, - border: InputBorder.none, - contentPadding: EdgeInsets.all(10.w), - ), - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 20.w), - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => Get.back(), - child: Container( - height: 48.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(12.r), - ), - child: Text( - 'cancel'.tr, // 取消 - style: TextStyle( - fontSize: 16.sp, - color: Colors.black, - ), - ), - ), - ), - ), - 10.horizontalSpace, - Expanded( - child: GestureDetector( - onTap: () { - if (_inputController.text.isNotEmpty) { - Get.back(result: _inputController.text); - } - }, - child: Container( - height: 48.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(12.r), - ), - child: Text( - 'confirm'.tr, // 确认 - style: TextStyle( - fontSize: 16.sp, - color: Colors.white, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/apps/client/lib/modules/home/bindings/home_binding.dart b/apps/client/lib/modules/home/bindings/home_binding.dart deleted file mode 100644 index a9439e66..00000000 --- a/apps/client/lib/modules/home/bindings/home_binding.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/home_controller.dart'; -import '../controllers/calendar_controller.dart'; - -class HomeBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => HomeController()); - // 首页日历面板控制器 - Get.lazyPut(() => CalendarController()); - } -} diff --git a/apps/client/lib/modules/home/views/home_view.dart b/apps/client/lib/modules/home/views/home_view.dart deleted file mode 100644 index 7b16b5d9..00000000 --- a/apps/client/lib/modules/home/views/home_view.dart +++ /dev/null @@ -1,520 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '../controllers/home_controller.dart'; -import '../../../routes/app_routes.dart'; -import 'widgets/calendar/calendar_card.dart'; -import 'widgets/calendar/calendar_task_list.dart'; -import '../controllers/calendar_controller.dart'; - -// 首页视图 -class HomeView extends GetView { - const HomeView({Key? key}) : super(key: key); - - // 功能卡片模块(语音记录/翻译/通话录音等)折叠态下的目标可视高度: - // 固定预算而非内容自然高度,避免卡片变多时撑爆首页固定布局; - // 展示不下的部分(如通话录音)在模块内部滚动查看。 - static const double _extrasTargetHeight = 420; - - @override - Widget build(BuildContext context) { - final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; - return Scaffold( - backgroundColor: Colors.transparent, - // 背景:顶部「左淡蓝·中浅蓝·右淡暖」横向晕染,向下渐隐到白(取自参考图真实色值) - body: Stack( - children: [ - // 1) 顶部横向色带(浅色模式)/ 深色竖直渐变(深色模式) - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: isDarkMode - ? const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF0A0F1A), Color(0xFF000000)], - ) - : const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Color(0xFFD4EAFF), // 左:淡蓝 - Color(0xFFE6F3FE), // 中:浅蓝 - Color(0xFFFBF2F0), // 右:淡暖(浅奶粉) - ], - ), - ), - ), - ), - // 2) 竖直方向把顶部色带渐隐到白(仅浅色模式) - if (!isDarkMode) - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0x00FFFFFF), // 顶部透明:露出横向色带 - Color(0x00FFFFFF), - Color(0xFFFFFFFF), // 向下渐变为白 - ], - stops: [0.0, 0.15, 0.42], - ), - ), - ), - ), - // 内容(固定布局:日历展开时任务列表铺满并覆盖下方翻译卡片/AI) - SafeArea( - child: Padding( - // 横向不留白:任务列表需铺满到屏幕边(在屏幕边裁切,阴影完整); - // 其余元素各自加 16 横向内边距对齐 - padding: EdgeInsets.only(top: 16.w, bottom: 8.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 顶部标题栏(固定) - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: _buildAppBar(context, isDarkMode), - ), - SizedBox(height: 16.h), - // 日历 + 任务列表 + 翻译卡片/AI(随日历折叠/展开切换) - Expanded(child: _buildCalendarArea(isDarkMode)), - ], - ), - ), - ), - ], - ), - ); - } - - // 日历区域:日历卡片 + 任务列表(Expanded) + 翻译卡片/AI(随展开收起) - Widget _buildCalendarArea(bool isDarkMode) { - final cal = Get.find(); - return LayoutBuilder( - builder: (context, constraints) { - final double h = constraints.maxHeight; - // 极小高度(超大字体/超小屏)兜底:退回可滚动布局,避免溢出 - if (h < 360.h) { - return _buildCalendarScrollFallback(isDarkMode); - } - // 月历网格最大高度夹取:留出间距 + 最小任务列表 + 卡片其余高度,避免 Expanded 负高 - final double maxBody = h - 16.h - 96.h - 130.h; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: CalendarCard(maxBodyHeight: maxBody > 0 ? maxBody : null), - ), - SizedBox(height: 16.h), - // 任务列表:填满任务区(连续高度,便于折叠/展开两层按下拉距离交叉淡入淡出)。 - const Expanded(child: CalendarTaskList()), - // 翻译卡片 + AI:随日历展开(dragProgress→0)从底部收起、被覆盖。 - // 用固定目标高度(非内容自然高度)做插值:卡片模块内容一旦变多(如新增 - // "通话录音")就不会撑爆这个不滚动的整屏布局;模块内部改用可滚动容器 - // 兜底——展示不下的卡片(如通话录音)靠上下滑动查看,而不是让布局溢出。 - Obx(() => ClipRect( - child: SizedBox( - height: _extrasTargetHeight.h * cal.dragProgress.value, - child: IgnorePointer( - ignoring: cal.dragProgress.value < 1.0, - child: SingleChildScrollView( - padding: EdgeInsets.symmetric(horizontal: 16.w), - physics: const BouncingScrollPhysics(), - child: _buildExtrasBlock(isDarkMode), - ), - ), - ), - )), - ], - ); - }, - ); - } - - // 翻译功能卡片(折叠态显示在任务列表下方;展开态被收起覆盖) - // AI 球已改为全局悬浮球(core/widgets/global_ai_orb.dart),不再嵌在首页里 - Widget _buildExtrasBlock(bool isDarkMode) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 16.h), - _buildFeatureGrid(isDarkMode), - SizedBox(height: 24.h), - ], - ); - } - - // 极小屏兜底:可滚动布局(任务列表给有限高度) - Widget _buildCalendarScrollFallback(bool isDarkMode) { - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: const CalendarCard(), - ), - SizedBox(height: 16.h), - SizedBox(height: 280.h, child: const CalendarTaskList()), - SizedBox(height: 16.h), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: _buildFeatureGrid(isDarkMode), - ), - SizedBox(height: 24.h), - ], - ), - ); - } - - // 顶部标题栏:左侧设备管理,右侧“语音打断”开关 + 设置入口 - Widget _buildAppBar(BuildContext context, bool isDarkMode) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // 左侧:设备管理入口 - _buildDeviceManager(isDarkMode), - // 右侧:标语 + 设置图标 - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'YM开启科技人生', - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white70 : Colors.grey[700], - ), - ), - SizedBox(width: 8.w), - _buildSettingsEntry(isDarkMode), - ], - ), - ], - ); - } - - // 左侧设备管理:未连接显示“添加设备”,已连接显示电量 - Widget _buildDeviceManager(bool isDarkMode) { - final Color pillBg = isDarkMode ? const Color(0xFF1A1A1A) : Colors.white; - final Color textColor = isDarkMode ? Colors.white : const Color(0xFF222222); - return Obx(() { - final bool connected = controller.isFullyConnectedRx.value; - return GestureDetector( - behavior: HitTestBehavior.opaque, - // 未连接 → 扫描页;已连接 → 我的设备 - onTap: () => Get.toNamed(connected ? Routes.myDevices : Routes.devices), - child: Container( - padding: EdgeInsets.fromLTRB(6.w, 6.h, 14.w, 6.h), - decoration: BoxDecoration( - color: pillBg, - borderRadius: BorderRadius.circular(24.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.25 : 0.08), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // 蓝色圆形图标 - Container( - width: 26.w, - height: 26.w, - decoration: const BoxDecoration( - color: Color(0xFF3B82F6), - shape: BoxShape.circle, - ), - child: Icon( - connected ? Icons.battery_full : Icons.add, - color: Colors.white, - size: connected ? 16.sp : 18.sp, - ), - ), - SizedBox(width: 8.w), - Text( - connected ? controller.getBatteryText() : 'addDevice'.tr, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.bold, - color: textColor, - ), - ), - ], - ), - ), - ); - }); - } - - // 右侧“语音打断”开关(保留原有逻辑与样式) - // 设置入口:头像/人形图标,点击进入设置页 - Widget _buildSettingsEntry(bool isDarkMode) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => Get.toNamed(Routes.settings), - child: Container( - width: 36.w, - height: 36.w, - decoration: BoxDecoration( - color: isDarkMode - ? const Color(0xFF2A2A2A) - : Colors.white.withOpacity(0.7), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.25 : 0.08), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: Icon( - Icons.person, - size: 22.sp, - color: isDarkMode ? Colors.white70 : Colors.grey[700], - ), - ), - ); - } - - // 功能卡片区:顶部(通话录音/现场录音,依赖设备连接) + 左1大(语音记录助手) + - // 右2(同声翻译/面对面翻译) + 下2(通话翻译/多媒体翻译) - Widget _buildFeatureGrid(bool isDarkMode) { - const double smallH = 84; // 小卡高度 - const double gap = 12; // 卡片间距 - return Column( - children: [ - // 顶区:通话录音 + 现场录音(依赖设备连接,未连接时置灰/透明) - Obx(() { - final bool connected = controller.isFullyConnectedRx.value; - return Row( - children: [ - Expanded( - child: _buildFeatureCard( - title: '通话录音', - icon: Icons.fiber_manual_record, - gradient: const [Color(0xFFEF5350), Color(0xFFE53935)], - height: smallH.h, - isDarkMode: isDarkMode, - enabled: connected, - onTap: controller.openCallRecording, - ), - ), - SizedBox(width: gap.w), - Expanded( - child: _buildFeatureCard( - title: '现场录音', - icon: Icons.mic, - gradient: const [Color(0xFF26C6DA), Color(0xFF00838F)], - height: smallH.h, - isDarkMode: isDarkMode, - onTap: controller.openFieldRecording, - ), - ), - ], - ); - }), - SizedBox(height: gap.h), - // 上区:左侧高卡 + 右侧两张卡 - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _buildFeatureCard( - title: '语音记录助手', - subtitle: '实时记录各种场景语音', - icon: Icons.groups_2, - gradient: const [Color(0xFFAB47BC), Color(0xFFEC407A)], - height: (smallH * 2 + gap).h, - iconOnTop: true, - isDarkMode: isDarkMode, - onTap: controller.openMeet, // → 会议(Routes.meeting) - ), - ), - SizedBox(width: gap.w), - Expanded( - child: Column( - children: [ - _buildFeatureCard( - title: '同声翻译', - icon: Icons.record_voice_over, - gradient: const [Color(0xFF4CAF50), Color(0xFF009688)], - height: smallH.h, - isDarkMode: isDarkMode, - onTap: () => - controller.openTranslationFeature('simultaneous'), - ), - SizedBox(height: gap.h), - _buildFeatureCard( - title: '面对面翻译', - icon: Icons.forum, - gradient: const [Color(0xFF42A5F5), Color(0xFF1E88E5)], - height: smallH.h, - isDarkMode: isDarkMode, - onTap: () => - controller.openTranslationFeature('faceToFace'), - ), - ], - ), - ), - ], - ), - SizedBox(height: gap.h), - // 下区:通话翻译 + 多媒体翻译(依赖设备连接,未连接时置灰/透明) - Obx(() { - final bool connected = controller.isFullyConnectedRx.value; - return Row( - children: [ - Expanded( - child: _buildFeatureCard( - title: '通话翻译', - icon: Icons.phone_in_talk, - gradient: const [Color(0xFFFFA726), Color(0xFFFB8C00)], - height: smallH.h, - isDarkMode: isDarkMode, - enabled: connected, - onTap: () => controller.openTranslationFeature('call'), - ), - ), - SizedBox(width: gap.w), - Expanded( - child: _buildFeatureCard( - title: '多媒体翻译', - icon: Icons.photo_camera, - gradient: const [Color(0xFF5C6BC0), Color(0xFF3949AB)], - height: smallH.h, - isDarkMode: isDarkMode, - onTap: () => controller.openTranslationFeature('audioVideo'), - ), - ), - ], - ); - }), - ], - ); - } - - // 通用功能卡片 - Widget _buildFeatureCard({ - required String title, - String? subtitle, - required IconData icon, - required List gradient, - required double height, - required bool isDarkMode, - required VoidCallback onTap, - bool iconOnTop = false, - bool enabled = true, // false 时整卡变透明(未连接设备) - }) { - final Color cardBg = isDarkMode ? const Color(0xFF1A1A1A) : Colors.white; - final Color titleColor = - isDarkMode ? Colors.white : const Color(0xFF222222); - final Color subColor = - isDarkMode ? Colors.white60 : const Color(0xFF888888); - final Widget iconBox = Container( - width: 42.w, - height: 42.w, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: gradient, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12.r), - ), - child: Icon(icon, color: Colors.white, size: 22.sp), - ); - return Opacity( - opacity: enabled ? 1.0 : 0.4, // 未连接:整卡变透明(仍可点击 → 弹"请连接设备") - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Container( - height: height, - padding: EdgeInsets.all(14.w), - decoration: BoxDecoration( - color: cardBg, - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.06 : 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: iconOnTop - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - iconBox, - const Spacer(), - Text( - title, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: titleColor, - ), - ), - if (subtitle != null) ...[ - SizedBox(height: 4.h), - Text( - subtitle, - style: TextStyle(fontSize: 12.sp, color: subColor), - ), - ], - ], - ) - : Row( - children: [ - iconBox, - SizedBox(width: 12.w), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.bold, - color: titleColor, - ), - ), - if (subtitle != null) ...[ - SizedBox(height: 2.h), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: - TextStyle(fontSize: 11.sp, color: subColor), - ), - ], - ], - ), - ), - ], - ), - ), - ), - ); - } - -} diff --git a/apps/client/lib/modules/meeting/utils/jieli_call_recorder.dart b/apps/client/lib/modules/meeting/utils/jieli_call_recorder.dart deleted file mode 100644 index e611ef9d..00000000 --- a/apps/client/lib/modules/meeting/utils/jieli_call_recorder.dart +++ /dev/null @@ -1,242 +0,0 @@ -// 通话录音 Dart 端 PCM 写入器(设备录音通路 / stereo) -// -// 早先曾走过两版: -// 1) deviceRecordStart (MODE_CALL_TRANSLATION + ch=1, ALWAYS_RECORDING): -// 监听 uplink + downlink 两路 mono 配对写 stereo WAV。 -// 问题:那条路是为真实 SCO 电话通话准备的,没有通话期间设备不会推任何 -// PCM,本地测试无法采样。 -// 2) assistantStart (MODE_RECORD + ch=1, ALWAYS_RECORDING): -// 只能拿到本端单声道,对端音丢失,无法满足"会议记录"对双方音轨的需求。 -// -// 当前实现回到 deviceRecordStart,但 native 已经把 mode 切到 -// MODE_CALL_TRANSLATION_WITH_STEREO + ch=2 + STRATEGY_DEVICE_ALWAYS_RECORDING。 -// 耳机持续上推 SOURCE_E_SCO_MIX 双声道交织 OPUS(与真实通话状态解耦), -// 由 device_jieli 解码成 16k/16bit/stereo PCM(L=本端 R=对端)后,按 -// [DeviceRecordAudioEvent] 推到 Dart。本类直接写成标准 stereo WAV。 -// -// 注意:device_jieli 设备录音通路无原生 pause API,本类的暂停语义通过 -// 在 Dart 层丢弃接收到的 PCM 帧实现。 -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:device_jieli/device_jieli.dart'; - -import '/core/utils/logger.dart'; - -class JieliCallRecorder { - JieliCallRecorder({ - this.sampleRate = 16000, - this.bitsPerSample = 16, - this.onWavePcm, - }); - - /// 采样率(Hz)。device_jieli 设备录音通路固定 16000。 - final int sampleRate; - - /// 位深,固定 16bit。 - final int bitsPerSample; - - /// 写入文件后的 stereo PCM 通知(与 WAV 同步),用于驱动上层波形/振幅。 - /// payload 是 stereo 交织 PCM,调用方如需做 mono 振幅可自取一边或求平均。 - final void Function(Uint8List pcm)? onWavePcm; - - static const String _tag = 'JieliCallRecorder'; - static const int _channels = 2; // stereo: L=本端 R=对端 - - StreamSubscription? _eventSub; - RandomAccessFile? _raf; - String? _filePath; - int _dataBytes = 0; - bool _paused = false; - bool _running = false; - - bool get isRunning => _running; - String? get filePath => _filePath; - - /// 启动录音:写 WAV 头占位、订阅事件、调起设备录音通路。 - Future start(String filePath) async { - if (_running) { - Logger.w(_tag, '重复 start 调用,已忽略'); - return false; - } - try { - final file = File(filePath); - final dir = file.parent; - if (!await dir.exists()) { - await dir.create(recursive: true); - } - if (await file.exists()) { - await file.delete(); - } - _raf = await file.open(mode: FileMode.write); - await _raf!.writeFrom(_makeWavHeader(0)); - - _dataBytes = 0; - _filePath = filePath; - _paused = false; - - _eventSub = Jielihome.instance.events.listen(_onEvent); - - try { - // 防御性先 stop,避免外部已有录音通路在跑导致 start 行为不确定 - try { - await Jielihome.instance.deviceRecordStop(); - } catch (_) {} - await Jielihome.instance.deviceRecordStart(sampleRate: sampleRate); - } catch (e) { - Logger.error('$_tag deviceRecordStart 失败: $e'); - await _shutdown(saveFile: false); - return false; - } - - _running = true; - Logger.info( - '$_tag 启动通话录音 path=$filePath sr=$sampleRate ch=$_channels (设备录音通路 / stereo)'); - return true; - } catch (e) { - Logger.error('$_tag 启动失败: $e'); - await _shutdown(saveFile: false); - return false; - } - } - - /// 暂停:仅在 Dart 层丢帧;设备端继续推流。 - void pause() { - if (!_running) return; - _paused = true; - } - - /// 恢复接收设备端帧。 - void resume() { - if (!_running) return; - _paused = false; - } - - /// 停止录音;[save] 为 false 时回滚删除文件。 - Future stop({required bool save}) async { - if (!_running) { - // 即便外部异常重复 stop,也保证幂等 - await _shutdown(saveFile: save); - return true; - } - _running = false; - try { - try { - await Jielihome.instance.deviceRecordStop(); - } catch (e) { - Logger.w(_tag, 'deviceRecordStop 失败(忽略): $e'); - } - await _shutdown(saveFile: save); - return true; - } catch (e) { - Logger.error('$_tag 停止失败: $e'); - return false; - } - } - - Future _shutdown({required bool saveFile}) async { - await _eventSub?.cancel(); - _eventSub = null; - final raf = _raf; - final path = _filePath; - _raf = null; - _filePath = null; - _paused = false; - - if (raf != null) { - try { - if (saveFile) { - await raf.setPosition(0); - await raf.writeFrom(_makeWavHeader(_dataBytes)); - } - } catch (e) { - Logger.error('$_tag 写 WAV 头失败: $e'); - } finally { - try { - await raf.flush(); - } catch (_) {} - try { - await raf.close(); - } catch (_) {} - } - } - - if (!saveFile && path != null) { - try { - final f = File(path); - if (await f.exists()) { - await f.delete(); - } - } catch (e) { - Logger.w(_tag, '删除录音文件失败: $e'); - } - } - _dataBytes = 0; - } - - void _onEvent(JieliEvent event) { - if (event is DeviceRecordAudioEvent) { - if (_paused) return; - if (event.pcm.isEmpty) return; - _writePcm(event.pcm); - } else if (event is DeviceRecordErrorEvent) { - Logger.error( - '$_tag 设备录音通路错误: code=${event.code} msg=${event.message ?? ''}'); - } - } - - void _writePcm(Uint8List pcm) { - final raf = _raf; - if (raf == null) return; - try { - raf.writeFromSync(pcm); - _dataBytes += pcm.length; - } catch (e) { - Logger.error('$_tag 写 PCM 失败: $e'); - return; - } - onWavePcm?.call(pcm); - } - - /// 标准 PCM WAV 头(44 字节)。dataLength=0 用于占位,stop 时回填实际值。 - /// 头里的 channels=2 / blockAlign=4 反映 stereo 交织 PCM 格式。 - Uint8List _makeWavHeader(int dataLength) { - final byteRate = sampleRate * _channels * (bitsPerSample ~/ 8); - final blockAlign = _channels * (bitsPerSample ~/ 8); - final totalLength = 36 + dataLength; - final h = ByteData(44); - - // "RIFF" - h.setUint8(0, 0x52); - h.setUint8(1, 0x49); - h.setUint8(2, 0x46); - h.setUint8(3, 0x46); - h.setUint32(4, totalLength, Endian.little); - // "WAVE" - h.setUint8(8, 0x57); - h.setUint8(9, 0x41); - h.setUint8(10, 0x56); - h.setUint8(11, 0x45); - // "fmt " - h.setUint8(12, 0x66); - h.setUint8(13, 0x6d); - h.setUint8(14, 0x74); - h.setUint8(15, 0x20); - h.setUint32(16, 16, Endian.little); // fmt chunk size - h.setUint16(20, 1, Endian.little); // PCM - h.setUint16(22, _channels, Endian.little); - h.setUint32(24, sampleRate, Endian.little); - h.setUint32(28, byteRate, Endian.little); - h.setUint16(32, blockAlign, Endian.little); - h.setUint16(34, bitsPerSample, Endian.little); - // "data" - h.setUint8(36, 0x64); - h.setUint8(37, 0x61); - h.setUint8(38, 0x74); - h.setUint8(39, 0x61); - h.setUint32(40, dataLength, Endian.little); - - return h.buffer.asUint8List(); - } -} diff --git a/apps/client/lib/modules/music/bindings/music_playlist_binding.dart b/apps/client/lib/modules/music/bindings/music_playlist_binding.dart deleted file mode 100644 index 85f385d4..00000000 --- a/apps/client/lib/modules/music/bindings/music_playlist_binding.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/music_playlist_controller.dart'; - -class MusicPlaylistBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut( - () => MusicPlaylistController(), - ); - } -} diff --git a/apps/client/lib/modules/music/controllers/music_playlist_controller.dart b/apps/client/lib/modules/music/controllers/music_playlist_controller.dart deleted file mode 100644 index 5fe2c8d8..00000000 --- a/apps/client/lib/modules/music/controllers/music_playlist_controller.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:get/get.dart'; - -class MusicPlaylistController extends GetxController { - // 歌单数据 - final playlist = >[].obs; - - @override - void onInit() { - super.onInit(); - // 从路由参数获取歌单数据 - final List> playlistData = Get.arguments ?? []; - playlist.assignAll(playlistData); - } -} diff --git a/apps/client/lib/modules/music/views/music_playlist_view.dart b/apps/client/lib/modules/music/views/music_playlist_view.dart deleted file mode 100644 index 16a7dfaf..00000000 --- a/apps/client/lib/modules/music/views/music_playlist_view.dart +++ /dev/null @@ -1,724 +0,0 @@ -import 'package:get/get.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '../../../core/utils/logger.dart'; -import '../../agent/controllers/agent_controller.dart'; -import '../../agent/widgets/circular_progress_button.dart'; - -class MusicPlaylistView extends StatelessWidget { - const MusicPlaylistView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - ScreenUtil.init(context, designSize: const Size(375, 812)); // 添加设计尺寸参考 - final controller = Get.find(); // 获取控制器 - final List> playlist = Get.arguments ?? []; - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, size: 20), // iOS风格箭头 - onPressed: () => Navigator.pop(context), - ), - backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, - foregroundColor: isDarkMode ? Colors.white : Colors.black, - elevation: 0, - ), - backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - body: playlist.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.music_off, - size: 64.w, - color: Colors.grey[400], - ), - SizedBox(height: 16.h), - Text( - 'noMusic'.tr, // 暂无音乐 - style: TextStyle( - fontSize: 16.sp, - color: Colors.grey[600], - ), - ), - ], - ), - ) - : Column( - children: [ - // 上部:当前播放音乐详情 - 响应式高度 - Container( - height: MediaQuery.of(context).size.height * 0.38, // 增大高度比例 - child: _buildCurrentPlayingSection( - controller, playlist, isDarkMode), - ), - // 中部:播放全部按钮 - _buildPlayAllSection(controller, playlist, isDarkMode), - // 下部:歌单列表 - 使用剩余空间 - Expanded( - child: - _buildPlaylistSection(controller, playlist, isDarkMode), - ), - ], - ), - ); - } - - // 构建当前播放音乐详情部分 - Widget _buildCurrentPlayingSection(AgentController controller, - List> playlist, bool isDarkMode) { - return SingleChildScrollView( - // 添加滚动支持 - child: Obx(() { - // 查找当前播放的歌曲 - Map? currentSong; - if (controller.currentId.value != 0) { - try { - currentSong = playlist.firstWhere( - (song) => song['id'] == controller.currentId.value, - ); - } catch (e) { - // 如果没找到,使用第一首歌曲 - - currentSong = playlist.isNotEmpty ? playlist.first : null; - } - } else { - currentSong = playlist.isNotEmpty ? playlist.first : null; - } - - if (currentSong == null) { - return Container( - padding: EdgeInsets.all(16.w), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.music_note, - size: 60.w, - color: Colors.grey[400], - ), - SizedBox(height: 12.h), - Text( - 'noPlayingMusic'.tr, // 暂无播放音乐 - style: TextStyle( - fontSize: 16.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - ], - ), - ), - ); - } - - final title = currentSong['name'] ?? - currentSong['title'] ?? - 'unknownSong'.tr; // 未知歌曲 - final artist = currentSong['singer'] ?? - currentSong['sgener'] ?? - currentSong['artist'] ?? - 'unknownArtist'.tr; // 未知歌手 - final coverUrl = currentSong['image'] ?? currentSong['coverUrl'] ?? ''; - - return Container( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 封面图片 - 响应式尺寸 - _buildCoverImage(coverUrl, isDarkMode, size: 110.w), - SizedBox(height: 12.h), - // 歌曲信息 - _buildSongInfo(title, artist, isDarkMode), - SizedBox(height: 16.h), - // 进度条 - _buildProgressBar(controller, isDarkMode), - SizedBox(height: 15.h), - // 播放控制按钮 - _buildPlayControls(controller, playlist, isDarkMode), - ], - ), - ); - }), - ); - } - - // 构建播放全部按钮部分 - Widget _buildPlayAllSection(AgentController controller, - List> playlist, bool isDarkMode) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), - child: SizedBox( - width: double.infinity, - height: 48.h, - child: ElevatedButton.icon( - onPressed: () async { - if (playlist.isNotEmpty) { - await controller.playPlaylist(playlist); - } - }, - icon: Icon(Icons.play_arrow, color: Colors.white, size: 20.w), - label: Text( - 'playAll'.tr, // 播放全部 - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(Get.context!).primaryColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14.r), - ), - elevation: 3, - shadowColor: Theme.of(Get.context!).primaryColor.withAlpha(102), - ), - ), - ), - ); - } - - // 构建歌单列表部分 - Widget _buildPlaylistSection(AgentController controller, - List> playlist, bool isDarkMode) { - return ListView.builder( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h), - itemCount: playlist.length, - itemBuilder: (context, index) { - final item = playlist[index]; - final id = item["id"] ?? 0; - final name = item["name"] ?? 'unknownSong'.tr; // 未知歌曲 - final singer = - item["singer"] ?? item["sgener"] ?? 'unknownArtist'.tr; // 未知歌手 - final image = item["image"] ?? ""; - - return _buildMusicItem( - context, - controller, - id, - name, - singer, - image, - isDarkMode, - ); - }, - ); - } - - Widget _buildMusicItem( - BuildContext context, - AgentController controller, - int id, - String name, - String singer, - String image, - bool isDarkMode, - ) { - final song = { - 'id': id, - 'title': name, - 'artist': singer, - 'coverUrl': image, - }; - - return GestureDetector( - onTap: () async { - await _playSelectedSong(controller, song, Get.arguments ?? []); - }, - child: Container( - margin: EdgeInsets.only(bottom: 10.h), - padding: EdgeInsets.all(14.w), - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.white, - borderRadius: BorderRadius.circular(14.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.25 : 0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - // 封面图片 - Container( - width: 52.w, - height: 52.w, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10.r), - color: isDarkMode ? Colors.grey[700] : Colors.grey[200], - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.08), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: Stack( - children: [ - image.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(10.r), - child: Image.network( - image, - width: 52.w, - height: 52.w, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Icon( - Icons.music_note, - color: isDarkMode - ? Colors.grey[500] - : Colors.grey[600], - size: 24.w, - ); - }, - ), - ) - : Icon( - Icons.music_note, - color: - isDarkMode ? Colors.grey[500] : Colors.grey[600], - size: 24.w, - ), - // 右下角音乐图标 - if (image.isNotEmpty) - Positioned( - right: 2.w, - bottom: 2.w, - child: Container( - width: 14.w, - height: 14.w, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(2.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 1, - offset: const Offset(0, 0.5), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(2.r), - child: Image.asset( - 'assets/images/qqmusic.png', - fit: BoxFit.contain, - ), - ), - ), - ), - ], - ), - ), - SizedBox(width: 14.w), - // 歌曲信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - name, - style: TextStyle( - fontSize: 15.5.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black87, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 3.h), - Text( - singer, - style: TextStyle( - fontSize: 13.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - height: 1.1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - SizedBox(width: 14.w), - // 播放按钮 - Obx(() { - final currentIdInt = id; - final isThisPlaying = controller.isPlaying.value && - controller.currentId.value == currentIdInt; - final currentProgress = - isThisPlaying ? controller.progress.value : 0.0; - - return CircularProgressButton( - isPlaying: isThisPlaying, - progress: currentProgress, - primaryColor: Theme.of(context).primaryColor, - backgroundColor: Colors.grey, - size: 40.w, - strokeWidth: 2.5, - onPlayPause: () async { - try { - Logger.d('MusicPlaylistView', - '播放按钮被点击,当前播放状态: ${controller.isPlaying.value}'); - Logger.d('MusicPlaylistView', - '当前歌曲ID: ${controller.currentId.value}, 点击歌曲ID: $currentIdInt'); - if (controller.isPlaying.value) { - if (controller.currentId.value == currentIdInt) { - Logger.d('MusicPlaylistView', '暂停当前播放的歌曲'); - await controller.musicPause(); - } else { - Logger.d('MusicPlaylistView', '播放新歌曲'); - await controller.musicPlay(song); - } - } else { - Logger.d('MusicPlaylistView', '开始播放歌曲'); - await controller.musicPlay(song); - } - } catch (e) { - Logger.e('MusicPlaylistView', '播放音乐时发生错误: $e'); - } - }, - onSeek: isThisPlaying - ? (seekProgress) { - // 只有当前播放的歌曲才允许跳转 - final targetPosition = - (controller.duration.value * seekProgress).round(); - controller.seekToPosition(targetPosition); - } - : null, - ); - }), - ], - ), - ), - ); - } - - // 构建封面图片 - Widget _buildCoverImage(String coverUrl, bool isDarkMode, - {required double size}) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1), - blurRadius: 15, - spreadRadius: 2, - offset: const Offset(0, 6), - ), - ], - ), - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(16.r), - child: coverUrl.isNotEmpty - ? Image.network( - coverUrl, - fit: BoxFit.cover, - width: size, - height: size, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(isDarkMode, size: size); - }, - ) - : _buildDefaultCover(isDarkMode, size: size), - ), - // 右下角音乐图标 - Positioned( - right: 4.w, - bottom: 4.w, - child: Container( - width: size * 0.25, - height: size * 0.25, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(4.r), - child: Image.asset( - 'assets/images/qqmusic.png', - fit: BoxFit.contain, - ), - ), - ), - ), - ], - ), - ); - } - - // 构建默认封面 - Widget _buildDefaultCover(bool isDarkMode, {required double size}) { - return Container( - color: isDarkMode ? Colors.grey[800] : Colors.grey[300], - child: Icon( - Icons.music_note, - size: size * 0.55, - color: isDarkMode ? Colors.grey[600] : Colors.grey[500], - ), - ); - } - - // 构建歌曲信息 - Widget _buildSongInfo(String title, String artist, bool isDarkMode) { - return Column( - children: [ - Text( - title, - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black87, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 6.h), - Text( - artist, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } - - // 构建进度条 - Widget _buildProgressBar(AgentController controller, bool isDarkMode) { - return Obx(() { - return Row( - children: [ - SizedBox( - width: 40.w, - child: Text( - _formatDuration(controller.currentPosition.value), - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - fontFeatures: const [FontFeature.tabularFigures()], - ), - textAlign: TextAlign.left, - ), - ), - SizedBox(width: 8.w), - Expanded( - child: SliderTheme( - data: SliderThemeData( - activeTrackColor: Theme.of(Get.context!).primaryColor, - inactiveTrackColor: - isDarkMode ? Colors.grey[700] : Colors.grey[300], - thumbColor: Theme.of(Get.context!).primaryColor, - overlayColor: - Theme.of(Get.context!).primaryColor.withOpacity(0.2), - trackHeight: 4.h, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), - ), - child: Slider( - value: controller.progress.value.clamp(0.0, 1.0), - onChanged: (value) { - // 拖动中仅更新UI显示,不触发实际跳转 - controller.progress.value = value; - controller.currentPosition.value = - (controller.duration.value * value).round(); - }, - onChangeEnd: (value) { - // 松开时再执行跳转,确保拖动自然 - final targetPosition = - (controller.duration.value * value).round(); - controller.seekToPosition(targetPosition); - }, - ), - ), - ), - SizedBox(width: 8.w), - SizedBox( - width: 40.w, - child: Text( - _formatDuration(controller.duration.value), - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - fontFeatures: const [FontFeature.tabularFigures()], - ), - textAlign: TextAlign.right, - ), - ), - ], - ); - }); - } - - // 构建播放控制按钮 - Widget _buildPlayControls(AgentController controller, - List> playlist, bool isDarkMode) { - return Obx(() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: () async => await _playPrevious(controller, playlist), - iconSize: 32.w, - padding: EdgeInsets.all(8.w), // 增大触摸区域 - icon: Icon( - Icons.skip_previous, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - SizedBox(width: 16.w), - Container( - width: 56.w, - height: 56.w, - decoration: BoxDecoration( - color: Theme.of(Get.context!).primaryColor, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Theme.of(Get.context!).primaryColor.withOpacity(0.3), - blurRadius: 10, - spreadRadius: 1, - offset: const Offset(0, 3), - ), - ], - ), - child: IconButton( - onPressed: () => _togglePlayPause(controller), - padding: EdgeInsets.zero, - icon: Icon( - controller.isPlaying.value ? Icons.pause : Icons.play_arrow, - size: 28.w, - color: Colors.white, - ), - ), - ), - SizedBox(width: 16.w), - IconButton( - onPressed: () async => await _playNext(controller, playlist), - iconSize: 32.w, - padding: EdgeInsets.all(8.w), // 增大触摸区域 - icon: Icon( - Icons.skip_next, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ], - ); - }); - } - - // ...(其他辅助方法保持不变,如_formatDuration, _togglePlayPause等) - // 格式化时间显示 - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final minutes = duration.inMinutes; - final seconds = duration.inSeconds % 60; - return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } - - // 播放/暂停切换 - Future _togglePlayPause(AgentController controller) async { - if (controller.isPlaying.value) { - await controller.musicPause(); - } else { - await controller.musicResume(); - } - } - - // 播放上一曲 - Future _playPrevious( - AgentController controller, List> playlist) async { - // 防止重复点击导致的状态混乱 - if (controller.isLoadingMusic.value) { - return; - } - - try { - // 设置加载状态,防止重复操作 - controller.isLoadingMusic.value = true; - - // 更新播放列表上下文 - controller.updateCurrentPlaylistContext( - playlist, controller.currentId.value); - - // 执行上一曲操作 - await controller.playPrevious(); - } finally { - // 确保加载状态被重置 - controller.isLoadingMusic.value = false; - } - } - - // 播放下一曲 - Future _playNext( - AgentController controller, List> playlist) async { - // 防止重复点击导致的状态混乱 - if (controller.isLoadingMusic.value) { - return; - } - - try { - // 设置加载状态,防止重复操作 - controller.isLoadingMusic.value = true; - - // 更新播放列表上下文 - controller.updateCurrentPlaylistContext( - playlist, controller.currentId.value); - - // 执行下一曲操作 - await controller.playNext(); - } finally { - // 确保加载状态被重置 - controller.isLoadingMusic.value = false; - } - } - - // 播放选中的歌曲 - Future _playSelectedSong(AgentController controller, - Map song, List> playlist) async { - // 防止重复点击导致的状态混乱 - if (controller.isLoadingMusic.value) { - return; - } - - try { - // 设置加载状态,防止重复操作 - controller.isLoadingMusic.value = true; - - final songId = song['id'] ?? 0; - controller.updateCurrentPlaylistContext(playlist, songId); - await controller.musicPlay(song); - } finally { - // 确保加载状态被重置 - controller.isLoadingMusic.value = false; - } - } -} diff --git a/apps/client/lib/modules/opus_test/bindings/opus_test_binding.dart b/apps/client/lib/modules/opus_test/bindings/opus_test_binding.dart deleted file mode 100644 index c1e6a1aa..00000000 --- a/apps/client/lib/modules/opus_test/bindings/opus_test_binding.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/opus_test_controller.dart'; - -class OpusTestBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => OpusTestController()); - } -} \ No newline at end of file diff --git a/apps/client/lib/modules/opus_test/controllers/opus_test_controller.dart b/apps/client/lib/modules/opus_test/controllers/opus_test_controller.dart deleted file mode 100644 index 0cd479f8..00000000 --- a/apps/client/lib/modules/opus_test/controllers/opus_test_controller.dart +++ /dev/null @@ -1,676 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:get/get.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:jl_opus/jl_opus.dart'; -import 'package:permission_handler/permission_handler.dart'; -import '../../../core/utils/permission_util.dart'; -import 'package:share_plus/share_plus.dart'; // 添加分享插件导入 - -class OpusTestController extends GetxController { - // 选中的文件 - final selectedFiles = [].obs; - - // 扫描到的外部存储文件 - final externalFiles = [].obs; - // 是否携带协议头 - final hasHeader = false.obs; - - // 数据包长度 - final packetLength = '80'.obs; - - // 采样率选项 - final sampleRateOptions = [8000, 16000, 24000, 32000, 44100, 48000]; - final selectedSampleRate = 16000.obs; - - // 通道数量 - final isMono = true.obs; - - // 播放状态 - final isPlaying = false.obs; - final isDecoding = false.obs; - final statusMessage = ''.obs; - - // 播放器 - final player = AudioPlayer(); - - // 临时文件路径 - String? tempPcmPath; - - // 杰理OPUS解码器 - JlOpus? jlOpus; - StreamSubscription? _eventSubscription; - static const MethodChannel _bleServiceChannel = - MethodChannel('com.yunqiinnovation.ble_service'); - - @override - void onInit() { - super.onInit(); - // 合规(场景 1/2):不在 onInit 里申请权限——进页面就弹框,且拒绝后每次 - // 重进/重启还会再弹。改为用户点击"扫描存储"时按需申请。 - _initOpusDecoder(); - } - - @override - void onClose() { - player.dispose(); - _eventSubscription?.cancel(); - jlOpus?.dispose(); - super.onClose(); - } - - // 初始化OPUS解码器 - Future _initOpusDecoder() async { - if (!Platform.isAndroid) { - return; - } - - jlOpus = JlOpus(); - - // 监听解码器事件 - _eventSubscription = jlOpus!.eventStream.listen((event) { - switch (event.event) { - case 'onStart': - statusMessage.value = '开始${event.type == "file" ? "文件" : "流"}解码...'; - break; - case 'onComplete': - if (event.type == 'file') { - statusMessage.value = '解码完成: ${event.filePath}'; - } else { - statusMessage.value = '流解码完成'; - } - break; - case 'onError': - statusMessage.value = - '解码错误: [${event.errorCode}] ${event.errorMessage}'; - isDecoding.value = false; - break; - } - }); - - // 初始化OPUS解码器 - final initialized = await jlOpus!.initOpusDecoder(); - if (!initialized) { - statusMessage.value = 'OPUS解码器初始化失败'; - } - } - - Future _decodeOpusFile( - String inPath, String outPath, OpusOption option) async { - if (Platform.isIOS) { - final result = await _bleServiceChannel.invokeMethod( - 'decodeOpusFile', - { - 'inPath': inPath, - 'outPath': outPath, - ...option.toMap(), - }, - ); - return result; - } - - return jlOpus?.decodeOpusFile(inPath, outPath, option); - } - - // 请求必要权限(仅在用户主动触发读取存储的操作时调用) - // - // 合规(场景 1/2):统一走 PermissionUtil,拒绝一次后 48 小时内不再申请, - // 返回 false 时直接放弃本次操作,不弹引导框、不跳设置页。 - Future requestPermissions() async { - if (!Platform.isAndroid) return true; - return PermissionUtil.instance.requestPermission( - permissionType: Permission.storage, - permissionName: 'storagePermission'.tr, - explanationText: 'storagePermissionExplanation'.tr, - permanentDenialText: 'storagePermissionExplanation'.tr, - ); - } - - // 加载外部存储文件 - Future loadExternalFiles() async { - // 用户主动触发,此时才申请存储权限;拿不到就直接退出该功能 - if (!await requestPermissions()) { - statusMessage.value = 'storagePermissionExplanation'.tr; - return; - } - try { - externalFiles.clear(); - statusMessage.value = '正在扫描存储...'; - print('正在扫描存储...'); - - List directories = []; - - if (Platform.isAndroid) { - // Android: 使用外部存储目录 - final androidDirs = await getExternalStorageDirectories(); - if (androidDirs != null && androidDirs.isNotEmpty) { - directories.addAll(androidDirs); - } - } else if (Platform.isIOS) { - // iOS: 使用Documents目录 - final iosDir = await getApplicationDocumentsDirectory(); - directories.add(iosDir); - } - - if (directories.isEmpty) { - statusMessage.value = '未找到存储目录'; - return; - } - - print('存储目录: $directories'); - - // 遍历所有存储目录 - for (final directory in directories) { - final dirPath = directory.path; - final dir = Directory(dirPath); - - // 检查目录是否存在 - if (await dir.exists()) { - // 递归列出所有文件 - final files = await dir.list(recursive: true).toList(); - - for (var entity in files) { - if (entity is File) { - final path = entity.path.toLowerCase(); - if (path.endsWith('.opus') || path.endsWith('.txt')) { - final fileSize = await entity.length(); - externalFiles.add(PlatformFile( - name: entity.path.split('/').last, - path: entity.path, - size: fileSize, - )); - } - } - } - } - } - - statusMessage.value = '扫描完成,找到 ${externalFiles.length} 个Opus文件'; - } catch (e) { - statusMessage.value = '扫描失败: $e'; - } - } - - // 选择文件 - Future pickFiles() async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['opus'], - allowMultiple: false, - ); - - if (result != null && result.files.isNotEmpty) { - selectedFiles.clear(); - selectedFiles.add(result.files.first); - statusMessage.value = '已选择文件:${result.files.first.name}'; - } - } catch (e) { - statusMessage.value = '选择文件失败: $e'; - } - } - - // 从外部文件列表中选择文件 - void selectExternalFile(PlatformFile file) { - selectedFiles.clear(); - selectedFiles.add(file); - statusMessage.value = '已选择文件: ${file.name}'; - } - - // 切换通道模式 - void toggleChannel(bool isSingleChannel) { - isMono.value = isSingleChannel; - } - - // 设置采样率 - void setSampleRate(int rate) { - selectedSampleRate.value = rate; - } - - // 设置数据包长度 - void setPacketLength(String length) { - packetLength.value = length; - } - - // 切换是否携带协议头 - void toggleHeader(bool value) { - hasHeader.value = value; - } - - // 播放音频 - Future playAudio() async { - if (isPlaying.value) { - await player.stop(); - isPlaying.value = false; - statusMessage.value = '已停止播放'; - return; - } - - if (selectedFiles.isEmpty) { - statusMessage.value = '请先选择Opus文件'; - return; - } - - try { - isDecoding.value = true; - statusMessage.value = '正在解码...'; - - // 使用杰理OPUS库进行解码 - final inPath = selectedFiles.first.path!; - - // 创建WAV输出文件路径 - final tempDir = await getTemporaryDirectory(); - final wavFileName = - 'decoded_opus_${DateTime.now().millisecondsSinceEpoch}.wav'; - final outPath = '${tempDir.path}/$wavFileName'; - - // 创建解码参数 - final option = OpusOption( - hasHeader: hasHeader.value, - channel: isMono.value ? 1 : 2, - sampleRate: selectedSampleRate.value, - packetSize: int.tryParse(packetLength.value) ?? 40, - ); - - // 解码文件 - final pcmPath = await _decodeOpusFile(inPath, outPath, option); - if (pcmPath == null) { - statusMessage.value = '解码失败'; - isDecoding.value = false; - return; - } - print("pcmPath: $pcmPath"); - tempPcmPath = pcmPath; - - // 将PCM文件转换为WAV文件 - final wavFile = await _convertPcmToWav( - pcmPath, - '${tempDir.path}/$wavFileName', - selectedSampleRate.value, - isMono.value ? 1 : 2); - - statusMessage.value = '解码完成,准备播放'; - - // 播放WAV文件 - await player.setFilePath(wavFile.path); - await player.play(); - isPlaying.value = true; - statusMessage.value = '正在播放解码后的音频'; - - // 监听播放完成 - player.playerStateStream.listen((state) { - if (state.processingState == ProcessingState.completed) { - isPlaying.value = false; - statusMessage.value = '播放完成'; - } - }); - } catch (e) { - statusMessage.value = '解码或播放失败: $e'; - } finally { - isDecoding.value = false; - } - } - - // 将PCM转换为WAV格式 - Future _convertPcmToWav( - String pcmPath, String wavPath, int sampleRate, int channels) async { - final pcmFile = File(pcmPath); - final pcmData = await pcmFile.readAsBytes(); - - // 创建WAV文件 (44字节头部 + PCM数据) - final wavSize = 44 + pcmData.length; - final wavData = ByteData(wavSize); - - // 写入WAV头部 - _writeWavHeader(wavData, pcmData.length, sampleRate, channels); - - // 写入PCM数据 - for (int i = 0; i < pcmData.length; i++) { - wavData.setUint8(44 + i, pcmData[i]); - } - - // 保存为WAV文件 - final wavFile = File(wavPath); - await wavFile.writeAsBytes(wavData.buffer.asUint8List()); - - return wavFile; - } - - // 写入WAV头部到ByteData - void _writeWavHeader( - ByteData data, int pcmLength, int sampleRate, int channels) { - final bytesPerSample = 2; // 16位PCM - final byteRate = sampleRate * channels * bytesPerSample; - final blockAlign = channels * bytesPerSample; - - // "RIFF"标识 - data.setUint8(0, 0x52); // 'R' - data.setUint8(1, 0x49); // 'I' - data.setUint8(2, 0x46); // 'F' - data.setUint8(3, 0x46); // 'F' - - // 文件大小 (减去8个字节的RIFF头部和大小字段) - data.setUint32(4, 36 + pcmLength, Endian.little); - - // "WAVE"标识 - data.setUint8(8, 0x57); // 'W' - data.setUint8(9, 0x41); // 'A' - data.setUint8(10, 0x56); // 'V' - data.setUint8(11, 0x45); // 'E' - - // "fmt "子块 - data.setUint8(12, 0x66); // 'f' - data.setUint8(13, 0x6D); // 'm' - data.setUint8(14, 0x74); // 't' - data.setUint8(15, 0x20); // ' ' - - // 子块大小 (16 for PCM) - data.setUint32(16, 16, Endian.little); - - // 音频格式 (1 表示PCM) - data.setUint16(20, 1, Endian.little); - - // 通道数量 - data.setUint16(22, channels, Endian.little); - - // 采样率 - data.setUint32(24, sampleRate, Endian.little); - - // 字节率 = 采样率 * 通道数 * 每样本字节数 - data.setUint32(28, byteRate, Endian.little); - - // 块对齐 = 通道数 * 每样本字节数 - data.setUint16(32, blockAlign, Endian.little); - - // 每个样本位数 - data.setUint16(34, bytesPerSample * 8, Endian.little); - - // "data"子块 - data.setUint8(36, 0x64); // 'd' - data.setUint8(37, 0x61); // 'a' - data.setUint8(38, 0x74); // 't' - data.setUint8(39, 0x61); // 'a' - - // 数据大小 - data.setUint32(40, pcmLength, Endian.little); - } - - /// 删除外部文件 - Future deleteExternalFile(PlatformFile file) async { - try { - final fileToDelete = File(file.path!); - if (await fileToDelete.exists()) { - await fileToDelete.delete(); - externalFiles.removeWhere((f) => f.path == file.path); - - // 如果删除的是当前选中的文件,清空选择 - if (selectedFiles.isNotEmpty && selectedFiles.first.path == file.path) { - selectedFiles.clear(); - } - - statusMessage.value = '文件已删除: ${file.name}'; - } else { - statusMessage.value = '文件不存在: ${file.name}'; - } - } catch (e) { - statusMessage.value = '删除文件失败: $e'; - } - } - - /// 分享外部文件 - Future shareExternalFile(PlatformFile file) async { - try { - if (file.path != null) { - final fileToShare = File(file.path!); - if (await fileToShare.exists()) { - await Share.shareXFiles( - [XFile(file.path!)], - text: '分享Opus音频文件: ${file.name}', - ); - statusMessage.value = '正在分享文件: ${file.name}'; - } else { - statusMessage.value = '文件不存在: ${file.name}'; - } - } - } catch (e) { - statusMessage.value = '分享文件失败: $e'; - } - } - - /// 显示文件操作底部弹窗 - void showFileActionSheet(BuildContext context, PlatformFile file) { - showModalBottomSheet( - context: context, - backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) => Container( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 文件信息 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Icon( - Icons.audiotrack, - color: Get.isDarkMode ? Colors.white70 : Colors.black87, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - file.name, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Get.isDarkMode ? Colors.white : Colors.black, - ), - overflow: TextOverflow.ellipsis, - ), - Text( - '${(file.size / 1024).toStringAsFixed(2)} KB', - style: TextStyle( - fontSize: 14, - color: Get.isDarkMode - ? Colors.white60 - : Colors.black54, - ), - ), - ], - ), - ), - ], - ), - ), - const Divider(), - // 分享选项 - ListTile( - leading: const Icon(Icons.share, color: Colors.blue), - title: const Text('分享文件'), - onTap: () { - Navigator.pop(context); - shareExternalFile(file); - }, - ), - // 删除选项 - ListTile( - leading: const Icon(Icons.delete, color: Colors.red), - title: const Text('删除文件', style: TextStyle(color: Colors.red)), - onTap: () { - Navigator.pop(context); - _showDeleteConfirmDialog(context, file); - }, - ), - // 取消选项 - ListTile( - leading: const Icon(Icons.cancel), - title: const Text('取消'), - onTap: () => Navigator.pop(context), - ), - ], - ), - ), - ); - } - - /// 显示删除确认对话框 - void _showDeleteConfirmDialog(BuildContext context, PlatformFile file) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white, - title: Text( - '确认删除', - style: TextStyle( - color: Get.isDarkMode ? Colors.white : Colors.black, - ), - ), - content: Text( - '确定要删除文件 "${file.name}" 吗?\n此操作不可撤销。', - style: TextStyle( - color: Get.isDarkMode ? Colors.white70 : Colors.black87, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消'), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - deleteExternalFile(file); - }, - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('删除'), - ), - ], - ), - ); - } -} - -// PCM音频源 -class PCMAudioSource extends StreamAudioSource { - final Uint8List pcmData; - final int sampleRate; - final int channels; - - PCMAudioSource({ - required this.pcmData, - this.sampleRate = 16000, - this.channels = 1, - }); - - @override - Future request([int? start, int? end]) async { - start = start ?? 0; - end = end ?? pcmData.length; - - // 转换PCM到WAV格式 - final wavHeader = _createWavHeader(pcmData.length, sampleRate, channels); - final wavData = Uint8List(wavHeader.length + pcmData.length); - wavData.setRange(0, wavHeader.length, wavHeader); - wavData.setRange( - wavHeader.length, wavHeader.length + pcmData.length, pcmData); - - // 调整请求范围来适应WAV头部 - int adjustedStart = start; - int adjustedEnd = end; - - if (start < wavHeader.length) { - adjustedStart = start; - } else { - adjustedStart = start + wavHeader.length; - } - - if (end <= wavHeader.length) { - adjustedEnd = end; - } else { - adjustedEnd = end + wavHeader.length; - adjustedEnd = adjustedEnd > wavData.length ? wavData.length : adjustedEnd; - } - - return StreamAudioResponse( - sourceLength: wavData.length, - contentLength: adjustedEnd - adjustedStart, - offset: adjustedStart, - stream: Stream.value(wavData.sublist(adjustedStart, adjustedEnd)), - contentType: 'audio/wav', - ); - } - - // 创建WAV头部 - Uint8List _createWavHeader(int dataLength, int sampleRate, int channels) { - final bytesPerSample = 2; // 16位PCM - final byteRate = sampleRate * channels * bytesPerSample; - final blockAlign = channels * bytesPerSample; - - final header = ByteData(44); // WAV头部固定44字节 - - // "RIFF"标识 - header.setUint8(0, 0x52); // 'R' - header.setUint8(1, 0x49); // 'I' - header.setUint8(2, 0x46); // 'F' - header.setUint8(3, 0x46); // 'F' - - // 文件大小 (减去8个字节的RIFF头部和大小字段) - header.setUint32(4, 36 + dataLength, Endian.little); - - // "WAVE"标识 - header.setUint8(8, 0x57); // 'W' - header.setUint8(9, 0x41); // 'A' - header.setUint8(10, 0x56); // 'V' - header.setUint8(11, 0x45); // 'E' - - // "fmt "子块 - header.setUint8(12, 0x66); // 'f' - header.setUint8(13, 0x6D); // 'm' - header.setUint8(14, 0x74); // 't' - header.setUint8(15, 0x20); // ' ' - - // 子块大小 (16 for PCM) - header.setUint32(16, 16, Endian.little); - - // 音频格式 (1 表示PCM) - header.setUint16(20, 1, Endian.little); - - // 通道数量 - header.setUint16(22, channels, Endian.little); - - // 采样率 - header.setUint32(24, sampleRate, Endian.little); - - // 字节率 = 采样率 * 通道数 * 每样本字节数 - header.setUint32(28, byteRate, Endian.little); - - // 块对齐 = 通道数 * 每样本字节数 - header.setUint16(32, blockAlign, Endian.little); - - // 每个样本位数 - header.setUint16(34, bytesPerSample * 8, Endian.little); - - // "data"子块 - header.setUint8(36, 0x64); // 'd' - header.setUint8(37, 0x61); // 'a' - header.setUint8(38, 0x74); // 't' - header.setUint8(39, 0x61); // 'a' - - // 数据大小 - header.setUint32(40, dataLength, Endian.little); - - return header.buffer.asUint8List(); - } -} diff --git a/apps/client/lib/modules/opus_test/views/opus_test_view.dart b/apps/client/lib/modules/opus_test/views/opus_test_view.dart deleted file mode 100644 index de02e513..00000000 --- a/apps/client/lib/modules/opus_test/views/opus_test_view.dart +++ /dev/null @@ -1,522 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../controllers/opus_test_controller.dart'; - -class OpusTestView extends GetView { - const OpusTestView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - // 获取主题模式 - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return DefaultTabController( - length: 2, - child: Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - appBar: AppBar( - backgroundColor: isDarkMode ? Colors.black : Colors.white, - elevation: 0.5, - title: Text( - 'OPUS解码', - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - centerTitle: true, - bottom: TabBar( - indicatorColor: Colors.blue, - indicatorWeight: 3, - labelColor: isDarkMode ? Colors.white : Colors.black, - unselectedLabelColor: isDarkMode ? Colors.grey : Colors.grey, - tabs: const [ - Tab(text: "解码"), - Tab(text: "输出"), - ], - ), - ), - body: TabBarView( - children: [ - _buildDecodeTab(isDarkMode), - _buildEncodeTab(isDarkMode), - ], - ), - ), - ); - } - - // 解码标签页 - Widget _buildDecodeTab(bool isDarkMode) { - return ListView( - padding: EdgeInsets.all(16.w), - children: [ - // 文件选择部分 - _buildSection( - title: '选择文件', - isDarkMode: isDarkMode, - child: Column( - children: [ - // 当前选中的文件 - Obx(() => controller.selectedFiles.isEmpty - ? const SizedBox.shrink() - : ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: controller.selectedFiles.length, - itemBuilder: (context, index) { - final file = controller.selectedFiles[index]; - return ListTile( - title: Text( - file.name, - style: TextStyle( - color: - isDarkMode ? Colors.white70 : Colors.black87, - fontSize: 14.sp, - ), - ), - subtitle: Text( - '${(file.size / 1024).toStringAsFixed(2)} Kb', - style: TextStyle( - color: - isDarkMode ? Colors.white38 : Colors.black54, - fontSize: 12.sp, - ), - ), - trailing: IconButton( - icon: Icon(Icons.clear, color: Colors.red), - onPressed: () { - controller.selectedFiles.removeAt(index); - }, - ), - ); - }, - )), - - // 外部文件列表标题和刷新按钮 - Padding( - padding: EdgeInsets.symmetric(vertical: 8.h), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '外部存储文件', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - IconButton( - icon: Icon( - Icons.refresh, - color: isDarkMode ? Colors.blue[300] : Colors.blue, - ), - onPressed: controller.loadExternalFiles, - ), - ], - ), - ), - - // 外部文件列表 - Obx(() => controller.externalFiles.isEmpty - ? Center( - child: Padding( - padding: EdgeInsets.all(16.h), - child: Text( - '未找到Opus文件', - style: TextStyle( - color: isDarkMode ? Colors.white54 : Colors.black54, - ), - ), - ), - ) - : Container( - height: 200.h, - decoration: BoxDecoration( - border: Border.all( - color: isDarkMode - ? Colors.grey[700]! - : Colors.grey[300]!, - ), - borderRadius: BorderRadius.circular(8.r), - ), - child: ListView.builder( - itemCount: controller.externalFiles.length, - itemBuilder: (context, index) { - final file = controller.externalFiles[index]; - return ListTile( - title: Text( - file.name, - style: TextStyle( - color: isDarkMode - ? Colors.white70 - : Colors.black87, - fontSize: 14.sp, - ), - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - '${(file.size / 1024).toStringAsFixed(2)} Kb', - style: TextStyle( - color: isDarkMode - ? Colors.white38 - : Colors.black54, - fontSize: 12.sp, - ), - ), - onTap: () { - controller.selectExternalFile(file); - }, - onLongPress: () { - // 长按显示操作菜单 - controller.showFileActionSheet(context, file); - }, - trailing: Icon( - Icons.arrow_forward, - color: - isDarkMode ? Colors.white54 : Colors.black54, - ), - ); - }, - ), - )), - ], - ), - ), - SizedBox(height: 16.h), - - // 解码参数部分 - _buildSection( - title: '解码参数', - isDarkMode: isDarkMode, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Obx(() => Checkbox( - value: controller.hasHeader.value, - onChanged: (value) => controller.toggleHeader(value!), - activeColor: Colors.blue, - )), - Text( - '是否携带协议头', - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black87, - fontSize: 14.sp, - ), - ), - ], - ), - // 数据包长度 - Row( - children: [ - Text( - '数据包长度', - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black87, - fontSize: 14.sp, - ), - ), - SizedBox(width: 16.w), - Expanded( - child: Obx(() => TextField( - controller: TextEditingController( - text: controller.packetLength.value), - onChanged: (value) => - controller.setPacketLength(value), - keyboardType: TextInputType.number, - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, - ), - decoration: InputDecoration( - isDense: true, - suffix: Text( - 'Byte', - style: TextStyle( - color: isDarkMode - ? Colors.white60 - : Colors.black54, - fontSize: 12.sp, - ), - ), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: isDarkMode - ? Colors.white30 - : Colors.black26, - ), - ), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: Colors.blue, - ), - ), - ), - )), - ), - ], - ), - SizedBox(height: 20.h), - - // 采样率 - Row( - children: [ - Text( - '采样率', - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black87, - fontSize: 14.sp, - ), - ), - SizedBox(width: 16.w), - Expanded( - child: Obx(() => DropdownButton( - value: controller.selectedSampleRate.value, - isExpanded: true, - dropdownColor: - isDarkMode ? Colors.grey[800] : Colors.white, - items: controller.sampleRateOptions.map((rate) { - return DropdownMenuItem( - value: rate, - child: Text( - rate.toString(), - style: TextStyle( - color: isDarkMode - ? Colors.white - : Colors.black87, - ), - ), - ); - }).toList(), - onChanged: (value) => - controller.setSampleRate(value!), - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, - ), - )), - ), - ], - ), - SizedBox(height: 20.h), - - // 通道数量 - Text( - '通道数量', - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black87, - fontSize: 14.sp, - ), - ), - Row( - children: [ - Expanded( - child: Row( - children: [ - Obx(() => Radio( - value: true, - groupValue: controller.isMono.value, - onChanged: (value) => - controller.toggleChannel(value!), - activeColor: Colors.blue, - )), - Text( - '单通道', - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black87, - fontSize: 14.sp, - ), - ), - ], - ), - ), - Expanded( - child: Row( - children: [ - Obx(() => Radio( - value: false, - groupValue: controller.isMono.value, - onChanged: (value) => - controller.toggleChannel(value!), - activeColor: Colors.blue, - )), - Text( - '双通道', - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black87, - fontSize: 14.sp, - ), - ), - ], - ), - ), - ], - ), - ], - ), - ), - - SizedBox(height: 16.h), - - // 状态消息 - Obx(() => controller.statusMessage.value.isNotEmpty - ? Container( - padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), - margin: EdgeInsets.only(bottom: 16.h), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.blue.withOpacity(0.2) - : Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(4.r), - border: Border.all( - color: isDarkMode - ? Colors.blue.withOpacity(0.3) - : Colors.blue.withOpacity(0.2), - width: 1, - ), - ), - child: Text( - controller.statusMessage.value, - style: TextStyle( - color: isDarkMode ? Colors.blue[300] : Colors.blue[700], - fontSize: 12.sp, - ), - ), - ) - : const SizedBox.shrink()), - - // 播放按钮 - Container( - margin: EdgeInsets.symmetric(vertical: 8.h), - child: ElevatedButton.icon( - icon: Obx(() => Icon( - controller.isPlaying.value ? Icons.stop : Icons.play_arrow, - color: Colors.white, - )), - label: Obx(() => Text( - controller.isPlaying.value ? '停止播放' : '播放音频', - style: TextStyle( - color: Colors.white, - fontSize: 16.sp, - ), - )), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - padding: EdgeInsets.symmetric(vertical: 16.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.r), - ), - ), - onPressed: controller.isDecoding.value - ? null - : () { - controller.playAudio(); - }, - ), - ), - - // 解码按钮 - Container( - margin: EdgeInsets.symmetric(vertical: 8.h), - child: Obx(() => ElevatedButton( - onPressed: controller.selectedFiles.isEmpty || - controller.isDecoding.value - ? null - : () { - controller.pickFiles(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - padding: EdgeInsets.symmetric(vertical: 16.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.r), - ), - ), - child: Text( - '开始解码', - style: TextStyle( - color: Colors.white, - fontSize: 16.sp, - ), - ), - )), - ), - ], - ); - } - - // 编码标签页(暂未实现) - Widget _buildEncodeTab(bool isDarkMode) { - return Center( - child: Text( - '编码功能尚未实现', - style: TextStyle( - color: isDarkMode ? Colors.white54 : Colors.black54, - fontSize: 16.sp, - ), - ), - ); - } - - // 构建带标题的区段 - Widget _buildSection({ - required String title, - required Widget child, - required bool isDarkMode, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - title, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black87, - ), - ), - SizedBox(width: 8.w), - Expanded( - child: Container( - height: 1, - color: isDarkMode ? Colors.white24 : Colors.black12, - ), - ), - if (title == '选择文件') - IconButton( - icon: Icon( - Icons.add_circle_outline, - color: isDarkMode ? Colors.blue[300] : Colors.blue, - ), - onPressed: () { - controller.pickFiles(); - }, - ), - ], - ), - Container( - padding: EdgeInsets.all(12.w), - decoration: BoxDecoration( - color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, - borderRadius: BorderRadius.circular(8.r), - boxShadow: [ - BoxShadow( - color: isDarkMode - ? Colors.black.withOpacity(0.2) - : Colors.black.withOpacity(0.03), - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - child: child, - ), - ], - ); - } -} diff --git a/apps/client/lib/modules/ota_upgrade/controllers/bes_ota_upgrade_controller.dart b/apps/client/lib/modules/ota_upgrade/controllers/bes_ota_upgrade_controller.dart deleted file mode 100644 index c66f50bd..00000000 --- a/apps/client/lib/modules/ota_upgrade/controllers/bes_ota_upgrade_controller.dart +++ /dev/null @@ -1,495 +0,0 @@ -import 'dart:async'; - -import 'package:bes_ota_manager/bes_ota_manager.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; - -import '../../../core/utils/logger.dart'; -import '../../../data/models/appconfig.dart'; -import '../../../data/models/appconfig_model.dart'; -import '../../../data/services/bes_bluetooth_service.dart'; -import 'bes_ota_download_service.dart'; - -/// OTA 升级阶段(驱动页面 UI)。 -class OtaPhase { - static const idle = 0; // 待升级 - static const downloading = 1; // 下载固件中 - static const upgrading = 2; // 蓝牙推送中 - static const success = 3; // 升级成功 - static const failed = 4; // 升级失败 -} - -/// 恒玄(BES)耳机固件 OTA 升级控制器。 -/// -/// 与杰理那条链路([OtaCoreService] / `ota_upgrade_controller.dart`)完全独立: -/// 不碰 BleManager / Jielihome,只依赖 [BesBluetoothService] 与 `bes_ota_manager` 插件。 -/// 本项目只有恒玄耳机,路由 `Routes.OTA_UPGRADE` 已指向这里。 -/// -/// 流程:读产品配置 → 断开 app 层 SPP → 下载固件 → 插件自管连接推送 → 设备重启。 -class BesOtaUpgradeController extends GetxController { - static const _tag = 'BesOtaUpgradeController'; - - /// 看门狗超时:升级阶段超过该时长无任何进度或状态推进,即判定卡死失败。 - /// 兜底原生侧「既不推进也不上报 onError」导致 UI 永久停在升级中的情况。 - static const Duration _watchdogTimeout = Duration(seconds: 90); - - /// 固件下载地址:来自后端 `user_getappconfig` 下发的产品配置,按设备名匹配。 - String _downloadUrl = ''; - - /// 页面已销毁标志:onClose 置位,用于让正在进行的下载/升级异步链提前退出, - /// 避免在已销毁的控制器上发起 startOta(僵尸会话)。 - bool _disposed = false; - - /// 当前下载的取消令牌:cancel()/onClose 时取消,立即中止下载、不再续跑。 - CancelToken? _downloadCancelToken; - - /// 升级看门狗定时器:每次收到进度/状态事件即重置;超时未重置则判失败。 - Timer? _watchdog; - - final BesBluetoothService _bes = BesBluetoothService.to; - final BesOtaManager _besOta = BesOtaManager(); - final BesOtaDownloadService _downloadService = BesOtaDownloadService(); - - // ---- 响应式状态 ---- - final RxString currentVersion = ''.obs; - final RxString latestVersion = ''.obs; - final RxString updateDescription = ''.obs; - final RxBool isUpdateAvailable = false.obs; - final RxBool isConnected = true.obs; - - /// 设备名快照:开始升级时会主动断开 app 层连接,[BesBluetoothService] 的字段被 - /// 清空,页面展示用控制器自己的快照,不跟着变空。 - final RxString deviceName = ''.obs; - - /// 设备地址快照,服务字段被清空后(如失败重试时)回退使用。 - String _address = ''; - - /// 是否为本次 OTA 主动断开过 app 层连接。主动断开不会自动回连, - /// 退出页面时据此决定是否恢复连接(没点过「开始升级」则无需处理)。 - bool _appConnectionReleased = false; - - final RxInt phase = OtaPhase.idle.obs; - final RxDouble downloadProgress = 0.0.obs; // 0~1 - final RxDouble otaProgress = 0.0.obs; // 0~1 - final RxString detailedStatus = ''.obs; - - StreamSubscription? _otaSub; - Worker? _versionWorker; - Worker? _statusWorker; - Worker? _deviceWorker; - Worker? _nameWorker; - - bool get isBusy => - phase.value == OtaPhase.downloading || phase.value == OtaPhase.upgrading; - - @override - void onInit() { - super.onInit(); - - isConnected.value = _bes.isConnected; - currentVersion.value = _bes.deviceVersion.value; - deviceName.value = _bes.connectedDeviceName.value; - _address = _bes.connectedDeviceId.value; - _loadProductConfig(); - - // 设备版本回包(`AA 06` → `BB 86`)后同步刷新;开始升级时主动断开会把 - // deviceVersion 清空,空值忽略,保留页面上已知的版本显示。 - _versionWorker = ever(_bes.deviceVersion, (String v) { - if (v.isEmpty) return; - currentVersion.value = v; - _refreshUpdateAvailable(); - }); - - // 跟踪 app 层连接状态(仅用于展示与按钮可用性)。 - // 注意:OTA 连接由插件自管、掉线会经 onError 上报;开始升级时 app 层连接 - // 是我们主动断开的,不能拿它的断开去中止 OTA。 - _statusWorker = ever(_bes.deviceStatus, (int s) { - isConnected.value = s == 2; - }); - - // (重)连接后重新捕获设备地址,失败重试时要用。 - _deviceWorker = ever(_bes.connectedDeviceId, (String id) { - if (id.isEmpty) return; - _address = id; - }); - - // 设备名就绪/变化时刷新快照并重载产品配置。注意:重连时服务里 id 先于 - // name 赋值且 worker 同步触发,挂在 id 上拿不到新名字;页面初始化恰逢 - // 重连中名字为空时,也靠这里在名字就绪后补加载,否则「最新版本」会一直为空。 - _nameWorker = ever(_bes.connectedDeviceName, (String n) { - if (n.isEmpty) return; - deviceName.value = n; - _loadProductConfig(); - }); - - _otaSub = _besOta.events.listen( - _handleOtaEvent, - // 事件通道本身异常(原生 sink 出错/通道断开)也要落到失败终态,避免无声卡死。 - onError: (Object err) { - if (phase.value == OtaPhase.success) return; - _onUpgradeError(null, err.toString()); - }, - ); - } - - @override - void onClose() { - _disposed = true; - _downloadCancelToken?.cancel('page closed'); // 中止可能仍在跑的下载 - _cancelWatchdog(); - _setKeepScreenOn(false); // 退出页面兜底关闭常亮 - _otaSub?.cancel(); - _versionWorker?.dispose(); - _statusWorker?.dispose(); - _deviceWorker?.dispose(); - _nameWorker?.dispose(); - - // 主动断开不会自动回连:若本次为 OTA 断开过 app 层连接,退出页面时恢复。 - // 升级成功时设备正在重启,延时几秒再连。 - final needReconnect = _appConnectionReleased && _address.isNotEmpty; - final addr = _address; - final name = deviceName.value; - // SDK 自己 OTA 成功后的内部重连等了 15s(MSG_OTA_OVER_RECONNECT),设备重启到 - // 蓝牙就绪需要数秒;即使这次连不上,耳机开机也会主动回连手机兜底。 - final delay = phase.value == OtaPhase.success - ? const Duration(seconds: 5) // 设备正在重启 - : const Duration(seconds: 2); // 等耳机拆完 OTA 链路再回连(与开始升级方向一致) - // 先停掉 OTA 会话释放插件占用的 SPP 连接,再回连 app 层,避免两条连接打架。 - _besOta.stopOta().whenComplete(() { - _besOta.dispose(); - if (!needReconnect) return; - Future.delayed(delay, () { - // 期间可能已(自动/手动)连上,避免重复发起。 - if (_bes.deviceStatus.value != 2) { - _bes.connectToDevice(addr, name); - } - }); - }); - super.onClose(); - } - - /// 从后端产品配置取最新固件版本与下载地址(按设备名匹配产品)。 - void _loadProductConfig() { - final product = _findProduct(deviceName.value); - if (product == null) { - Logger.w(_tag, '未找到设备对应的产品配置: ${deviceName.value}'); - latestVersion.value = ''; - updateDescription.value = ''; - _downloadUrl = ''; - } else { - latestVersion.value = product.version?.split('+').first.trim() ?? ''; - updateDescription.value = product.updatedescription ?? ''; - _downloadUrl = product.updatepackageaddress ?? ''; - // 「拉到的版本不对」这类问题以前只能靠猜:失败有日志、成功没有, - // 分不清是没匹配上产品、下发的值就是旧的、还是设备当前版本读错了。 - Logger.i( - _tag, - '产品配置: name=${product.devicename} pid=${product.id} ' - '服务端最新=${product.version} → 解析后=${latestVersion.value} ' - '设备当前=${currentVersion.value} ' - '产品表共${AppConfig.config.products.length}个'); - } - _refreshUpdateAvailable(); - } - - /// 后台是否允许「版本不一致即可升级」。取不到产品配置时按 false(严格)。 - bool get _allowDowngrade => - _findProduct(deviceName.value)?.updateonmismatch ?? false; - - /// 按设备名找产品。 - /// - /// ⚠️ 不要用 [AppConfig.getproduct]——它内部 `firstWhere` 没有 orElse, - /// 查不到直接抛 StateError,而调用点写的都是 `if (product == null)`, - /// 那个 null 判断永远等不到,异常会穿出去、在固件页表现成「加载失败」。 - /// - /// 顺序:设备名互相包含 → 唯一的经典蓝牙耳机产品 → null。 - DBProduct? _findProduct(String name) { - if (!AppConfig.isInitialized()) return null; - final products = AppConfig.config.products; - if (products.isEmpty) return null; - final n = name.trim().toUpperCase(); - if (n.isNotEmpty && n != 'UNKNOWNEQUIPMENT') { - for (final p in products) { - final pn = p.devicename.trim().toUpperCase(); - if (pn.isEmpty) continue; - if (pn.contains(n) || n.contains(pn)) return p; - } - } - // 本项目只有恒玄(经典蓝牙耳机)一种设备,名字对不上时退化到它是安全的 - final headsets = products.where((p) => p.devicetype == 1).toList(); - if (headsets.length == 1) return headsets.first; - return null; - } - - void _refreshUpdateAvailable() { - final cur = currentVersion.value.trim(); - final latest = latestVersion.value.trim(); - // 没有产品配置(无最新版本)不可升级 - if (latest.isEmpty) { - isUpdateAvailable.value = false; - return; - } - // 读不到当前版本时允许升级(未知 → 允许) - if (cur.isEmpty) { - isUpdateAvailable.value = true; - return; - } - // 判定口径由**后台产品配置**决定,不看包类型: - // updateonmismatch=true → 版本不一致就允许升(开发/测试,可刷回旧固件) - // updateonmismatch=false → 只有服务端版本更高才允许(正式,默认) - // ⚠️ 之前用 kDebugMode 分界是错的:测试同事拿到的往往是 release 包, - // 在开发环境照样点不动升级按钮。开关放后台才能按产品、按环境自由控制。 - // ⚠️ 字段可空(老服务端不下发),null 一律按 false 走严格判定—— - // 判宽的后果是线上用户被降级,判严只是开发时少一个便利。 - isUpdateAvailable.value = _allowDowngrade - ? latest != cur - : _isNewer(latest, cur); - } - - /// 入口:开始升级(下载 → 交接连接 → OTA 推送)。 - Future startUpdate() async { - if (isBusy) return; - if (!_bes.isConnected) { - _snack('otaError'.tr, 'otaNeedConnect'.tr); - return; - } - - // 关键:在断开现有连接前先捕获设备标识(disconnect 会清空); - // 服务字段已为空时(失败后重试)回退到控制器快照。 - final serviceId = _bes.connectedDeviceId.value; - final address = serviceId.isNotEmpty ? serviceId : _address; - final serviceName = _bes.connectedDeviceName.value; - final name = serviceName.isNotEmpty ? serviceName : deviceName.value; - if (address.isEmpty) { - _snack('otaError'.tr, 'otaNeedConnect'.tr); - return; - } - _address = address; - - // 产品配置缺失(AppConfig 晚于页面初始化等)时兜底重取一次。 - if (_downloadUrl.isEmpty) { - _loadProductConfig(); - if (_downloadUrl.isEmpty) { - _snack('otaError'.tr, 'otaVersionInfoFailed'.tr); - return; - } - } - - final cancelToken = CancelToken(); - _downloadCancelToken = cancelToken; - - try { - // 升级期间保持屏幕常亮(成功/失败/退出页面时关闭)。 - _setKeepScreenOn(true); - phase.value = OtaPhase.downloading; - downloadProgress.value = 0; - otaProgress.value = 0; - detailedStatus.value = 'otaPreparing'.tr; - - // 1) 释放 app 现有 SPP 连接,让插件自管 OTA 连接。 - await _bes.disconnectDevice(); - _appConnectionReleased = true; - // disconnect 返回只代表本端 socket 已关,耳机侧链路拆除还要在空中传播; - // 固件已缓存时下载不耗时、衔接近乎瞬时,立刻连 OTA 可能被拒 - //(多数固件同时只接受一条 SPP 链路),留 2s 缓冲等耳机拆完旧链路。 - // 曾出现过快速衔接导致 OTA 连接失败的案例,SDK 内部还有 3 次快速重连兜底。 - await Future.delayed(const Duration(seconds: 2)); - if (_disposed) return; // 期间已退出页面,不再继续 - // 2) 初始化原生 OTA 环境。 - await _besOta.init(); - - // 3) 下载固件。 - final filePath = await _downloadService.downloadFirmware( - downloadUrl: _downloadUrl, - cancelToken: cancelToken, - onProgress: (p) => downloadProgress.value = p, - onStatusUpdate: (s) => detailedStatus.value = s, - ); - - // 已退出/已取消则不再发起原生 OTA,避免在已销毁控制器上建立僵尸会话。 - if (_disposed || cancelToken.isCancelled) return; - - // 4) 启动 SPP OTA 2.0。 - phase.value = OtaPhase.upgrading; - detailedStatus.value = 'otaConnectingDevice'.tr; - // 看门狗从这里起覆盖「连接+握手+推送」全程:原生握手会持续发 onStatus、 - // 传输会持续发 onProgress,任一推进即重置;长时间静默(如原生卡住且不报错)兜底失败。 - // 下载阶段的卡死另由 Dio 超时与 CancelToken 处理,不纳入看门狗。 - _armWatchdog(); - await _besOta.startOta( - address: address, - name: name, - filePath: filePath, - protocol: 'SPP', - userFlag: 1, - upgradeType: 1, - user: 1, - ); - } catch (e, st) { - // 用户取消下载 / 页面已退出:静默收尾(控制器可能已销毁)。 - if (_disposed || (e is DioException && CancelToken.isCancel(e))) { - return; - } - Logger.e(_tag, '升级流程异常', e, st); - _onUpgradeError(null, e.toString()); // 统一走失败终态(含去重/看门狗关闭) - } - } - - /// 取消升级。 - void cancel() { - _downloadCancelToken?.cancel('user cancelled'); // 中止下载,阻断后续 startOta - _cancelWatchdog(); - _besOta.stopOta(); - _setKeepScreenOn(false); - phase.value = OtaPhase.idle; - detailedStatus.value = ''; - downloadProgress.value = 0; - otaProgress.value = 0; - } - - // ---- 看门狗:覆盖升级(连接/握手/推送)阶段,超时未推进则判失败 ---- - void _armWatchdog() { - if (_disposed) return; - _watchdog?.cancel(); - _watchdog = Timer(_watchdogTimeout, _onWatchdogTimeout); - } - - void _cancelWatchdog() { - _watchdog?.cancel(); - _watchdog = null; - } - - void _onWatchdogTimeout() { - if (_disposed) return; - if (phase.value == OtaPhase.upgrading) { - Logger.w(_tag, 'OTA 看门狗超时:${_watchdogTimeout.inSeconds}s 无进度推进'); - _onUpgradeError(null, 'otaTimeout'.tr); - } - } - - /// 屏幕常亮开关。失败不致命,吞掉异常。 - void _setKeepScreenOn(bool on) { - try { - on ? WakelockPlus.enable() : WakelockPlus.disable(); - } catch (e) { - Logger.w(_tag, '设置屏幕常亮失败: $e'); - } - } - - void _handleOtaEvent(BesOtaEvent e) { - // 进入终态(成功/失败)即锁定:stopOta 清理、设备重启断链、看门狗超时后迟到的 - // 进度/Canceled/Error 一律忽略,避免成功被覆盖成失败、或失败被迟到进度翻回升级中。 - // 新会话由 startUpdate 重置 phase。 - if (phase.value == OtaPhase.success || phase.value == OtaPhase.failed) { - return; - } - // 任何进度/状态推进都重置看门狗(成功/失败在各自处理里关闭看门狗)。 - if (e.event == 'onProgress' || e.event == 'onStatus') { - _armWatchdog(); - } - switch (e.event) { - case 'onProgress': - phase.value = OtaPhase.upgrading; - _updateProgress(e.progress ?? 0); - break; - case 'onStatus': - // SDK 的 OTA_SEND_DATA_PROGRESS 状态消息带原始浮点百分比(如 99.94765…), - // 当作进度更新并入单一写入口;其余状态文本只记日志不上屏—— - // 曾直接展示,但与进度文案高频交替会闪烁出看不清的内容。 - final numeric = double.tryParse(e.message ?? ''); - if (numeric != null && numeric > 0) { - phase.value = OtaPhase.upgrading; - _updateProgress((numeric / 100).clamp(0.0, 1.0)); - } - break; - case 'onSuccess': - _onUpgradeSuccess(); - break; - case 'onError': - _onUpgradeError(e.code, e.message); - break; - } - } - - /// 进度的单一写入口:onProgress(Android 为整数百分比)与数字型 onStatus - /// 消息(浮点百分比)都汇到这里,只在单调递增时刷新,避免两个来源 - /// 高频交替导致数字来回跳动。 - void _updateProgress(double p) { - if (p <= otaProgress.value) return; - otaProgress.value = p; - detailedStatus.value = 'otaUpgrading'.trParams({ - 'percent': (p * 100).toStringAsFixed(2), - }); - } - - void _onUpgradeSuccess() { - _cancelWatchdog(); - _setKeepScreenOn(false); - phase.value = OtaPhase.success; - otaProgress.value = 1.0; - detailedStatus.value = 'otaSuccessReboot'.tr; - currentVersion.value = latestVersion.value; - isUpdateAvailable.value = false; - _besOta.stopOta(); - } - - void _onUpgradeError(int? code, String? message) { - if (phase.value == OtaPhase.failed) return; // 已是失败终态,避免重复弹窗 - _cancelWatchdog(); - _setKeepScreenOn(false); - phase.value = OtaPhase.failed; - // 进入失败终态即释放原生 OTA 会话与 SPP 链路(与 _onUpgradeSuccess/cancel 对齐)。 - // 否则看门狗超时/连接失败后 native 会话仍在跑,占用唯一 SPP,且因终态已锁, - // 迟到的 onSuccess 会被吞——会卡死在「失败」直到用户退页面才由 onClose 清理。 - _besOta.stopOta(); - detailedStatus.value = 'otaFailed'.tr; - _snack( - 'otaFailed'.tr, - message?.isNotEmpty == true - ? message! - : 'otaFailedWithCode'.trParams({'code': '${code ?? -1}'}), - ); - } - - void _snack(String title, String msg) { - Get.snackbar( - title, - msg, - snackPosition: SnackPosition.TOP, - backgroundColor: Colors.red[400], - colorText: Colors.white, - margin: const EdgeInsets.all(12), - duration: const Duration(seconds: 3), - ); - } - - /// [a] 是否比 [b] 新。 - /// - /// 逐段取前导数字比较,与服务端 `comm.CompareVersion` 同口径—— - /// `1.0.0-beta` / `1.2.3+4` 这类版本串不会抛异常(用 int.parse 会抛, - /// 被外层 catch 吞掉后表现为「明明有新版本却说已是最新」)。 - bool _isNewer(String a, String b) { - final pa = _splitVersion(a); - final pb = _splitVersion(b); - if (pa.isEmpty || pb.isEmpty) return a.trim() != b.trim(); - final len = pa.length > pb.length ? pa.length : pb.length; - for (var i = 0; i < len; i++) { - final x = i < pa.length ? pa[i] : 0; - final y = i < pb.length ? pb[i] : 0; - if (x != y) return x > y; - } - return false; - } - - /// 把版本串按 `.` 切开,每段只取前导数字(`3+4` → 3,`0-beta` → 0)。 - List _splitVersion(String v) { - final s = v.trim(); - if (s.isEmpty) return const []; - return s.split('.').map((seg) { - final m = RegExp(r'^\d+').firstMatch(seg.trim()); - return m == null ? 0 : int.parse(m.group(0)!); - }).toList(); - } -} diff --git a/apps/client/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart b/apps/client/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart deleted file mode 100644 index 8e202d32..00000000 --- a/apps/client/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart +++ /dev/null @@ -1,659 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:logger/web.dart'; -import '../../../data/services/ble_manager.dart'; -import '../../../data/services/bes_bluetooth_service.dart'; -import '../../../data/services/bluetooth_manager.dart'; // 添加蓝牙管理器导入 -import '../../../data/models/appconfig.dart'; -import '../../../data/models/appconfig_model.dart'; - -enum OtaCoreErrorType { - deviceInfoUnavailable, - productInfoUnavailable, - firmwareVersionFormatInvalid, -} - -class OtaCoreException implements Exception { - final OtaCoreErrorType type; - final Map params; - final String debugMessage; - - OtaCoreException({ - required this.type, - this.params = const {}, - this.debugMessage = '', - }); - - @override - String toString() => debugMessage; -} - -/// OTA核心服务 -/// 整合设备检查、版本管理和状态管理功能 -class OtaCoreService { - final BleManager _bleManager = Get.find(); - final BluetoothManager _bluetoothManager = - Get.find(); // 添加蓝牙管理器引用 - final int maxRetryAttempts = 3; - int currentRetryCount = 0; - - // ==================== 状态常量定义 ==================== - static const int NOT_STARTED = -1; // 未开始 - static const int VALIDATION_FILE = 0; // 验证文件 - static const int ONUPGRADE = 1; // 升级中 - static const int START_UPGRADING = 2; // 开始升级 - static const int UPGRADE_SUCCESS = 3; // 升级成功 - static const int UPGRADE_FAILED = 4; // 升级失败 - static const int CONNECTION_DISCONNECTION = 5; // 连接断开 - static const int CONNECTION_SUCCESS = 6; // 连接成功 - static const int DOWNLOADING_UP_FILES = 7; // 下载升级文件 - - /// 获取状态描述信息 - /// [type] 状态类型 - /// 返回对应的状态描述文本 - static String getStatusMessage(int type) { - switch (type) { - case NOT_STARTED: - return "未开始".tr; - case VALIDATION_FILE: - return "验证文件中".tr; - case ONUPGRADE: - return "升级中".tr; - case START_UPGRADING: - return "开始升级".tr; - case UPGRADE_SUCCESS: - return "升级成功".tr; - case UPGRADE_FAILED: - return "升级失败".tr; - case CONNECTION_DISCONNECTION: - return "连接断开".tr; - case CONNECTION_SUCCESS: - return "连接成功".tr; - case DOWNLOADING_UP_FILES: - return "下载升级文件".tr; - default: - return "未知状态".tr; - } - } - - // ==================== 设备检查功能 ==================== - - /// 检查BLE设备连接状态 - /// 返回true表示设备已连接,可以进行升级 - /// 返回false表示设备未连接,不能进行升级 - bool checkBleConnectionStatus() { - try { - // 检查BLE管理器的连接状态 - if (!_bleManager.isConnected) { - //_showBleDisconnectedDialog(); - return false; - } - - // 检查设备信息是否可用 - final deviceInfo = _bleManager.deviceInfo.value; - if (deviceInfo == null) { - _showDeviceInfoUnavailableDialog(); - return false; - } - - return true; - } catch (e) { - _showBleCheckFailedSnackbar('otaBleCheckErrorWithError'.trParams({'error': e.toString()})); - return false; - } - } - - /// 显示BLE设备未连接对话框 - void _showBleDisconnectedDialog() { - Get.dialog( - AlertDialog( - title: Text( - 'otaBleNotConnected'.tr, - style: TextStyle( - color: Colors.red[700], - fontWeight: FontWeight.w600, - ), - ), - content: Text( - 'otaBleNeedConnection'.tr, - style: TextStyle( - color: Colors.red[600], - ), - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - style: TextButton.styleFrom( - foregroundColor: Colors.red[700], - ), - child: Text('okConfirm'.tr), - ), - ], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: Colors.red.withValues(alpha: 0.3), - width: 1, - ), - ), - backgroundColor: Colors.red[50], - ), - ); - } - - /// 显示设备信息不可用对话框 - void _showDeviceInfoUnavailableDialog() { - Get.dialog( - AlertDialog( - title: Text( - 'otaDeviceInfoUnavailableTitle'.tr, - style: TextStyle( - color: Colors.orange[700], - fontWeight: FontWeight.w600, - ), - ), - content: Text( - 'otaCannotGetDeviceInfo'.tr, - style: TextStyle( - color: Colors.orange[600], - ), - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - style: TextButton.styleFrom( - foregroundColor: Colors.orange[700], - ), - child: Text('okConfirm'.tr), - ), - ], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: Colors.orange.withValues(alpha: 0.3), - width: 1, - ), - ), - backgroundColor: Colors.orange[50], - ), - ); - } - - /// 显示BLE连接检查失败提示 - /// [message] 错误信息 - void _showBleCheckFailedSnackbar(String message) { - Get.snackbar( - 'otaBleCheckFailed'.tr, - message, - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red[600], - colorText: Colors.white, - icon: const Icon( - Icons.bluetooth_disabled, - color: Colors.white, - ), - ); - } - - /// 检查设备电量是否满足升级条件 - /// 返回true表示电量充足,可以进行升级 - /// 返回false表示电量不足,不能进行升级 - bool checkBatteryLevel() { - try { - final deviceInfo = _bleManager.deviceInfo.value; - - if (deviceInfo == null) { - _showBatteryCheckFailedSnackbar('otaCannotGetDeviceInfoCheckConnection'.tr); - return false; - } - - // 获取设备类型以确定检查策略 - String deviceName = getDeviceName(); - final DBProduct? product = findProduct(deviceName); - if (product == null) { - _showBatteryCheckFailedSnackbar('otaCannotGetProductInfoWithName' - .trParams({'deviceName': deviceName})); - return false; - } - - final leftBattery = deviceInfo.leftEarBattery; - final rightBattery = deviceInfo.rightEarBattery; - - // 根据设备类型进行不同的电量检查策略 - if (product.devicetype == DeviceType.bleBluetoothSingleHeadset.value || - product.devicetype == DeviceType.smartEye.value || - product.devicetype == DeviceType.smartVoiceRecorder.value) { - // 单端耳机:只需要检查有效的那一端电量 - return _checkSingleHeadsetBattery(leftBattery, rightBattery); - } else if (product.devicetype == - DeviceType.bleBluetoothDoubleHeadset.value) { - // 双端耳机:需要检查左右两端电量 - return _checkDoubleHeadsetBattery(leftBattery, rightBattery); - } else { - // 其他设备类型,使用原有逻辑(双端检查) - return _checkDoubleHeadsetBattery(leftBattery, rightBattery); - } - } catch (e) { - _showBatteryCheckFailedSnackbar('otaBleCheckErrorWithError'.trParams({'error': e.toString()})); - return false; - } - } - - /// 检查单端耳机电量 - /// [leftBattery] 左耳电量 - /// [rightBattery] 右耳电量 - /// 返回true表示电量充足 - bool _checkSingleHeadsetBattery(int leftBattery, int rightBattery) { - // 单端耳机逻辑:优先检查左耳,如果左耳无效(<=0或127)则检查右耳 - int activeBattery; - String activeEar; - - if (leftBattery > 0 && leftBattery != 127) { - // 左耳有效,使用左耳电量 - activeBattery = leftBattery; - activeEar = 'leftEar'.tr; - } else if (rightBattery > 0 && rightBattery != 127) { - // 左耳无效,使用右耳电量 - activeBattery = rightBattery; - activeEar = 'rightEar'.tr; - } else { - // 两边都无效 - _showBatteryCheckFailedSnackbar('otaCannotGetBatteryInfo'.tr); - return false; - } - - // 检查有效端的电量是否大于20% - if (activeBattery <= 20) { - _showSingleHeadsetLowBatteryDialog(activeEar, activeBattery); - return false; - } - - return true; - } - - /// 检查双端耳机电量 - /// [leftBattery] 左耳电量 - /// [rightBattery] 右耳电量 - /// 返回true表示电量充足 - bool _checkDoubleHeadsetBattery(int leftBattery, int rightBattery) { - // 双端耳机逻辑:需要检查左右两端电量是否都大于20% - - // 检查电量数据有效性 - bool leftValid = leftBattery > 0 && leftBattery != 127; - bool rightValid = rightBattery > 0 && rightBattery != 127; - - if (!leftValid || !rightValid) { - _showBatteryCheckFailedSnackbar('otaCannotGetBatteryInfo'.tr); - return false; - } - - // 如果只有一端有效,按单端处理 - // if (!leftValid || !rightValid) { - // String activeEar = leftValid ? '左耳' : '右耳'; - // int activeBattery = leftValid ? leftBattery : rightBattery; - - // if (activeBattery <= 20) { - // _showSingleHeadsetLowBatteryDialog(activeEar, activeBattery); - // return false; - // } - // return true; - // } - - // 两端都有效,检查是否都大于20% - if (leftBattery <= 20 || rightBattery <= 20) { - _showLowBatteryDialog(leftBattery, rightBattery); - return false; - } - - return true; - } - - /// 显示单端耳机电量不足对话框 - /// [earSide] 耳机端(左耳/右耳) - /// [batteryLevel] 电量百分比 - void _showSingleHeadsetLowBatteryDialog(String earSide, int batteryLevel) { - String message = 'otaSingleBatteryLow'.trParams({ - 'ear': earSide, - 'battery': batteryLevel.toString(), - }); - - Get.dialog( - AlertDialog( - title: Text( - 'otaLowBattery'.tr, - style: TextStyle( - color: Colors.orange[700], - fontWeight: FontWeight.w600, - ), - ), - content: Text( - message, - style: TextStyle( - color: Colors.orange[600], - ), - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - style: TextButton.styleFrom( - foregroundColor: Colors.orange[700], - ), - child: Text('okConfirm'.tr), - ), - ], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: Colors.orange.withValues(alpha: 0.3), - width: 1, - ), - ), - backgroundColor: Colors.orange[50], - ), - ); - } - - /// 显示电量不足对话框 - /// [leftBattery] 左耳电量 - /// [rightBattery] 右耳电量 - void _showLowBatteryDialog(int leftBattery, int rightBattery) { - String message = 'otaDoubleBatteryLow'.trParams({ - 'left': leftBattery.toString(), - 'right': rightBattery.toString(), - }); - - Get.dialog( - AlertDialog( - title: Text( - 'otaLowBattery'.tr, - style: TextStyle( - color: Colors.orange[700], - fontWeight: FontWeight.w600, - ), - ), - content: Text( - message, - style: TextStyle( - color: Colors.orange[600], - ), - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - style: TextButton.styleFrom( - foregroundColor: Colors.orange[700], - ), - child: Text('okConfirm'.tr), - ), - ], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: Colors.orange.withValues(alpha: 0.3), - width: 1, - ), - ), - backgroundColor: Colors.orange[50], - ), - ); - } - - /// 显示电量检查失败提示 - /// [message] 错误信息 - void _showBatteryCheckFailedSnackbar(String message) { - Get.snackbar( - 'otaBatteryCheckFailed'.tr, - message, - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.orange[600], - colorText: Colors.white, - icon: const Icon( - Icons.warning_amber, - color: Colors.white, - ), - ); - } - - // ==================== 版本管理功能 ==================== - - - /// 按设备名安全地查产品配置。 - /// - /// 匹配规则比 `AppConfig.getproduct()` 宽:后者只做「产品名包含设备名」这一个 - /// 方向,恒玄耳机的广播名与产品表里的名字常常互为子串的另一头,单向匹配会漏。 - /// (getproduct 本身已经不会抛了,2026-09-03 起查不到返回 null。) - /// - /// 顺序:设备名互相包含 → 唯一的经典蓝牙耳机产品 → null。 - /// 老代码里的兜底名 "DeapSound" 是上一代品牌,早已不在产品表里,去掉了。 - DBProduct? findProduct(String deviceName) { - if (!AppConfig.isInitialized()) return null; - final products = AppConfig.config.products; - if (products.isEmpty) return null; - final n = deviceName.trim().toUpperCase(); - if (n.isNotEmpty && n != 'UNKNOWNEQUIPMENT') { - for (final p in products) { - final pn = p.devicename.trim().toUpperCase(); - if (pn.isEmpty) continue; - if (pn.contains(n) || n.contains(pn)) return p; - } - } - // 本项目只有恒玄(经典蓝牙耳机)一种设备,名字对不上时退化到它是安全的 - final headsets = products.where((p) => p.devicetype == 1).toList(); - if (headsets.length == 1) return headsets.first; - return null; - } - - /// 动态获取当前连接的设备名称 - /// 参考home_controller中的getDeviceName方法实现 - String getDeviceName() { - try { - // 优先从经典蓝牙获取设备名称 - final currentDevice = _bluetoothManager.currentDevice; - if (currentDevice?.name != null && currentDevice!.name.isNotEmpty) { - return currentDevice.name; - } - - // 其次从BLE设备信息获取 - final deviceInfo = _bleManager.deviceInfo.value; - if (deviceInfo?.name != null && deviceInfo!.name.isNotEmpty) { - return deviceInfo.name; - } - - // 恒玄耳机不在杰理 RCSP 链路上,上面两个来源都拿不到它。 - // 少了这一支,恒玄用户的设备名恒为 unknownEquipment, - // 后面按名字查产品必然落空 → 固件页取不到服务端版本。 - if (Get.isRegistered()) { - final besName = BesBluetoothService.to.connectedDeviceName.value; - if (besName.isNotEmpty) return besName; - } - - // 最后从BLE管理器的最后连接设备名称获取 - // if (_bleManager.currentDeviceName.isNotEmpty) { - // return _bleManager.currentDeviceName; - // } - - // 如果都获取不到,返回默认值 - return "unknownEquipment"; - } catch (e) { - Logger().e("获取设备名称失败: $e"); - return "unknownEquipment"; - } - } - - /// 获取当前设备版本信息 - /// 返回包含当前版本、产品ID和设备信息的Map - /// 抛出异常如果获取失败 - Future> getCurrentVersionInfo() async { - DeviceInfo? deviceInfo = _bleManager.deviceInfo.value; - - // 重试机制获取设备信息 - while (currentRetryCount < maxRetryAttempts) { - deviceInfo = _bleManager.deviceInfo.value; - - if (deviceInfo != null && - deviceInfo.firmwareVersion.isNotEmpty && - deviceInfo.firmwareProductId.isNotEmpty) { - break; - } - - await _bleManager.fetchDeviceInfo(); - await Future.delayed(const Duration(seconds: 5)); - currentRetryCount++; - } - - if (deviceInfo == null || - deviceInfo.firmwareVersion.isEmpty || - deviceInfo.firmwareProductId.isEmpty) { - throw OtaCoreException( - type: OtaCoreErrorType.deviceInfoUnavailable, - debugMessage: 'deviceInfoUnavailable', //获取设备信息失败,请检查连接 - ); - } - - String deviceName = getDeviceName(); - - // 获取产品信息以确定设备类型 - final DBProduct? product = findProduct(deviceName); - if (product == null) { - throw OtaCoreException( - type: OtaCoreErrorType.productInfoUnavailable, - params: {'deviceName': deviceName}, - debugMessage: 'productInfoUnavailable: $deviceName', //根据设备名称未找到产品信息 - ); - } - - // 根据设备类型进行不同的版本校验 - String currentVersion; - if (product.devicetype == DeviceType.bleBluetoothSingleHeadset.value) { - // 单端耳机:固件版本只有三位数(如:1.2.3) - final firmwareParts = deviceInfo.firmwareVersion.split('.'); - if (firmwareParts.length < 3) { - throw OtaCoreException( - type: OtaCoreErrorType.firmwareVersionFormatInvalid, - params: {'firmwareVersion': deviceInfo.firmwareVersion}, - debugMessage: - 'single firmwareVersion format invalid: ${deviceInfo.firmwareVersion}', //单端耳机固件版本格式无效 - ); - } - currentVersion = firmwareParts.sublist(0, 3).join('.'); - Logger().d("单端耳机版本: $currentVersion"); - } else if (product.devicetype == - DeviceType.bleBluetoothDoubleHeadset.value) { - // 双端耳机:固件版本有六位数(如:1.2.3.1.2.3) - final firmwareParts = deviceInfo.firmwareVersion.split('.'); - if (firmwareParts.length < 6) { - throw OtaCoreException( - type: OtaCoreErrorType.firmwareVersionFormatInvalid, - params: {'firmwareVersion': deviceInfo.firmwareVersion}, - debugMessage: - 'double firmwareVersion length invalid: ${deviceInfo.firmwareVersion}', //双端耳机固件版本格式无效 - ); - } - final firstPart = firmwareParts.sublist(0, 3).join('.'); - final secondPart = firmwareParts.sublist(3, 6).join('.'); - - Logger().d("双端耳机版本 - firstPart: $firstPart, secondPart: $secondPart"); - - if (firstPart != secondPart) { - throw OtaCoreException( - type: OtaCoreErrorType.firmwareVersionFormatInvalid, - params: {'firmwareVersion': deviceInfo.firmwareVersion}, - debugMessage: - 'double firmwareVersion mismatch: ${deviceInfo.firmwareVersion}', //双端耳机固件版本不一致,前三位和后三位不一样 - ); - } - currentVersion = firstPart; - } else { - // 其他设备类型,使用原有逻辑 - final firmwareParts = deviceInfo.firmwareVersion.split('.'); - if (firmwareParts.length >= 3) { - currentVersion = firmwareParts.sublist(0, 3).join('.'); - } else { - currentVersion = deviceInfo.firmwareVersion; - } - Logger().d("其他设备类型版本: $currentVersion"); - } - - return { - 'currentVersion': currentVersion, - 'productId': deviceInfo.firmwareProductId, - 'deviceInfo': deviceInfo, - }; - } - - /// 获取最新版本信息 - /// 返回包含最新版本、更新描述和更新包地址的Map - /// 抛出异常如果获取失败 - Future> getLatestVersionInfo() async { - // 动态获取当前连接的设备名称 - String deviceName = getDeviceName(); - - final DBProduct? product = findProduct(deviceName); - if (product == null) { - throw "无法获取产品信息,设备名称: $deviceName"; - } - - // ⚠️ 强更以**后台 product.isforceupdate 字段**为准。 - // 原来写的是 `product.version?.contains("+")`——拿版本号里有没有 "+" - // 当强更开关,跟后台配置完全脱节(后台勾了拦不住、版本号带 + 的全被强更)。 - // 这和 App 版本更新那边踩过的土规则是同一个毛病,别改回去。 - return { - 'latestVersion': product.version?.split('+').first ?? "", - 'updateDescription': product.updatedescription ?? "", - 'updatePackageAddress': product.updatepackageaddress ?? "", - 'isForceUpdate': product.isforceupdate ?? false, - }; - } - - /// 把版本串按 `.` 切开,每段只取前导数字(`3+4` → 3,`0-beta` → 0)。 - /// 与服务端 comm.CompareVersion 的口径一致。 - static List _splitVersion(String v) { - final s = v.trim(); - if (s.isEmpty) return const []; - return s.split('.').map((seg) { - final m = RegExp(r'^\d+').firstMatch(seg.trim()); - return m == null ? 0 : int.parse(m.group(0)!); - }).toList(); - } - - /// 比较版本号,判断是否有新版本可用 - /// [currentVersion] 当前版本号 - /// [latestVersion] 最新版本号 - /// 返回true表示有新版本可用(只支持升级) - bool isNewVersionAvailable(String currentVersion, String latestVersion) { - try { - // ⚠️ 逐段取**前导数字**,不能用 int.parse:固件版本出现过 - // `1.0.0-beta` / `1.2.3+4` 这类后缀,int.parse 直接抛, - // 异常被下面的 catch 吞掉后返回 false —— 表现是「明明有新版本却说已是最新」。 - List currentParts = _splitVersion(currentVersion); - List latestParts = _splitVersion(latestVersion); - if (currentParts.isEmpty || latestParts.isEmpty) return false; - - // 确保两个版本号长度一致,短的用0补齐 - int maxLength = currentParts.length > latestParts.length - ? currentParts.length - : latestParts.length; - - while (currentParts.length < maxLength) { - currentParts.add(0); - } - while (latestParts.length < maxLength) { - latestParts.add(0); - } - - // 从高位到低位比较版本号 - for (int i = 0; i < maxLength; i++) { - if (latestParts[i] > currentParts[i]) { - return true; - } else if (latestParts[i] < currentParts[i]) { - return false; - } - } - - return false; - } catch (e) { - Logger().d('版本比较错误: $e'); - return false; - } - } -} diff --git a/apps/client/lib/modules/ota_upgrade/controllers/ota_download_contropller.dart b/apps/client/lib/modules/ota_upgrade/controllers/ota_download_contropller.dart deleted file mode 100644 index a3f97485..00000000 --- a/apps/client/lib/modules/ota_upgrade/controllers/ota_download_contropller.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:io'; -import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:path/path.dart'; -import 'package:logger/web.dart'; - -/// OTA下载服务 -/// 负责处理固件包的下载、文件管理等功能 -class OtaDownloadService { - late final Dio _dio; - - /// 构造函数 - /// 初始化Dio实例并配置SSL证书处理 - OtaDownloadService() { - _dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 30), // 增加连接超时时间 - receiveTimeout: Duration.zero, - sendTimeout: const Duration(seconds: 30), // 增加发送超时时间 - headers: {'Accept-Encoding': 'gzip'}, - )); - - // 配置SSL证书验证 - (_dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { - final client = HttpClient(); - - // 处理SSL证书验证 - client.badCertificateCallback = - (X509Certificate cert, String host, int port) { - // 在生产环境中,应该进行适当的证书验证 - // 这里为了解决证书过期问题,暂时跳过验证 - // 建议:在生产环境中实现更安全的证书验证逻辑 - Logger().w('SSL证书验证跳过: $host:$port'); - return true; - }; - - return client; - }; - } - - /// 下载固件包 - /// [downloadUrl] 下载地址 - /// [onProgress] 下载进度回调 - /// [onStatusUpdate] 状态更新回调 - /// 返回下载文件的本地路径 - /// 抛出异常如果下载失败 - Future downloadFirmware({ - required String downloadUrl, - required Function(double) onProgress, - required Function(String) onStatusUpdate, - }) async { - onStatusUpdate('准备下载固件包...'); - - final fileName = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1); - const directoryName = "ota_upgrades"; - - // 获取下载目录 - onStatusUpdate('正在获取下载目录...'); - final Directory? downloadsDir = await getApplicationSupportDirectory(); - if (downloadsDir == null) { - throw FileSystemException('无法访问下载目录'); - } - - final targetDir = Directory('${downloadsDir.path}/Download/$directoryName'); - if (!await targetDir.exists()) { - onStatusUpdate('创建下载目录...'); - await targetDir.create(recursive: true); - } - - final savePath = '${targetDir.path}/$fileName'; - - // 检查是否已存在同名文件 - onStatusUpdate('检查本地文件...'); - if (await _checkExistingFile(targetDir, fileName)) { - onStatusUpdate('文件已存在,准备升级...'); - return savePath; - } - - // 清理旧文件 - onStatusUpdate('清理旧文件...'); - await _cleanOldFiles(targetDir); - - // 开始下载 - onStatusUpdate('开始下载固件包...'); - Logger().d('文件保存路径: $savePath'); - - final options = Options( - receiveTimeout: Duration.zero, - followRedirects: true, - maxRedirects: 5, - ); - - await _dio.download( - downloadUrl, - savePath, - options: options, - onReceiveProgress: (received, total) { - if (total != -1) { - final progress = received / total; - onProgress(progress); - onStatusUpdate('正在下载固件包... ${(progress * 100).toStringAsFixed(1)}%'); - } - }, - ); - - // 验证文件是否下载成功 - final file = File(savePath); - if (!await file.exists()) { - throw FileSystemException('文件下载失败,请检查写入权限'); - } - - onStatusUpdate('下载完成,准备升级...'); - return savePath; - } - - /// 检查是否存在同名文件 - /// [targetDir] 目标目录 - /// [fileName] 文件名 - /// 返回true表示文件已存在 - Future _checkExistingFile(Directory targetDir, String fileName) async { - try { - final files = await targetDir.list().toList(); - for (var entity in files) { - if (await FileSystemEntity.isFile(entity.path)) { - final name = basename(entity.path); - if (name == fileName) { - return true; - } - } - } - return false; - } catch (e) { - Logger().d('检查本地文件失败: $e'); - return false; - } - } - - /// 清理旧文件 - /// [targetDir] 目标目录 - /// 抛出异常如果清理失败 - Future _cleanOldFiles(Directory targetDir) async { - try { - final files = await targetDir.list().toList(); - for (var entity in files) { - if (await FileSystemEntity.isFile(entity.path)) { - await entity.delete(); - } - } - } catch (e) { - throw Exception('删除旧文件失败: $e'); - } - } -} \ No newline at end of file diff --git a/apps/client/lib/modules/ota_upgrade/controllers/bes_ota_download_service.dart b/apps/client/lib/modules/ota_upgrade/controllers/ota_download_service.dart similarity index 100% rename from apps/client/lib/modules/ota_upgrade/controllers/bes_ota_download_service.dart rename to apps/client/lib/modules/ota_upgrade/controllers/ota_download_service.dart diff --git a/apps/client/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart b/apps/client/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart deleted file mode 100644 index c17c940c..00000000 --- a/apps/client/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart +++ /dev/null @@ -1,424 +0,0 @@ -import 'dart:async'; -import '/data/services/bes_bluetooth_service.dart'; - -import 'package:classic_bluetooth/classic_bluetooth.dart'; -import 'package:device_jieli/device_jieli.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -// import 'package:logger/web.dart'; -import 'package:get_storage/get_storage.dart'; -import '../../../data/services/ble_manager.dart'; -import 'ota_core_contropller.dart'; -import 'ota_download_contropller.dart'; -import '../views/ota_upgrade_popup_view.dart'; -import '../../../core/utils/logger.dart'; - -/// OTA升级主控制器 - 重构后的简化版本 -/// 负责协调各个服务,管理升级流程的整体状态 -class OtaUpgradeController extends GetxController { - // 服务依赖 - 使用新的合并后的服务 - final _coreService = OtaCoreService(); - final _downloadService = OtaDownloadService(); - final _uiService = OtaUpgradePopUp(); - final _bleManager = Get.find(); - final _jlota = Jielihome.instance; - StreamSubscription? _otaEventSub; - - // 状态管理 - final RxString errorMessage = ''.obs; - final RxString latestVersion = ''.obs; - final RxString currentVersion = ''.obs; - final RxString detailedStatus = ''.obs; - final RxString updatedescription = ''.obs; - final RxDouble downloadProgress = 0.0.obs; - final RxDouble progress = 0.0.obs; - final RxInt type = OtaCoreService.NOT_STARTED.obs; - final RxBool isUpdateAvailable = false.obs; - final RxBool isDownloading = false.obs; - final RxBool isUpgrade = false.obs; - final RxBool isFetchingDeviceInfo = false.obs; - final RxBool showDetailedStatus = false.obs; - final RxBool isBleConnected = true.obs; - final GetStorage _storage = GetStorage(); - - String _address = ''; - String _updatePackageAddress = ''; - String? _lastConnectedDeviceImage; // 新增:存储上一次连接设备的图片链接 - - get lastConnectedDeviceImage => _lastConnectedDeviceImage; - - @override - void onInit() { - super.onInit(); - _loadLastConnectedDeviceImage(); // 加载持久化的设备图片 - _bindBesVersion(); - _initializeController(); - } - - @override - void onClose() { - _jlota.otaCancel(); - _otaEventSub?.cancel(); - _besVersionWorker?.dispose(); - _otaEventSub = null; - _bleManager.setEnterOta(false); - isUpgrade.value = false; - super.onClose(); - } - - /// 恒玄耳机的固件版本是连接后异步查回来的(`AA 06` → `0x86` 解析), - /// 一次性读取会赶不上:页面先开、版本后到就一直空着。 - /// 这里改成订阅,版本一到就刷新界面。 - Worker? _besVersionWorker; - - void _bindBesVersion() { - if (!Get.isRegistered()) return; - final bes = BesBluetoothService.to; - void apply(String v) { - if (v.isEmpty || !bes.isConnected) return; - currentVersion.value = v; - isBleConnected.value = true; - // 有了当前版本才能和服务端最新版本比对 - isUpdateAvailable.value = - _coreService.isNewVersionAvailable(v, latestVersion.value); - } - - apply(bes.deviceVersion.value); - _besVersionWorker = ever(bes.deviceVersion, apply); - // 连上但版本还没回来时,主动补查一次 - if (bes.isConnected && bes.deviceVersion.value.isEmpty) { - bes.sendData([0xAA, 0x06]); - } - } - - /// 从本地存储加载上一次连接设备的图片链接 - void _loadLastConnectedDeviceImage() { - _lastConnectedDeviceImage = _storage.read('last_connected_device_image'); - Logger.d( - 'OtaUpgradeController', '加载上一次连接设备的图片: $_lastConnectedDeviceImage'); - } - - /// 初始化控制器 - /// 设置事件监听并加载版本信息 - /// 恒玄耳机是否连着。恒玄不在杰理 RCSP 链路上, - /// [OtaCoreService.checkBleConnectionStatus] 查的是 BleManager(杰理), - /// 对恒玄恒为 false。 - bool get _besConnected => - Get.isRegistered() && - BesBluetoothService.to.isConnected; - - Future _initializeController() async { - // ⚠️ 这里原来是「杰理没连上就直接 return」,而本项目用的是恒玄耳机—— - // 于是 _loadVersionInfo() 永远不会被调到,固件页从头到尾**没有任何一次 - // 服务端拉取**,看起来就是「接口没通」。实际上服务端版本是随 - // user_getappconfig 的 products 下发的(product.version / - // updatepackageaddress / updatedescription / isforceupdate), - // 一直都在,只是没人去读。 - final bool jieliReady = _coreService.checkBleConnectionStatus(); - if (!jieliReady && !_besConnected) { - isBleConnected.value = false; - return; - } - isBleConnected.value = true; - // 杰理的 OTA 事件流只有杰理链路才有意义 - if (jieliReady) _listenToOtaEvents(); - await _loadVersionInfo(); - } - - /// 加载版本信息 - /// 获取当前设备版本和最新版本信息,检查是否有更新 - Future _loadVersionInfo() async { - try { - isFetchingDeviceInfo.value = true; - errorMessage.value = ''; - - // 获取当前版本信息。 - // 恒玄耳机不在杰理 RCSP 链路上,_coreService 读不到它的版本, - // 但连接时已经用 `AA 06` 查过并解析好了,这里直接取。 - if (Get.isRegistered() && - BesBluetoothService.to.isConnected && - BesBluetoothService.to.deviceVersion.value.isNotEmpty) { - currentVersion.value = BesBluetoothService.to.deviceVersion.value; - isBleConnected.value = true; - } else { - final currentInfo = await _coreService.getCurrentVersionInfo(); - currentVersion.value = currentInfo['currentVersion']; - } - - // 获取蓝牙地址 - final connectedDevices = await ClassicBluetooth.getConnectedDevices(); - if (connectedDevices.isNotEmpty) { - _address = connectedDevices.first.address; - } - - // 获取最新版本信息 - final latestInfo = await _coreService.getLatestVersionInfo(); - latestVersion.value = latestInfo['latestVersion']; - _updatePackageAddress = latestInfo['updatePackageAddress']; - - // 设置更新描述信息 - updatedescription.value = - latestInfo['updateDescription'] ?? 'otaDefaultUpdateDesc'.tr; - - // 检查是否有更新 - isUpdateAvailable.value = _coreService.isNewVersionAvailable( - currentVersion.value, - latestVersion.value, - ); - } catch (e) { - _handleError('error'.tr, _getUserFriendlyErrorMessage(e)); - } finally { - isFetchingDeviceInfo.value = false; - } - } - - /// 检查更新 - /// 显示更新确认对话框或错误提示 - Future checkUpdate() async { - try { - if (latestVersion.value.isNotEmpty) { - _uiService.showUpdateDialog(latestVersion.value, _startUpdateProcess); - } else { - _uiService.showErrorSnackbar('error'.tr, 'otaGetLatestVersionFailed'.tr, - backgroundColor: Colors.orange[800]); - } - } catch (e) { - _uiService.showErrorSnackbar('error'.tr, 'otaGetVersionInfoFailed'.tr, - backgroundColor: Colors.orange[800]); - } - } - - /// 开始更新流程的公共方法 - /// 供视图调用的更新入口点 - Future startUpdate() async { - // 恒玄耳机目前**没有固件推送通道**:bluetooth_manager 插件里没有任何 - // OTA 实现,下面这条流程整条是杰理 RCSP 的(下载 → RCSP 分包 → 重启配对)。 - // 让它跑下去只会在中途失败,用户看到的是一串莫名其妙的错误码。 - // 所以这里明确拦一下:版本对比与更新提示照常给,升级动作说清楚做不了。 - if (_besConnected && !_coreService.checkBleConnectionStatus()) { - _uiService.showErrorSnackbar( - 'tip'.tr, - 'otaBesNotSupported'.tr, - backgroundColor: Colors.orange[800], - ); - return; - } - //立即给用户状态反馈 - showDetailedStatus.value = true; - detailedStatus.value = 'otaCheckingDeviceStatus'.tr; - - await _startUpdateProcess(); - } - - /// 检查设备状态后开始下载和升级 - Future _startUpdateProcess() async { - // 检查设备电量 - if (!_coreService.checkBatteryLevel()) { - return; - } - - await _startDownload(); - } - - /// 开始下载固件 - /// 初始化OTA环境并下载固件包 - Future _startDownload() async { - try { - await _bleManager.setEnterOta(true); - await Future.delayed(const Duration(seconds: 2)); - // device_jieli 已经在全局 InitialBinding 里 initialize 过; - // 这里无需重复 init —— 直接订阅 OTA 事件流即可。 - - isDownloading.value = true; - showDetailedStatus.value = true; - type.value = OtaCoreService.DOWNLOADING_UP_FILES; - - final filePath = await _downloadService.downloadFirmware( - downloadUrl: _updatePackageAddress, - onProgress: (progress) { - downloadProgress.value = progress; - }, - onStatusUpdate: (status) { - detailedStatus.value = status; - }, - ); - - isDownloading.value = false; - await _startUpgrading(filePath); - } catch (e) { - isDownloading.value = false; - detailedStatus.value = 'otaDownloadFailedCheckNetwork'.tr; - showDetailedStatus.value = false; - _handleError('otaDownloadFailed'.tr, e.toString()); - Logger.d('OtaUpgradeController', '下载失败: $e'); - } - } - - /// 开始升级 - /// [filePath] 固件文件路径 - /// 启动OTA升级过程 - Future _startUpgrading(String filePath) async { - detailedStatus.value = 'otaPreparingConnection'.tr; - await Future.delayed(const Duration(milliseconds: 500)); - detailedStatus.value = 'otaConnectingDevice'.tr; - _jlota.otaStart(address: _address, firmwareFilePath: filePath); - } - - /// 停止升级 - /// 关闭OTA连接 - void stopUpgrading() { - _jlota.otaCancel(); - } - - /// 重试连接 - /// 清除错误信息并重新加载版本信息 - void retryConnection() { - errorMessage.value = ''; - _loadVersionInfo(); - } - - /// 获取状态描述 - /// [type] 状态类型 - /// 返回对应的状态描述文本 - String getStatusMessage(int type) { - return OtaCoreService.getStatusMessage(type); - } - - /// 监听OTA事件 - /// 订阅 device_jieli 的统一事件流,过滤 OTA 相关事件并转发到分发器。 - void _listenToOtaEvents() { - _otaEventSub?.cancel(); - _otaEventSub = _jlota.events.listen(_handleOtaEvent); - } - - /// 处理 device_jieli 抛出的 OTA / 连接事件。 - /// - [OtaStateEvent]: 进度 / 状态机推进 - /// - [OtaErrorEvent]: 升级失败 - /// - [ConnectionStateEvent]: 设备连接 / 断开(OTA 期间用于"中断"指示) - void _handleOtaEvent(JieliEvent event) { - if (event is OtaStateEvent) { - switch (event.state) { - case OtaState.entering: - case OtaState.inquiring: - case OtaState.notifyingSize: - type.value = OtaCoreService.START_UPGRADING; - isUpgrade.value = true; - detailedStatus.value = 'otaUpgradeStarted'.tr; - break; - case OtaState.transferring: - if (event.percent >= 0) { - // device_jieli 发的是 0..100 整数;UI 这边按 0..1 浮点用。 - progress.value = event.percent / 100.0; - type.value = OtaCoreService.ONUPGRADE; - isUpgrade.value = true; - detailedStatus.value = 'otaUpgradeProgressWithPercent' - .trParams({'percent': event.percent.toStringAsFixed(1)}); - } - break; - case OtaState.verifying: - detailedStatus.value = 'otaUpgradeStarted'.tr; - break; - case OtaState.rebooting: - // 设备重启等待,仍在升级中 - isUpgrade.value = true; - break; - case OtaState.done: - _handleUpgradeSuccess(); - break; - case OtaState.failed: - _handleUpgradeError(-1); - break; - case OtaState.cancelled: - isUpgrade.value = false; - showDetailedStatus.value = false; - break; - case OtaState.idle: - break; - } - return; - } - if (event is OtaErrorEvent) { - _handleUpgradeError(event.code); - return; - } - if (event is ConnectionStateEvent) { - if (event.state == ConnectionStateEvent.connectionDisconnect && - isUpgrade.value) { - _handleConnectionLost(); - } else if (event.state == ConnectionStateEvent.connectionOk && - isUpgrade.value) { - _handleConnectionSuccess(); - } - } - } - - /// 处理升级成功 - /// 更新状态并显示成功对话框 - void _handleUpgradeSuccess() { - type.value = OtaCoreService.UPGRADE_SUCCESS; - isUpgrade.value = false; - detailedStatus.value = 'otaUpgradeSuccessComplete'.tr; - showDetailedStatus.value = false; - - // 更新版本信息 - currentVersion.value = latestVersion.value; - isUpdateAvailable.value = false; - - // 显示成功对话框 - _uiService.showUpgradeSuccessDialog(); - } - - /// 处理升级错误 - /// [errorCode] 错误代码 - /// 更新状态并显示错误提示 - void _handleUpgradeError(dynamic errorCode) { - type.value = OtaCoreService.UPGRADE_FAILED; - isUpgrade.value = false; - detailedStatus.value = 'otaUpgradeFailedWithCode'.trParams({'code': errorCode.toString()}); - showDetailedStatus.value = false; - _uiService.showErrorSnackbar( - 'otaUpgradeFailedTitle'.tr, - 'otaUpgradeFailedDesc'.trParams({'code': errorCode.toString()}), - ); - } - - /// 处理连接断开 - /// 更新连接状态 - void _handleConnectionLost() { - type.value = OtaCoreService.CONNECTION_DISCONNECTION; - isUpgrade.value = false; - detailedStatus.value = 'otaDeviceDisconnected'.tr; - showDetailedStatus.value = false; - } - - /// 处理连接成功 - /// 更新连接状态 - void _handleConnectionSuccess() { - type.value = OtaCoreService.CONNECTION_SUCCESS; - isUpgrade.value = true; - detailedStatus.value = 'otaDeviceConnected'.tr; - } - - /// 统一错误处理 - /// [title] 错误标题 - /// [message] 错误信息 - /// 设置错误状态并显示错误提示 - void _handleError(String title, String message) { - errorMessage.value = message; - _uiService.showErrorSnackbar(title, message); - } - - String _getUserFriendlyErrorMessage(Object error) { - if (error is OtaCoreException) { - switch (error.type) { - case OtaCoreErrorType.deviceInfoUnavailable: - return 'otaDeviceInfoUnavailable'.tr; - case OtaCoreErrorType.productInfoUnavailable: - return 'otaProductInfoUnavailable'.tr; - case OtaCoreErrorType.firmwareVersionFormatInvalid: - return 'otaFirmwareVersionInvalid'.tr; - } - } - return 'otaVersionInfoFailed'.tr; - } -} diff --git a/apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart b/apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart similarity index 100% rename from apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart rename to apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart diff --git a/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart b/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart deleted file mode 100644 index 572dcc74..00000000 --- a/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart +++ /dev/null @@ -1,1132 +0,0 @@ -import 'package:flutter/material.dart'; -import '/data/services/bes_bluetooth_service.dart'; -import '/core/utils/device_connection.dart'; -import 'package:get/get.dart'; -import '../controllers/ota_upgrade_controller.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '../../../data/services/ble_manager.dart'; - -class OtaUpgradeView extends GetView { - const OtaUpgradeView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return WillPopScope( - onWillPop: () async { - if (controller.isUpgrade.value && controller.showDetailedStatus.value) { - // 显示确认弹窗 - await Get.dialog( - AlertDialog( - title: Text('tip'.tr), // 提示 - content: - Text('upgradeExitConfirm'.tr), // 当前正在升级,退出将中断升级进程,确定要退出吗? - actions: [ - TextButton( - onPressed: () => Get.back(), // 关闭弹窗 - child: Text('cancel'.tr), // 取消 - ), - TextButton( - onPressed: () { - controller.stopUpgrading(); - Get.back(); // 关闭弹窗 - Get.back(); // 返回上一页 - }, - child: Text('confirm'.tr), // 确定 - ), - ], - ), - ); - return false; // 阻止默认返回 - } - return true; // 允许返回 - }, - child: Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.grey[100], - appBar: AppBar( - automaticallyImplyLeading: false, - centerTitle: true, - elevation: 0, - backgroundColor: isDarkMode ? Colors.black : Colors.white, - leading: IconButton( - icon: Icon( - Icons.arrow_back_ios, - size: 20.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - onPressed: () { - if (controller.isUpgrade.value && - controller.showDetailedStatus.value) { - // 显示确认弹窗 - Get.dialog( - AlertDialog( - title: Text('tip'.tr), // 提示 - content: Text( - 'upgradeExitConfirm'.tr), // 当前正在升级,退出将中断升级进程,确定要退出吗? - actions: [ - TextButton( - onPressed: () => Get.back(), // 关闭弹窗 - child: Text('cancel'.tr), // 取消 - ), - TextButton( - onPressed: () { - controller.stopUpgrading(); - Get.back(); // 关闭弹窗 - Get.back(); // 返回上一页 - }, - child: Text('confirm'.tr), // 确定 - ), - ], - ), - ); - } else { - Get.back(); // 正常返回 - } - }, - ), - title: Text( - 'firmwareUpgrade'.tr, //固件升级 - style: TextStyle( - fontSize: 20.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - body: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 设备信息卡片 - 优化后的版本 - _buildDeviceCard(isDarkMode), - SizedBox(height: 16.h), - - // 更新说明 - 当有可用更新时显示 - //_buildUpdateNotes(isDarkMode), - - // 状态显示区域 - Obx(() { - if (controller.isFetchingDeviceInfo.value) { - return _buildLoadingIndicator(isDarkMode); - } - if (controller.errorMessage.value.isNotEmpty) { - return _buildErrorInfo(isDarkMode); - } - return _buildUpdateButton(isDarkMode); - }), - ], - ), - ), - ), - ), - ); - } - - // 设备信息卡片 - Widget _buildDeviceCard(bool isDarkMode) { - return Container( - width: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [ - const Color(0xFF1A1A1A).withValues(alpha: 0.9), - const Color(0xFF2A2A2A).withValues(alpha: 0.9), - ] - : [ - const Color(0xFFF8FAFF), - const Color(0xFFE3F2FD), - ], - ), - borderRadius: BorderRadius.circular(20.r), - boxShadow: [ - BoxShadow( - color: isDarkMode - ? Colors.black.withValues(alpha: 0.3) - : Colors.black.withValues(alpha: 0.08), - blurRadius: 20, - offset: const Offset(0, 8), - spreadRadius: 0, - ), - ], - ), - child: Padding( - padding: EdgeInsets.all(20.w), - child: Column( - children: [ - // 设备图标区域 - _buildDeviceIcon(isDarkMode), - SizedBox(height: 16.h), - // 设备详细信息区域 - _buildDeviceDetailInfo(isDarkMode), - SizedBox(height: 16.h), - // 版本信息区域 - _buildVersionInfo(isDarkMode), - ], - ), - ), - ); - } - - // 设备图标 - Widget _buildDeviceIcon(bool isDarkMode) { - return Container( - padding: EdgeInsets.all(6.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.05) - : Colors.white.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(16.r), - border: Border.all( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.1) - : Colors.blue.withValues(alpha: 0.1), - width: 1, - ), - ), - child: GetBuilder( - builder: (controller) { - // 一律按设备名读本地产品小图(<设备名>_icon.png,规则见 DeviceImages)。 - // - // ⚠️ 这里原来会先试服务端下发的 productimage 网络图,已去掉: - // 产品图统一走本地资源(2026-09-08 定的),保留网络图会出现 - // 「同一台设备在设备页显示本地图、在这里显示另一张网络图」。 - // 顺带也省掉了加载中转圈和破图兜底那一堆分支。 - return ClipRRect( - borderRadius: BorderRadius.circular(12.r), - child: SizedBox( - width: 120.h, - height: 120.h, - child: Image.asset( - DeviceConnection.currentIconImage(), - // 产品图有留白,用 contain 等比缩放,cover 会把机身裁掉 - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => Icon( - Icons.headphones, - size: 48.h, - color: isDarkMode ? Colors.white54 : Colors.blue, - ), - ), - ), - ); - }, - ), - ); - } - - // 设备详细信息显示 - Widget _buildDeviceDetailInfo(bool isDarkMode) { - return Obx(() { - final deviceInfo = Get.find().deviceInfo.value; - // 恒玄耳机走的是另一条链路,杰理的 deviceInfo 里没有它 - final besName = Get.isRegistered() && - BesBluetoothService.to.isConnected - ? BesBluetoothService.to.connectedDeviceName.value - : ''; - - return Container( - padding: EdgeInsets.all(14.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.03) - : Colors.white.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(12.r), - border: Border.all( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.08) - : Colors.grey.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Column( - children: [ - // 设备型号信息 - _buildDeviceInfoRow( - label: 'deviceModel'.tr, // 设备型号 - value: besName.isNotEmpty - ? besName - : (deviceInfo?.name ?? 'unknownDevice'.tr), - isDarkMode: isDarkMode, - ), - // SizedBox(height: 10.h), - // // 电量信息区域 - // Row( - // children: [ - // // 左耳电量 - // Expanded( - // child: _buildBatteryInfo( - // label: '左耳电量', - // batteryLevel: deviceInfo?.leftEarBattery ?? 0, - // isCharging: deviceInfo?.leftEarCharging ?? false, - // isDarkMode: isDarkMode, - // icon: Icons.headset, - // ), - // ), - // SizedBox(width: 10.w), - // // 右耳电量 - // Expanded( - // child: _buildBatteryInfo( - // label: '右耳电量', - // batteryLevel: deviceInfo?.rightEarBattery ?? 0, - // isCharging: deviceInfo?.rightEarCharging ?? false, - // isDarkMode: isDarkMode, - // icon: Icons.headset, - // ), - // ), - // ], - // ), - ], - ), - ); - }); - } - - // 设备信息行组件 - // - // 行首图标用 App 自己的图标(原来是蓝色 Icons.headset glyph 套一层浅蓝底)。 - // app_icon.png 自带深色背景,再套浅蓝底会脏,所以底色一并去掉, - // 直接按原来的整体尺寸(6.w 内边距 + 16.sp 字形 ≈ 28.w)出图。 - Widget _buildDeviceInfoRow({ - required String label, - required String value, - required bool isDarkMode, - }) { - return Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6.r), - child: Image.asset( - 'assets/images/app_icon.png', - width: 28.w, - height: 28.w, - fit: BoxFit.cover, - ), - ), - SizedBox(width: 12.w), - Text( - label, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.w500, - ), - ), - const Spacer(), - Text( - value, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black87, - ), - ), - ], - ); - } - - // 电池信息组件 - Widget _buildBatteryInfo({ - required String label, - required int batteryLevel, - required bool isCharging, - required bool isDarkMode, - required IconData icon, - }) { - Color batteryColor; - if (batteryLevel > 60) { - batteryColor = Colors.green; - } else if (batteryLevel > 30) { - batteryColor = Colors.orange; - } else { - batteryColor = Colors.red; - } - - return Container( - padding: EdgeInsets.all(10.w), - decoration: BoxDecoration( - color: isDarkMode - ? batteryColor.withValues(alpha: 0.1) - : batteryColor.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8.r), - border: Border.all( - color: batteryColor.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - isCharging ? Icons.battery_charging_full : Icons.battery_std, - size: 16.sp, - color: batteryColor, - ), - SizedBox(width: 4.w), - Text( - label, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.w500, - ), - ), - ], - ), - SizedBox(height: 4.h), - Text( - '${batteryLevel}%', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: batteryColor, - ), - ), - if (isCharging) ...[ - SizedBox(height: 1.h), - Text( - 'charging'.tr, // 充电中 - style: TextStyle( - fontSize: 10.sp, - color: batteryColor, - fontWeight: FontWeight.w500, - ), - ), - ], - ], - ), - ); - } - - // 版本信息显示 - Widget _buildVersionInfo(bool isDarkMode) { - return Column( - children: [ - // 当前版本信息 - _buildVersionInfoRow( - label: 'currentFirmwareVersion'.tr, //当前固件版本 - version: controller.currentVersion, - isDarkMode: isDarkMode, - isLatest: false, - ), - SizedBox(height: 8.h), - // 最新版本信息 - _buildVersionInfoRow( - label: 'latestFirmwareVersion'.tr, //最新固件版本 - version: controller.latestVersion, - isDarkMode: isDarkMode, - isLatest: true, - ), - // 版本状态指示器 - SizedBox(height: 14.h), - if (controller.isBleConnected.value) - _buildVersionStatusIndicator(isDarkMode), - ], - ); - } - - // 版本信息行组件 - Widget _buildVersionInfoRow({ - required String label, - required RxString version, - required bool isDarkMode, - required bool isLatest, - }) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.03) - : Colors.white.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(12.r), - border: Border.all( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.08) - : Colors.grey.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - label, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.w500, - ), - ), - Obx(() => Row( - children: [ - if (isLatest && controller.isUpdateAvailable.value) ...[ - Container( - padding: - EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8.r), - ), - child: Text( - 'NEW', - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.bold, - color: Colors.orange, - ), - ), - ), - SizedBox(width: 8.w), - ], - Text( - 'V ${version.value}', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode - ? (isLatest ? Colors.green[300] : Colors.blue[300]) - : (isLatest ? Colors.green[600] : Colors.blue[600]), - ), - ), - ], - )), - ], - ), - ); - } - - // 版本状态指示器 - Widget _buildVersionStatusIndicator(bool isDarkMode) { - return Obx(() { - if (controller.isUpdateAvailable.value) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.orange.withValues(alpha: 0.1), - Colors.red.withValues(alpha: 0.1), - ], - ), - borderRadius: BorderRadius.circular(20.r), - border: Border.all( - color: Colors.orange.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.update, - size: 16.sp, - color: Colors.orange, - ), - SizedBox(width: 6.w), - Text( - 'ota_newVersionFound'.tr, // 发现新版本 - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w500, - color: Colors.orange, - ), - ), - ], - ), - ); - } else { - return Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20.r), - border: Border.all( - color: Colors.green.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.check_circle, - size: 16.sp, - color: Colors.green, - ), - SizedBox(width: 6.w), - Text( - 'latestVersion'.tr, // 已是最新版本 - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w500, - color: Colors.green, - ), - ), - ], - ), - ); - } - }); - } - - // 固件更新说明 - Widget _buildUpdateNotes(bool isDarkMode) { - return Obx(() => controller.isUpdateAvailable.value && - controller.updatedescription.value.isNotEmpty - ? Container( - width: double.infinity, - margin: EdgeInsets.only(bottom: 8.h), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [ - Colors.amber[900]!.withValues(alpha: 0.1), - Colors.orange[900]!.withValues(alpha: 0.05), - ] - : [ - Colors.amber[50]!, - Colors.orange[100]!.withValues(alpha: 0.3), - ], - ), - borderRadius: BorderRadius.circular(16.r), - border: Border.all( - color: isDarkMode - ? Colors.amber[700]!.withValues(alpha: 0.3) - : Colors.amber[200]!.withValues(alpha: 0.5), - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.all(20.w), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: EdgeInsets.all(8.w), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8.r), - ), - child: Icon( - Icons.new_releases, - color: isDarkMode - ? Colors.amber[300] - : Colors.amber[700], - size: 18.sp, - ), - ), - SizedBox(width: 12.w), - Text( - 'updateContent'.tr, //更新内容 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode - ? Colors.amber[200] - : Colors.amber[800], - ), - ), - ], - ), - SizedBox(height: 16.h), - Container( - width: double.infinity, - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.03) - : Colors.white.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(12.r), - ), - child: Text( - controller.updatedescription.value, - style: TextStyle( - fontSize: 14.sp, - height: 1.5, - color: isDarkMode ? Colors.grey[300] : Colors.grey[700], - ), - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink()); - } - - // 加载指示器 - Widget _buildLoadingIndicator(bool isDarkMode) { - return Container( - padding: EdgeInsets.all(32.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.03) - : Colors.white.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(20.r), - border: Border.all( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.08) - : Colors.grey.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Column( - children: [ - // 自定义加载动画 - SizedBox( - width: 50.w, - height: 50.w, - child: CircularProgressIndicator( - strokeWidth: 3, - valueColor: AlwaysStoppedAnimation( - isDarkMode ? const Color(0xFF0066CC) : Colors.blue, - ), - backgroundColor: isDarkMode - ? Colors.white.withValues(alpha: 0.1) - : Colors.grey.withValues(alpha: 0.2), - ), - ), - SizedBox(height: 24.h), - Text( - 'gettingDeviceInfo'.tr, // 正在获取设备信息... - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.grey[300] : Colors.grey[700], - ), - ), - SizedBox(height: 8.h), - Text( - 'keepDeviceConnected'.tr, // 请保持设备连接 - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[500] : Colors.grey[500], - ), - ), - ], - ), - ); - } - - // 错误信息显示 - Widget _buildErrorInfo(bool isDarkMode) { - return Container( - padding: EdgeInsets.all(24.w), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [ - Colors.red[900]!.withValues(alpha: 0.15), - Colors.red[800]!.withValues(alpha: 0.1), - ] - : [ - Colors.red[50]!, - Colors.red[100]!.withValues(alpha: 0.3), - ], - ), - borderRadius: BorderRadius.circular(20.r), - border: Border.all( - color: isDarkMode - ? Colors.red[700]!.withValues(alpha: 0.3) - : Colors.red[200]!.withValues(alpha: 0.5), - width: 1, - ), - ), - child: Column( - children: [ - // 错误图标 - Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(50.r), - ), - child: Icon( - Icons.error_outline, - color: Colors.red, - size: 32.sp, - ), - ), - SizedBox(height: 16.h), - // 错误标题 - Text( - 'connectionFailed'.tr, // 连接失败 - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.red[300] : Colors.red[700], - ), - ), - SizedBox(height: 8.h), - // 错误信息 - Text( - controller.errorMessage.value, - textAlign: TextAlign.center, - style: TextStyle( - color: isDarkMode ? Colors.red[200] : Colors.red[600], - fontSize: 14.sp, - height: 1.4, - ), - ), - SizedBox(height: 24.h), - // 重试按钮 - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - icon: Icon(Icons.refresh, size: 18.sp), - label: Text( - 'retryConnection'.tr, //重试连接 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: - isDarkMode ? const Color(0xFF0066CC) : Colors.blue, - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric(vertical: 14.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - elevation: 0, - ), - onPressed: controller.retryConnection, - ), - ), - ], - ), - ); - } - - // 更新按钮 - Widget _buildUpdateButton(bool isDarkMode) { - return Obx(() { - if (controller.isUpgrade.value && controller.showDetailedStatus.value) { - return _buildProgressIndicator(isDarkMode); - } - - // 显示详细状态提示 - if (controller.showDetailedStatus.value) { - return _buildDetailedStatusIndicator(isDarkMode); - } - - return SizedBox( - width: double.infinity, - child: Column( - children: [ - // 主要更新按钮 - Container( - width: double.infinity, - height: 56.h, - decoration: BoxDecoration( - gradient: controller.isUpdateAvailable.value - ? LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: isDarkMode - ? [const Color(0xFF0066CC), const Color(0xFF0052A3)] - : [Colors.blue, Colors.blue[700]!], - ) - : null, - color: !controller.isUpdateAvailable.value - ? (isDarkMode ? Colors.grey[800] : Colors.grey[300]) - : null, - borderRadius: BorderRadius.circular(16.r), - boxShadow: controller.isUpdateAvailable.value - ? [ - BoxShadow( - color: Colors.blue.withValues(alpha: 0.3), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ] - : null, - ), - child: ElevatedButton.icon( - icon: Icon( - controller.isUpdateAvailable.value - ? Icons.system_update - : Icons.check_circle, - size: 20.sp, - ), - label: Text( - controller.isUpdateAvailable.value - ? 'ota_updateNow'.tr - : (controller.isBleConnected.value || - DeviceConnection.besConnected) - ? 'latestVersion'.tr // 立即更新 : 已是最新版本 - : 'unconnected'.tr, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - shadowColor: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - ), - onPressed: controller.isUpdateAvailable.value - ? controller.startUpdate - : null, - ), - ), - - // 更新提示信息 - if (controller.isUpdateAvailable.value) ...[ - SizedBox(height: 16.h), - Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.orange.withValues(alpha: 0.1) - : Colors.orange[50], - borderRadius: BorderRadius.circular(12.r), - border: Border.all( - color: Colors.orange.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - children: [ - Icon( - Icons.info_outline, - color: Colors.orange, - size: 20.sp, - ), - SizedBox(width: 12.w), - Expanded( - child: Text( - 'removeEarbudsEnsureBattery'.tr, //取出双耳机,并保证电量均为20%以上的状态 - style: TextStyle( - fontSize: 13.sp, - color: isDarkMode - ? Colors.orange[200] - : Colors.orange[800], - height: 1.3, - ), - ), - ), - ], - ), - ), - ], - ], - ), - ); - }); - } - - // 详细状态指示器 - Widget _buildDetailedStatusIndicator(bool isDarkMode) { - return Obx(() { - return Container( - width: double.infinity, - padding: EdgeInsets.all(24.w), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [ - const Color(0xFF1A1A1A).withValues(alpha: 0.9), - const Color(0xFF2A2A2A).withValues(alpha: 0.9), - ] - : [ - const Color(0xFFF8FAFF), - const Color(0xFFE3F2FD), - ], - ), - borderRadius: BorderRadius.circular(20.r), - boxShadow: [ - BoxShadow( - color: isDarkMode - ? Colors.black.withValues(alpha: 0.3) - : Colors.black.withValues(alpha: 0.08), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - children: [ - // 状态图标和文字 - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: EdgeInsets.all(8.w), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8.r), - ), - child: SizedBox( - width: 20.sp, - height: 20.sp, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - isDarkMode ? const Color(0xFF0066CC) : Colors.blue, - ), - ), - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Text( - controller.detailedStatus.value, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.blue[300] : Colors.blue[700], - ), - ), - ), - ], - ), - SizedBox(height: 16.h), - - // 提示信息 - Text( - 'keepConnectedDontClose'.tr, // 请保持设备连接,不要关闭应用 - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - ], - ), - ); - }); - } - - // 进度指示器 - Widget _buildProgressIndicator(bool isDarkMode) { - return Obx(() { - final progress = controller.progress.value; - final type = controller.type.value; - final statusMessage = controller.getStatusMessage(type); - - return Container( - width: double.infinity, - padding: EdgeInsets.all(24.w), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? [ - const Color(0xFF1A1A1A).withValues(alpha: 0.9), - const Color(0xFF2A2A2A).withValues(alpha: 0.9), - ] - : [ - const Color(0xFFF8FAFF), - const Color(0xFFE3F2FD), - ], - ), - borderRadius: BorderRadius.circular(20.r), - boxShadow: [ - BoxShadow( - color: isDarkMode - ? Colors.black.withValues(alpha: 0.3) - : Colors.black.withValues(alpha: 0.08), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - children: [ - // 状态图标和文字 - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: EdgeInsets.all(8.w), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8.r), - ), - child: Icon( - Icons.system_update, - color: isDarkMode ? const Color(0xFF0066CC) : Colors.blue, - size: 20.sp, - ), - ), - SizedBox(width: 12.w), - Text( - statusMessage, - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.blue[300] : Colors.blue[700], - ), - ), - ], - ), - SizedBox(height: 24.h), - - // 进度条容器 - Container( - height: 8.h, - decoration: BoxDecoration( - color: isDarkMode - ? Colors.white.withValues(alpha: 0.1) - : Colors.grey[200], - borderRadius: BorderRadius.circular(4.r), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(4.r), - child: LinearProgressIndicator( - value: progress / 100.0, - backgroundColor: Colors.transparent, - valueColor: AlwaysStoppedAnimation( - isDarkMode ? const Color(0xFF0066CC) : Colors.blue, - ), - ), - ), - ), - SizedBox(height: 16.h), - - // 进度百分比和详细信息 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${progress.toStringAsFixed(1)}%', - style: TextStyle( - fontSize: 24.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.blue[300] : Colors.blue[700], - ), - ), - Text( - 'doNotDisconnect'.tr, // 请勿断开连接 - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - ], - ), - ], - ), - ); - }); - } -} diff --git a/apps/client/lib/modules/qqmusic/bindings/qqmusic_playlist_binding.dart b/apps/client/lib/modules/qqmusic/bindings/qqmusic_playlist_binding.dart deleted file mode 100644 index 04c53d81..00000000 --- a/apps/client/lib/modules/qqmusic/bindings/qqmusic_playlist_binding.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/qqmusic_playlist_controller.dart'; - -class QQMusicPlaylistBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => QQMusicPlaylistController()); - } -} diff --git a/apps/client/lib/modules/qqmusic/controllers/qqmusic_playlist_controller.dart b/apps/client/lib/modules/qqmusic/controllers/qqmusic_playlist_controller.dart deleted file mode 100644 index 7db886a1..00000000 --- a/apps/client/lib/modules/qqmusic/controllers/qqmusic_playlist_controller.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; -import 'package:get/get.dart'; -import '../../../data/models/song_model.dart'; -import '../../../data/services/qq_music.dart'; -import '../../../core/utils/logger.dart'; - -class QQMusicPlaylistController extends GetxController { - final QqMusicService _qqMusicService = QqMusicService.to; - - // 登录相关 - final isLoggedIn = false.obs; - final userInfo = Rxn>(); - - // 界面维护的播放列表状态 - RxList playlist = [].obs; - RxInt currentIndex = 0.obs; - - // 界面显示的当前歌曲(可能与实际播放的不同) - Rxn displayCurrentSong = Rxn(); - - // 界面播放状态(当前播放歌曲不在列表中时为未播放状态) - RxBool displayIsPlaying = false.obs; - - // 从全局服务获取实际播放状态(仅用于同步) - RxBool get actualIsPlaying => _qqMusicService.isPlaying; - RxInt get currentPosition => _qqMusicService.currentPosition; - RxInt get totalDuration => _qqMusicService.totalDuration; - Rxn get actualCurrentSong => _qqMusicService.currentSong; - - @override - void onInit() { - super.onInit(); - _initializePlaylist(); - print('🎵 [QQMusicPlaylistController] 控制器初始化完成'); - } - - /// 初始化播放列表 - void _initializePlaylist() { - // 从路由参数获取播放列表 - final arguments = Get.arguments; - if (arguments != null && arguments['playlist'] != null) { - final List songList = arguments['playlist']; - // 更新播放列表 - playlist.assignAll(songList); - // 检查当前播放歌曲是否在列表中 - _syncDisplayStateWithActualPlayback(); - } else { - Logger.w('QQMusicPlaylist', '未接收到播放列表参数'); - _setDefaultDisplayState(); - } - // 界面初始化时同步播放状态和进度 - _qqMusicService.syncCurrentPlayInfo(); - } - - /// 同步显示状态与实际播放状态 - void _syncDisplayStateWithActualPlayback() { - if (playlist.isEmpty) { - _setDefaultDisplayState(); - return; - } - - final actualSong = actualCurrentSong.value; - - if (actualSong != null) { - // 查找当前播放歌曲在列表中的位置 - final index = playlist.indexWhere((song) => song.mid == actualSong.mid); - - if (index != -1) { - // 当前播放歌曲在列表中 - currentIndex.value = index; - displayCurrentSong.value = playlist[index]; - displayIsPlaying.value = actualIsPlaying.value; - Logger.d('QQMusicPlaylist', '当前播放歌曲在列表中,索引: $index'); - } else { - // 当前播放歌曲不在列表中,显示列表第一个,状态为未播放 - _setDefaultDisplayState(); - Logger.d('QQMusicPlaylist', '当前播放歌曲不在列表中,显示默认状态'); - } - } else { - // 没有当前播放歌曲,显示列表第一个,状态为未播放 - _setDefaultDisplayState(); - Logger.d('QQMusicPlaylist', '无当前播放歌曲,显示默认状态'); - } - } - - /// 设置默认显示状态 - void _setDefaultDisplayState() { - if (playlist.isNotEmpty) { - currentIndex.value = 0; - displayCurrentSong.value = playlist.first; - displayIsPlaying.value = false; // 默认为未播放状态 - Logger.d('QQMusicPlaylist', '设置默认显示状态: ${playlist.first.name}'); - } else { - currentIndex.value = 0; - displayCurrentSong.value = null; - displayIsPlaying.value = false; - Logger.w('QQMusicPlaylist', '播放列表为空,无法设置默认状态'); - } - } - - /// 音乐状态变化回调 - void _onMusicStateChanged() { - SchedulerBinding.instance.addPostFrameCallback((_) { - if (!isClosed) { - // 重新同步显示状态 - _syncDisplayStateWithActualPlayback(); - update(); - } - }); - } - - @override - void onClose() { - // 移除状态监听器 - super.onClose(); - } - - /// 播放歌曲 - Future playSong(SongModel song, {List? songList}) async { - try { - displayIsPlaying.value = true; - // 使用传入的歌曲列表或当前播放列表 - final List finalSongList = songList ?? playlist.toList(); - final songMids = finalSongList.map((s) => s.mid).toList(); - final playIndex = finalSongList.indexOf(song); - - // 歌曲在当前列表中,更新界面状态 - currentIndex.value = playIndex; - displayCurrentSong.value = song; - final result = await _qqMusicService.playSongs( - songMids: songMids, - index: playIndex, - songList: finalSongList, - currentSongModel: song, - ); - - if (result['success'] == true) { - displayIsPlaying.value = true; - // _qqMusicService.syncCurrentPlayInfo(); - // Get.snackbar('开始播放', song.name); - } else { - displayIsPlaying.value = false; - // Get.snackbar('播放失败', result['error'] ?? '未知错误'); - } - } catch (e) { - displayIsPlaying.value = false; - // Get.snackbar('播放失败', e.toString()); - } - } - - /// 播放列表中的指定歌曲 - Future playPlaylistSong(int index) async { - if (index >= 0 && index < playlist.length) { - await playSong(playlist[index]); - } - } - - /// 暂停/继续播放 - Future togglePlayPause() async { - // 检查当前显示的歌曲是否正在播放 - final displaySong = displayCurrentSong.value; - final actualSong = actualCurrentSong.value; - - if (displaySong != null && - actualSong != null && - displaySong.mid == actualSong.mid) { - // 显示的歌曲正在播放,执行暂停/继续 - if (actualIsPlaying.value) { - await _qqMusicService.pauseMusic(); - displayIsPlaying.value = false; - } else { - await _qqMusicService.resumeMusic(); - displayIsPlaying.value = true; - } - } else { - // 显示的歌曲不是当前播放的,播放显示的歌曲 - if (displaySong != null) { - await playSong(displaySong); - } - } - } - - /// 下一首(列表内) - Future nextSong() async { - if (playlist.isNotEmpty) { - final nextIndex = (currentIndex.value + 1) % playlist.length; - await playPlaylistSong(nextIndex); - } - } - - /// 上一首(列表内) - Future previousSong() async { - if (playlist.isNotEmpty) { - final prevIndex = - currentIndex.value > 0 ? currentIndex.value - 1 : playlist.length - 1; - await playPlaylistSong(prevIndex); - } - } - - /// 停止播放 - Future stopMusic() async { - await _qqMusicService.stopMusic(); - displayIsPlaying.value = false; - } - - /// 格式化时间 - String formatDuration(int milliseconds) { - return _qqMusicService.formatDuration(milliseconds); - } - - /// 获取当前显示的播放状态(用于UI显示) - bool get isCurrentSongPlaying { - final displaySong = displayCurrentSong.value; - final actualSong = actualCurrentSong.value; - - // 只有当显示的歌曲与实际播放的歌曲相同时,才显示播放状态 - if (displaySong != null && - actualSong != null && - displaySong.mid == actualSong.mid) { - return actualIsPlaying.value; - } - return false; - } - - /// 检查歌曲是否在当前播放列表中 - bool isSongInPlaylist(SongModel song) { - return playlist.any((s) => s.mid == song.mid); - } - - /// 更新播放列表(支持多个播放列表场景) - void updatePlaylist(List newPlaylist) { - playlist.assignAll(newPlaylist); - _syncDisplayStateWithActualPlayback(); - Logger.d('QQMusicPlaylist', '播放列表已更新,歌曲数量: ${newPlaylist.length}'); - } -} diff --git a/apps/client/lib/modules/qqmusic/views/qqmusic_playlist_view.dart b/apps/client/lib/modules/qqmusic/views/qqmusic_playlist_view.dart deleted file mode 100644 index 799dac92..00000000 --- a/apps/client/lib/modules/qqmusic/views/qqmusic_playlist_view.dart +++ /dev/null @@ -1,651 +0,0 @@ -import 'package:get/get.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '/modules/agent/widgets/circular_progress_button.dart'; -import '/modules/qqmusic/controllers/qqmusic_playlist_controller.dart'; -import '/data/models/song_model.dart'; // Use the data model instead -import '../../../core/utils/logger.dart'; - -class QqmusicPlaylistView extends GetView { - const QqmusicPlaylistView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - ScreenUtil.init(context, designSize: const Size(375, 812)); - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, size: 20), - onPressed: () => Navigator.pop(context), - ), - backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, - foregroundColor: isDarkMode ? Colors.white : Colors.black, - elevation: 0, - ), - backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - body: controller.playlist.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.music_off, - size: 64.w, - color: Colors.grey[400], - ), - SizedBox(height: 16.h), - Text( - 'noMusic'.tr, - style: TextStyle( - fontSize: 16.sp, - color: Colors.grey[600], - ), - ), - ], - ), - ) - : Column( - children: [ - // 上部:当前播放音乐详情 - Container( - height: MediaQuery.of(context).size.height * 0.38, - child: _buildCurrentPlayingSection(isDarkMode), - ), - // 中部:播放全部按钮 - _buildPlayAllSection(isDarkMode), - // 下部:歌单列表 - Expanded( - child: _buildPlaylistSection(isDarkMode), - ), - ], - ), - // 添加迷你播放器 - // bottomNavigationBar: _buildMiniPlayer(isDarkMode), - ); - } - - // 构建当前播放音乐详情部分 - Widget _buildCurrentPlayingSection(bool isDarkMode) { - return SingleChildScrollView( - child: Obx(() { - // 使用 displayCurrentSong 而不是 actualCurrentSong - final displayCurrentSong = controller.displayCurrentSong.value; - if (displayCurrentSong == null) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.music_note, - size: 110.w, - color: Colors.grey[400], - ), - SizedBox(height: 12.h), - Text( - 'noMusicPlaying'.tr, - style: TextStyle( - fontSize: 16.sp, - color: Colors.grey[600], - ), - ), - ], - ), - ); - } - - return Container( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 封面图片 - 使用 displayCurrentSong - _buildCoverImage(displayCurrentSong.albumPic ?? '', isDarkMode, - size: 110.w), - SizedBox(height: 12.h), - // 歌曲信息 - 使用 displayCurrentSong - _buildSongInfo(displayCurrentSong.name, displayCurrentSong.artist, - isDarkMode), - SizedBox(height: 16.h), - // 进度条 - _buildProgressBar(isDarkMode), - SizedBox(height: 15.h), - // 播放控制按钮 - _buildPlayControls(isDarkMode), - ], - ), - ); - }), - ); - } - - // 构建播放全部按钮部分 - Widget _buildPlayAllSection(bool isDarkMode) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), - child: SizedBox( - width: double.infinity, - height: 48.h, - child: ElevatedButton.icon( - onPressed: () async { - if (controller.playlist.isNotEmpty) {} - }, - icon: Icon(Icons.play_arrow, color: Colors.white, size: 20.w), - label: Text( - 'playAll'.tr, // 播放全部 - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(Get.context!).primaryColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14.r), - ), - elevation: 3, - shadowColor: Theme.of(Get.context!).primaryColor.withAlpha(102), - ), - ), - ), - ); - } - - // 构建歌单列表部分 - Widget _buildPlaylistSection(bool isDarkMode) { - return GetBuilder( - builder: (controller) { - return ListView.builder( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h), - itemCount: controller.playlist.length, - itemBuilder: (context, index) { - final song = controller.playlist[index]; - return _buildMusicItem( - context, controller.playlist, song, index, isDarkMode); - }, - ); - }, - ); - } - - Widget _buildMusicItem( - BuildContext context, - List songs, - SongModel song, - int index, - bool isDarkMode, - ) { - return GestureDetector( - onTap: () async { - await controller.playSong(song, songList: songs); - }, - child: Container( - margin: EdgeInsets.only(bottom: 10.h), - padding: EdgeInsets.all(14.w), - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.white, - borderRadius: BorderRadius.circular(14.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.25 : 0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - // 封面图片 - Container( - width: 52.w, - height: 52.w, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10.r), - color: isDarkMode ? Colors.grey[700] : Colors.grey[200], - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.08), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10.r), - child: Image.network( - song.albumPic ?? '', - width: 52.w, - height: 52.w, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Icon( - Icons.music_note, - color: isDarkMode - ? Colors.grey[500] - : Colors.grey[600], - size: 24.w, - ); - }, - ), - ), - // 右下角音乐图标 - Positioned( - right: 0.w, - bottom: 0.w, - child: Container( - width: 14.w, - height: 14.w, - child: Image.asset( - 'assets/images/qqmusic.png', - fit: BoxFit.contain, - ), - ), - ), - ], - )), - SizedBox(width: 14.w), - // 歌曲信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - song.name, - style: TextStyle( - fontSize: 15.5.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black87, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 3.h), - Text( - song.artist, - style: TextStyle( - fontSize: 13.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - height: 1.1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - SizedBox(width: 14.w), - // 播放按钮 - Obx(() { - // 使用 actualCurrentSong 的 mid 来判断是否是当前播放的歌曲 - final isThactualIsPlaying = controller.actualIsPlaying.value && - controller.actualCurrentSong.value?.mid == song.mid; - - final currentProgress = isThactualIsPlaying - ? controller.totalDuration.value > 0 - ? controller.currentPosition.value / - controller.totalDuration.value - : 0.0 - : 0.0; - return CircularProgressButton( - isPlaying: isThactualIsPlaying, - progress: currentProgress, - primaryColor: Theme.of(context).primaryColor, - backgroundColor: Colors.grey, - size: 40.w, - strokeWidth: 2.5, - onPlayPause: () async { - try { - Logger.d('MusicPlaylistView', - '播放按钮被点击,当前播放状态: ${controller.actualIsPlaying.value}'); - Logger.d('MusicPlaylistView', - '当前歌曲ID: ${controller.actualCurrentSong.value?.mid}, 点击歌曲ID: ${song.mid}'); - if (controller.actualIsPlaying.value) { - if (controller.actualCurrentSong.value?.mid == song.mid) { - Logger.d('MusicPlaylistView', '暂停当前播放的歌曲'); - await controller.togglePlayPause(); - } else { - Logger.d('MusicPlaylistView', '播放新歌曲'); - await controller.playSong(song, songList: songs); - } - } else { - Logger.d('MusicPlaylistView', '开始播放歌曲'); - await controller.playSong(song, songList: songs); - } - } catch (e) { - Logger.e('MusicPlaylistView', '播放音乐时发生错误: $e'); - } - }, - onSeek: isThactualIsPlaying ? (seekProgress) {} : null, - ); - }), - ], - ), - ), - ); - } - - // 构建封面图片 - Widget _buildCoverImage(String coverUrl, bool isDarkMode, - {required double size}) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1), - blurRadius: 15, - spreadRadius: 2, - offset: const Offset(0, 6), - ), - ], - ), - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(16.r), - child: coverUrl.isNotEmpty - ? Image.network( - coverUrl, - fit: BoxFit.cover, - width: size, - height: size, - errorBuilder: (context, error, stackTrace) { - return _buildDefaultCover(isDarkMode, size: size); - }, - ) - : _buildDefaultCover(isDarkMode, size: size), - ), - // 右下角音乐图标 - Positioned( - right: 0.w, - bottom: 0.w, - child: Container( - width: size * 0.3, - height: size * 0.3, - child: ClipRRect( - borderRadius: BorderRadius.circular(4.r), - child: Image.asset( - 'assets/images/qqmusic.png', - fit: BoxFit.contain, - ), - ), - ), - ), - ], - ), - ); - } - - // 构建默认封面 - Widget _buildDefaultCover(bool isDarkMode, {required double size}) { - return Container( - color: isDarkMode ? Colors.grey[800] : Colors.grey[300], - child: Icon( - Icons.music_note, - size: size * 0.55, - color: isDarkMode ? Colors.grey[600] : Colors.grey[500], - ), - ); - } - - // 构建歌曲信息 - Widget _buildSongInfo(String title, String artist, bool isDarkMode) { - return Column( - children: [ - Text( - title, - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black87, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 6.h), - Text( - artist, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } - - // 构建进度条 - 参考qqmusic_test的简洁设计 - Widget _buildProgressBar(bool isDarkMode) { - return Obx(() { - // 计算进度值,确保在0.0-1.0范围内 - double progressValue = 0.0; - if (controller.totalDuration.value > 0) { - progressValue = - (controller.currentPosition.value / controller.totalDuration.value) - .clamp(0.0, 1.0); - } - - return Column( - children: [ - // 时间显示 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _formatDuration(controller.currentPosition.value), - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - Text( - _formatDuration(controller.totalDuration.value), - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - ], - ), - SizedBox(height: 8.h), - // 进度条 - 使用LinearProgressIndicator,更简洁 - LinearProgressIndicator( - value: progressValue, - backgroundColor: isDarkMode ? Colors.grey[700] : Colors.grey[300], - valueColor: AlwaysStoppedAnimation( - Theme.of(Get.context!).primaryColor), - minHeight: 4.h, - ), - ], - ); - }); - } - - // 构建迷你播放器 - 参考qqmusic_test的设计 - Widget _buildMiniPlayer(bool isDarkMode) { - return Obx(() { - if (controller.actualCurrentSong.value == null) { - return const SizedBox.shrink(); - } - - final song = controller.actualCurrentSong.value!; - return Container( - height: 80.h, - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[900] : Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 4, - offset: const Offset(0, -2), - ), - ], - ), - child: Column( - children: [ - // 进度条 - LinearProgressIndicator( - value: controller.totalDuration.value > 0 - ? (controller.currentPosition.value / - controller.totalDuration.value) - .clamp(0.0, 1.0) - : 0, - backgroundColor: Colors.grey[300], - valueColor: AlwaysStoppedAnimation( - Theme.of(Get.context!).primaryColor), - ), - Expanded( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: Row( - children: [ - // 歌曲信息 - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - song.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - Text( - song.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: isDarkMode - ? Colors.grey[400] - : Colors.grey[600], - fontSize: 12.sp, - ), - ), - ], - ), - ), - // 控制按钮 - Row( - children: [ - IconButton( - icon: const Icon(Icons.skip_previous), - onPressed: controller.previousSong, - color: isDarkMode ? Colors.white : Colors.black, - ), - IconButton( - icon: Icon( - controller.actualIsPlaying.value - ? Icons.pause - : Icons.play_arrow, - ), - onPressed: controller.togglePlayPause, - color: Theme.of(Get.context!).primaryColor, - ), - IconButton( - icon: const Icon(Icons.skip_next), - onPressed: controller.nextSong, - color: isDarkMode ? Colors.white : Colors.black, - ), - ], - ), - ], - ), - ), - ), - ], - ), - ); - }); - } - - // 构建播放控制按钮 - Widget _buildPlayControls(bool isDarkMode) { - return Obx(() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: () async { - await controller.previousSong(); - }, - iconSize: 32.w, - padding: EdgeInsets.all(8.w), // 增大触摸区域 - icon: Icon( - Icons.skip_previous, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - SizedBox(width: 16.w), - Container( - width: 56.w, - height: 56.w, - decoration: BoxDecoration( - color: Theme.of(Get.context!).primaryColor, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Theme.of(Get.context!).primaryColor.withOpacity(0.3), - blurRadius: 10, - spreadRadius: 1, - offset: const Offset(0, 3), - ), - ], - ), - child: IconButton( - onPressed: () async { - await controller.togglePlayPause(); - }, - padding: EdgeInsets.zero, - icon: Icon( - controller.actualIsPlaying.value - ? Icons.pause - : Icons.play_arrow, - size: 28.w, - color: Colors.white, - ), - ), - ), - SizedBox(width: 16.w), - IconButton( - onPressed: () async { - await controller.nextSong(); - }, - iconSize: 32.w, - padding: EdgeInsets.all(8.w), // 增大触摸区域 - icon: Icon( - Icons.skip_next, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ], - ); - }); - } - - // ...(其他辅助方法保持不变,如_formatDuration, _togglePlayPause等) - // 格式化时间显示 - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final minutes = duration.inMinutes; - final seconds = duration.inSeconds % 60; - return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } -} diff --git a/apps/client/lib/modules/realtime/README.md b/apps/client/lib/modules/realtime/README.md deleted file mode 100644 index c41004a5..00000000 --- a/apps/client/lib/modules/realtime/README.md +++ /dev/null @@ -1,160 +0,0 @@ -# Realtime 实时语音聊天模块 - -## ⚠️ 当前状态 - -**此模块为功能预览版本,提供完整的实时语音对话功能。** - -通过原生插件实现语音识别、AI对话和语音合成的完整语音交互体验。 - -## 概述 - -Realtime模块实现了类似ChatGPT app的实时语音对话功能,当前版本提供: - -- ✅ **完整语音交互** - 实时语音识别、AI对话和语音合成 -- ✅ **状态管理** - 聆听、思考、回答状态的可视化 -- ✅ **动画效果** - 基于Shader的流动云效果,支持音频振幅驱动 -- ✅ **字幕显示** - 可选择开启/关闭的字幕模式 -- ✅ **智能UI** - 根据连接状态自动调整按钮可用性 - -## 界面设计 - -### 布局结构 -- **顶部状态区域** - 显示当前状态和字幕模式切换按钮 -- **中间可视化区域** - 圆形Shader动画,根据音频振幅呈现呼吸效果 -- **底部控制区域** - 主控制按钮和状态指示器 - -### 状态指示 -- 🔵 **聆听状态** - 蓝色动画,接收用户语音输入 -- 🟠 **思考状态** - 橙色动画,AI处理中 -- 🟢 **回答状态** - 绿色动画,AI语音输出 -- ⚪ **空闲状态** - 灰色静态,等待交互 - -### 字幕模式 -- **默认关闭** - 初始状态下不显示任何字幕内容 -- **手动切换** - 通过右上角按钮手动开启/关闭字幕 -- **完整隐藏** - 关闭时字幕区域完全不显示,不占用界面空间 -- **智能布局** - 开启时圆形动画自动上移,字幕区域占据下半屏 - -### 交互控制 -- **录音按钮** - 仅在成功连接AI服务器且开始录音后可用 -- **连接状态** - 未连接时录音按钮显示不可用状态 -- **状态反馈** - 按钮颜色和可点击性根据连接状态动态调整 - -## 使用方法 - -### 路由导航 - -```dart -// 跳转到实时语音对话页面 -Get.toNamed(Routes.realtime); -``` - -### 交互流程 - -1. **自动连接** - 页面加载后自动尝试连接AI服务器 -2. **等待连接** - 录音按钮在连接成功前保持不可用状态 -3. **开始录音** - 连接成功后自动开始录音,录音按钮变为可用 -4. **语音对话** - 用户说话 → AI处理 → 语音回复 -5. **字幕控制** - 可随时通过右上角按钮切换字幕显示 - -## 技术架构 - -### 控制器 (RealtimeController) -- **连接管理**: WebSocket连接到AI服务器 -- **状态同步**: `isConnected`, `isListening`, `isSpeaking` -- **字幕控制**: `isSubtitleMode` 默认为false,用户手动控制 -- **消息管理**: 智能去重,避免流式响应重复显示 -- **音频处理**: 原生层计算RMS值,Flutter层接收并控制动画 - -### 视图组件 -- **RealtimeView** - 主视图容器,响应式布局 -- **ShaderMicFlow** - 基于GLSL Shader的流动云动画组件 -- **AutoScrollList** - 自动滚动的字幕列表 - -### 原生集成 -- **RealtimeService** - 原生音频处理和WebSocket通信 -- **音频格式** - 16kHz, 1声道, 16位PCM -- **实时传输** - 低延迟音频流处理 -- **RMS计算** - 原生层实时计算音频振幅 - -## 默认行为 - -### 字幕模式 -- **初始状态**: 关闭 (`isSubtitleMode.value = false`) -- **显示逻辑**: 仅在用户手动开启时显示字幕内容 -- **空间占用**: 关闭时字幕区域高度为0,不影响其他UI元素 -- **切换效果**: 平滑动画过渡,圆形动画位置和大小同步调整 - -### 按钮状态 -- **录音按钮**: 默认不可用,连接成功且开始录音后才可点击 -- **状态指示**: 通过颜色和图标变化反映当前可用性 -- **交互反馈**: 不可用时点击无效果,避免误操作 - -## 开发计划 - -### 已完成功能 ✅ -- [x] 完整语音交互流程 -- [x] 实时WebSocket通信 -- [x] Shader流动云动画 -- [x] RMS音频振幅计算 -- [x] 智能UI状态管理 -- [x] 字幕模式控制 -- [x] 消息去重优化 - -### 计划增强 ⏳ -- [ ] 多语言语音识别 -- [ ] 语音中断检测 -- [ ] 音频质量自适应 -- [ ] 离线降级处理 -- [ ] 语音情感识别 - -## 注意事项 - -1. **网络依赖** - 需要连接到AI服务器 (ws://192.168.1.11:8000/ws) -2. **麦克风权限** - 需要获取麦克风使用权限 -3. **音频会话** - 自动管理音频会话,支持蓝牙设备 -4. **资源管理** - 页面关闭时自动清理音频资源和网络连接 - -## 自定义配置 - -### 修改服务器地址 - -```dart -// 在RealtimeController中修改连接URL -await _realtimeService.initialize( - serverUrl: 'ws://your-server:port/ws', - // ... 其他参数 -); -``` - -### 调整UI布局 - -```dart -// 修改字幕模式下圆形动画位置 -alignment: controller.isSubtitleMode.value - ? const Alignment(0, -0.9) // 调整Y轴位置 - : const Alignment(0, -0.25), -``` - -### 音频参数调优 - -```dart -// 在初始化时调整音频参数 -sampleRate: 16000, // 采样率 -channels: 1, // 声道数 -bitsPerSample: 16, // 位深度 -``` - -## 技术栈 - -- **Flutter SDK**: >= 3.0.0 -- **GetX**: >= 4.6.5 (状态管理) -- **Flutter Shaders**: >= 0.0.6 (Shader动画) -- **原生插件**: Realtime服务 (音频处理) -- **WebSocket**: 实时通信协议 - -## 版本信息 - -- **当前版本**: v1.0.0 (Production) -- **最后更新**: 2024年 -- **状态**: 完整功能实现 \ No newline at end of file diff --git a/apps/client/lib/modules/realtime/bindings/realtime_binding.dart b/apps/client/lib/modules/realtime/bindings/realtime_binding.dart deleted file mode 100644 index a9b6b65b..00000000 --- a/apps/client/lib/modules/realtime/bindings/realtime_binding.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/realtime_controller.dart'; - -class RealtimeBinding extends Bindings { - @override - void dependencies() { - // 注册实时语音聊天控制器 - Get.lazyPut(() => RealtimeController()); - } -} \ No newline at end of file diff --git a/apps/client/lib/modules/realtime/controllers/realtime_controller.dart b/apps/client/lib/modules/realtime/controllers/realtime_controller.dart deleted file mode 100644 index a68b7989..00000000 --- a/apps/client/lib/modules/realtime/controllers/realtime_controller.dart +++ /dev/null @@ -1,406 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:get/get.dart'; -import 'package:realtime/realtime.dart'; - -import '../../../core/utils/logger.dart'; - -class RealtimeController extends GetxController with GetTickerProviderStateMixin { - static const int _sampleRate = 16000; - - // Realtime服务实例 - final RealtimeService _realtimeService = RealtimeService(); - - // 音频振幅值(RMS) - final RxDouble audioAmplitude = 0.0.obs; - - // 连接状态 - final RxBool isConnected = false.obs; - final RxBool isConnecting = false.obs; - - // 语音状态 - final RxBool isListening = false.obs; - final RxBool isSpeaking = false.obs; - - // UI状态 - final RxString currentStatus = ''.obs; // 移除默认文字 - final RxString userSpeechText = ''.obs; - final RxBool isUserSpeechFinal = true.obs; - final RxList aiResponseMessages = [].obs; - final RxBool isSubtitleMode = true.obs; - - // 字幕显示用的最新消息 - final RxString latestUserMessage = ''.obs; - final RxString latestAiMessage = ''.obs; - - // 实时字幕更新用的临时消息 - final RxString partialUserMessage = ''.obs; - final RxString streamingAiMessage = ''.obs; - - // 状态监听 - StreamSubscription? _eventSubscription; - - - @override - void onInit() { - super.onInit(); - _setupEventListener(); - - // 自动开始连接 - WidgetsBinding.instance.addPostFrameCallback((_) { - _autoConnect(); - }); - } - - @override - void onClose() { - _cleanup(); - super.onClose(); - } - - /// 设置事件监听器 - void _setupEventListener() { - _eventSubscription = _realtimeService.eventStream.listen( - (event) { - _handleRealtimeEvent(event); - }, - onError: (error) { - Logger.error('Realtime事件流错误: $error'); - }, - ); - } - - /// 处理Realtime事件 - void _handleRealtimeEvent(RealtimeEvent event) { - switch (event.type) { - case 'connectionStatusChanged': - _handleConnectionStatusChanged(event.data); - break; - case 'voiceStatusChanged': - _handleVoiceStatusChanged(event.data); - break; - case 'textReceived': - _handleTextReceived(event.data); - break; - case 'audioRms': - _handleAudioRms(event.data); - break; - case 'error': - _handleError(event.data); - break; - } - } - - /// 自动连接 - Future _autoConnect() async { - currentStatus.value = ''; // 不显示任何文字 - await connect(); - } - - /// 处理连接状态变化 - void _handleConnectionStatusChanged(String status) { - switch (status) { - case 'connected': - isConnected.value = true; - isConnecting.value = false; - currentStatus.value = ''; // 不显示任何文字 - // 连接成功后自动开始录音 - Timer(const Duration(milliseconds: 500), () { - startListening(); - }); - break; - case 'connecting': - isConnecting.value = true; - currentStatus.value = ''; // 不显示任何文字 - break; - case 'disconnected': - isConnected.value = false; - isConnecting.value = false; - currentStatus.value = ''; // 不显示任何文字 - _resetState(); - break; - case 'error': - isConnected.value = false; - isConnecting.value = false; - currentStatus.value = '连接失败,请检查网络'; // 保留错误提示 - _resetState(); - Logger.error('Vocode服务器连接错误'); - break; - } - } - - /// 处理语音状态变化 - void _handleVoiceStatusChanged(String status) { - switch (status) { - case 'recording': - isListening.value = true; - isSpeaking.value = false; - currentStatus.value = ''; // 不显示任何文字 - break; - case 'processing': - isListening.value = false; - currentStatus.value = ''; // 不显示任何文字 - break; - case 'playing': - isListening.value = false; - isSpeaking.value = true; - currentStatus.value = ''; // 不显示任何文字 - break; - case 'idle': - isListening.value = false; - isSpeaking.value = false; - currentStatus.value = ''; // 不显示任何文字 - // 空闲时音频振幅归零 - audioAmplitude.value = 0.0; - break; - } - } - - /// 处理收到的文本消息 - 标准Vocode WebSocket协议 - void _handleTextReceived(dynamic payload) { - if (payload is String) { - try { - // 解析JSON消息 - final Map json = jsonDecode(payload); - final String messageType = json['type'] ?? ''; - - switch (messageType) { - case 'websocket_transcript': - // 处理统一的文本消息格式 - final String text = json['text'] ?? ''; - final String sender = json['sender'] ?? ''; - final bool isFinal = json['is_final'] ?? false; - // final dynamic timestamp = json['timestamp']; // 保留供未来使用 - - if (sender.toLowerCase() == 'human') { - // 用户语音识别消息 - if (isFinal) { - // ASR最终结果 - if (text.isNotEmpty) { - latestUserMessage.value = text; - userSpeechText.value = text; - isUserSpeechFinal.value = true; - } - // 清空部分消息 - partialUserMessage.value = ''; - } else { - // ASR实时识别 - if (text.isNotEmpty) { - partialUserMessage.value = text; - isUserSpeechFinal.value = false; - } - } - } else if (sender.toLowerCase() == 'bot') { - // AI回复消息 - if (isFinal) { - // LLM回复完成标识 (text为空) - if (streamingAiMessage.value.isNotEmpty) { - // 将流式消息确认为最终消息 - latestAiMessage.value = streamingAiMessage.value; - if (aiResponseMessages.isNotEmpty) { - aiResponseMessages[aiResponseMessages.length - 1] = streamingAiMessage.value; - } else { - aiResponseMessages.add(streamingAiMessage.value); - } - streamingAiMessage.value = ''; - } - } else { - // LLM流式回复 - if (text.isNotEmpty) { - streamingAiMessage.value += text; - } - } - } - break; - - case 'websocket_ready': - // 连接就绪 - break; - - case 'websocket_stop': - // 会话结束 - break; - - case 'websocket_start': - // 会话开始 - break; - - default: - Logger.error('收到未知消息类型: "$messageType"'); - } - } catch (e) { - Logger.error('解析WebSocket消息失败: $e'); - } - } else { - Logger.error('收到非字符串类型的payload: ${payload.runtimeType}'); - } - } - - /// 处理音频RMS值 - void _handleAudioRms(dynamic rms) { - if (rms is double || rms is num) { - // 平滑处理RMS值,避免抖动太大,增大幅度 - final double rawValue = rms.toDouble().clamp(0.0, 1.0); - // 增强幅度:平方根函数减少小值,对数函数增强大值 - final double enhancedValue = (rawValue * 3.0).clamp(0.0, 1.0); - // 更强的平滑处理,减少抖动 - audioAmplitude.value = audioAmplitude.value * 0.85 + enhancedValue * 0.15; - } - } - - /// 处理错误 - void _handleError(String error) { - Logger.error('Realtime错误: $error'); - } - - /// 切换字幕模式 - void toggleSubtitleMode() { - isSubtitleMode.value = !isSubtitleMode.value; - } - - /// 清空聊天记录 - void clearChat() { - userSpeechText.value = ''; - isUserSpeechFinal.value = true; - aiResponseMessages.clear(); - latestUserMessage.value = ''; - latestAiMessage.value = ''; - partialUserMessage.value = ''; - streamingAiMessage.value = ''; - HapticFeedback.lightImpact(); - } - - /// 连接到AI服务 - Future connect() async { - if (isConnecting.value || isConnected.value) return; - - try { - HapticFeedback.lightImpact(); - - // 初始化Realtime服务 - bool initialized = await _realtimeService.initialize( - serverUrl: 'ws://192.168.1.11:8000/ws', - sampleRate: _sampleRate, - channels: 1, - bitsPerSample: 16, - ); - - if (!initialized) { - throw Exception('初始化Realtime服务失败'); - } - - // 连接服务器 - final connected = await _realtimeService.connect(); - if (!connected) { - throw Exception('连接Vocode服务器失败'); - } - - } catch (e) { - Logger.error('连接失败: $e'); - isConnecting.value = false; - _resetState(); - } - } - - /// 断开连接 - Future disconnect() async { - try { - HapticFeedback.mediumImpact(); - - if (isListening.value) { - await _realtimeService.stopRecording(); - } - - await _realtimeService.stopPlaying(); - await _realtimeService.disconnect(); - - } catch (e) { - Logger.error('断开连接失败: $e'); - } - } - - /// 开始聆听 - void startListening() { - if (!isConnected.value || isListening.value) return; - - HapticFeedback.lightImpact(); - - _realtimeService.startRecording().then((success) { - if (!success) { - Logger.error('开始录音失败'); - } - }); - } - - /// 停止聆听 - void stopListening() { - if (!isListening.value) return; - - _realtimeService.stopRecording().then((success) { - if (!success) { - Logger.error('停止录音失败'); - } - }); - } - - /// 切换聆听状态 - void toggleListening() { - if (!isConnected.value) return; - - if (isListening.value) { - stopListening(); - } else { - startListening(); - } - } - - /// 关闭并返回上一页 - Future closeAndGoBack() async { - Get.back(); - - // 异步清理资源 - Future.delayed(Duration.zero, () async { - try { - if (isListening.value) { - await _realtimeService.stopRecording(); - } - await _realtimeService.stopPlaying(); - await _realtimeService.disconnect(); - await _realtimeService.dispose(); - _resetState(); - } catch (e) { - Logger.error('关闭连接失败: $e'); - } - }); - } - - /// 重置状态 - void _resetState() { - isConnected.value = false; - isConnecting.value = false; - isListening.value = false; - isSpeaking.value = false; - userSpeechText.value = ''; - isUserSpeechFinal.value = true; - aiResponseMessages.clear(); - latestUserMessage.value = ''; - latestAiMessage.value = ''; - partialUserMessage.value = ''; - streamingAiMessage.value = ''; - audioAmplitude.value = 0.0; - } - - /// 清理资源 - void _cleanup() async { - _eventSubscription?.cancel(); - - try { - await _realtimeService.dispose(); - } catch (e) { - Logger.error('清理Realtime服务失败: $e'); - } - } -} \ No newline at end of file diff --git a/apps/client/lib/modules/realtime/views/realtime_view.dart b/apps/client/lib/modules/realtime/views/realtime_view.dart deleted file mode 100644 index cfe33cab..00000000 --- a/apps/client/lib/modules/realtime/views/realtime_view.dart +++ /dev/null @@ -1,332 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:get/get.dart'; -import '../controllers/realtime_controller.dart'; -import 'widgets/shader_mic_flow.dart'; - -class RealtimeView extends GetView { - const RealtimeView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - // 设置状态栏为黑色文字 - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - statusBarBrightness: Brightness.light, - ), - ); - - return Scaffold( - backgroundColor: Colors.white, - body: SafeArea( - child: Stack( - children: [ - // 中间音频可视化 - Positioned.fill( - child: Obx(() => AnimatedAlign( - alignment: controller.isSubtitleMode.value - ? const Alignment(0, -0.7) // 字幕模式时移到更上方 - : const Alignment(0, -0.25), - duration: const Duration(milliseconds: 600), - curve: Curves.easeInOutCubic, - child: LayoutBuilder( - builder: (context, constraints) { - final targetSize = controller.isSubtitleMode.value - ? MediaQuery.of(context).size.width * 0.55 // 字幕模式时较小 - : MediaQuery.of(context).size.width * 0.75; // 初始状态更大 - - return TweenAnimationBuilder( - tween: Tween(end: targetSize), - duration: const Duration(milliseconds: 600), - curve: Curves.easeInOutCubic, - builder: (context, animatedSize, child) { - return Stack( - alignment: Alignment.center, - children: [ - // 使用Shader动画 - Obx(() => ShaderMicFlow( - size: animatedSize, - amplitude: controller.audioAmplitude.value, - )), - ], - ); - }, - ); - }, - ), - )), - ), - - // 字幕显示区域 - 只有在字幕模式开启时才显示,位于圆形下方 - if (controller.isSubtitleMode.value) - Positioned( - left: 20, - right: 20, - top: MediaQuery.of(context).size.height * 0.35, // 在圆形下方 - bottom: 160, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Obx(() => AnimatedOpacity( - opacity: controller.isSubtitleMode.value ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: TweenAnimationBuilder( - tween: Tween( - begin: 0.0, - end: controller.isSubtitleMode.value ? 1.0 : 0.0, - ), - duration: const Duration(milliseconds: 900), // 延迟显示,让圆圈先移动 - curve: Curves.easeInOut, - builder: (context, animationValue, child) { - return Transform.translate( - offset: Offset(0, 20 * (1 - animationValue)), // 从下方滑入 - child: Opacity( - opacity: animationValue, - child: _buildChatSubtitles(), - ), - ); - }, - ), - )), - ), - ), - - // 底部控制按钮 - Positioned( - bottom: 80, - left: 0, - right: 0, - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - // 麦克风按钮 - Obx(() => _buildMicButton()), - // 字幕按钮 - Obx(() => _buildMainButton( - icon: controller.isSubtitleMode.value ? Icons.subtitles : Icons.subtitles_outlined, - onTap: controller.toggleSubtitleMode, - enabled: true, - isActive: controller.isSubtitleMode.value, - )), - // 清空聊天按钮 - _buildMainButton( - icon: Icons.clear_all, - onTap: controller.clearChat, - enabled: true, - isActive: false, - ), - // 关闭按钮 - _buildMainButton( - icon: Icons.close, - onTap: controller.closeAndGoBack, - enabled: true, - isActive: false, - ), - ], - ), - ), - ), - - // Home Indicator - Positioned( - bottom: 8, - left: 0, - right: 0, - child: Center( - child: Container( - width: 134, - height: 5, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(2.5), - ), - ), - ), - ), - ], - ), - ), - ); - } - - /// 构建聊天字幕界面 - 上下两行显示用户输入和AI回复 - Widget _buildChatSubtitles() { - return Obx(() { - // 组合用户消息:优先显示实时部分转录,否则显示最终消息 - final String displayUserMessage = controller.partialUserMessage.value.isNotEmpty - ? controller.partialUserMessage.value - : controller.latestUserMessage.value; - - // 组合AI消息:优先显示流式响应,否则显示最终消息 - final String displayAiMessage = controller.streamingAiMessage.value.isNotEmpty - ? controller.streamingAiMessage.value - : controller.latestAiMessage.value; - - final hasUserMessage = displayUserMessage.isNotEmpty; - final hasAiMessage = displayAiMessage.isNotEmpty; - - // 如果没有消息,显示空白 - if (!hasUserMessage && !hasAiMessage) { - return Container(); - } - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, // 让Column紧凑排列 - children: [ - // 用户输入行 - 蓝色,动态扩展 - if (hasUserMessage) - Container( - width: double.infinity, - constraints: const BoxConstraints( - minHeight: 30, // 最小1行高度 - maxHeight: 104, // 最大4行高度 (26*4) - ), - padding: const EdgeInsets.symmetric(vertical: 4), - child: SingleChildScrollView( - reverse: true, - child: Text( - displayUserMessage, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - color: Colors.blue[600], - height: 1.4, - fontWeight: FontWeight.w500, - letterSpacing: 0.3, - ), - ), - ), - ), - - // 分隔线 - 只有在两个消息都存在时才显示 - if (hasUserMessage && hasAiMessage) - Container( - width: 60, - height: 1, - margin: const EdgeInsets.symmetric(vertical: 2), // 进一步减少分隔线间距 - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(0.5), - ), - ), - - // AI回复行 - 绿色,动态扩展 - if (hasAiMessage) - Container( - width: double.infinity, - constraints: const BoxConstraints( - minHeight: 30, // 最小1行高度 - maxHeight: 104, // 最大4行高度 (26*4) - ), - padding: const EdgeInsets.symmetric(vertical: 4), - child: SingleChildScrollView( - reverse: true, - child: Text( - displayAiMessage, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - color: Colors.green[600], - height: 1.4, - fontWeight: FontWeight.w500, - letterSpacing: 0.3, - ), - ), - ), - ), - ], - ); - }); - } - - /// 构建麦克风按钮 - 根据连接状态和录音状态显示不同的状态 - Widget _buildMicButton() { - // 获取当前状态 - final isConnected = controller.isConnected.value; - final isConnecting = controller.isConnecting.value; - final isListening = controller.isListening.value; - - // 确定按钮状态 - IconData icon; - Color? backgroundColor; - Color? iconColor; - VoidCallback? onTap; - - if (!isConnected && !isConnecting) { - // 未连接状态 - 禁用 - icon = Icons.mic_off; - backgroundColor = Colors.grey[300]; - iconColor = Colors.grey[600]; - onTap = null; - } else if (isConnecting) { - // 连接中状态 - 禁用但显示连接中 - icon = Icons.sync; - backgroundColor = Colors.orange[200]; - iconColor = Colors.orange[700]; - onTap = null; - } else if (isListening) { - // 录音中状态 - 激活状态 - icon = Icons.mic; - backgroundColor = Theme.of(Get.context!).primaryColor; - iconColor = Colors.white; - onTap = controller.toggleListening; - } else { - // 已连接但未录音状态 - 正常状态 - icon = Icons.mic_none; - backgroundColor = Colors.grey[200]; - iconColor = Colors.black; - onTap = controller.toggleListening; - } - - return GestureDetector( - onTap: onTap, - child: Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: backgroundColor, - shape: BoxShape.circle, - ), - child: Icon( - icon, - color: iconColor, - size: 26, - ), - ), - ); - } - - /// 构建主要控制按钮 - Widget _buildMainButton({ - required IconData icon, - VoidCallback? onTap, - required bool enabled, - required bool isActive, - }) { - return GestureDetector( - onTap: enabled ? onTap : null, - child: Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: enabled - ? (isActive ? Theme.of(Get.context!).primaryColor : Colors.grey[200]) - : Colors.grey[300], - shape: BoxShape.circle, - ), - child: Icon( - icon, - color: enabled - ? (isActive ? Colors.white : Colors.black) - : Colors.grey[600], - size: 26, - ), - ), - ); - } - -} \ No newline at end of file diff --git a/apps/client/lib/modules/realtime/views/widgets/shader_mic_flow.dart b/apps/client/lib/modules/realtime/views/widgets/shader_mic_flow.dart deleted file mode 100644 index bf21bd14..00000000 --- a/apps/client/lib/modules/realtime/views/widgets/shader_mic_flow.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'dart:ui'; -import 'package:flutter/material.dart'; - -/// 使用Shader实现的麦克风流动动画组件 -class ShaderMicFlow extends StatefulWidget { - final double size; - final double amplitude; - - const ShaderMicFlow({ - super.key, - required this.size, - this.amplitude = 0.0, - }); - - @override - State createState() => _ShaderMicFlowState(); -} - -class _ShaderMicFlowState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - FragmentShader? _shader; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(seconds: 60), - vsync: this, - )..repeat(); - - _loadShader(); - } - - Future _loadShader() async { - try { - final program = await FragmentProgram.fromAsset('shaders/mic_flow.frag'); - setState(() { - _shader = program.fragmentShader(); - }); - } catch (e) { - debugPrint('Failed to load shader: $e'); - } - } - - @override - void dispose() { - _controller.dispose(); - _shader?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - width: widget.size, - height: widget.size, - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return CustomPaint( - painter: _shader != null - ? _ShaderPainter( - shader: _shader!, - time: _controller.value * 60.0, - amplitude: widget.amplitude, - ) - : null, - size: Size.square(widget.size), - ); - }, - ), - ); - } -} - -class _ShaderPainter extends CustomPainter { - final FragmentShader shader; - final double time; - final double amplitude; - - _ShaderPainter({ - required this.shader, - required this.time, - required this.amplitude, - }); - - @override - void paint(Canvas canvas, Size size) { - // 设置shader uniforms - shader.setFloat(0, size.width); // u_resolution.x - shader.setFloat(1, size.height); // u_resolution.y - shader.setFloat(2, time); // u_time - shader.setFloat(3, amplitude); // u_amp - - final paint = Paint()..shader = shader; - canvas.drawRect(Offset.zero & size, paint); - } - - @override - bool shouldRepaint(covariant _ShaderPainter oldDelegate) { - return oldDelegate.time != time || oldDelegate.amplitude != amplitude; - } -} \ No newline at end of file diff --git a/apps/client/lib/modules/speech_test/bindings/speech_test_binding.dart b/apps/client/lib/modules/speech_test/bindings/speech_test_binding.dart deleted file mode 100644 index 71e981a9..00000000 --- a/apps/client/lib/modules/speech_test/bindings/speech_test_binding.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:get/get.dart'; - -import '../controllers/speech_test_controller.dart'; - -class SpeechTestBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut( - () => SpeechTestController(), - ); - } -} \ No newline at end of file diff --git a/apps/client/lib/modules/speech_test/controllers/speech_test_controller.dart b/apps/client/lib/modules/speech_test/controllers/speech_test_controller.dart deleted file mode 100644 index a2f74d3b..00000000 --- a/apps/client/lib/modules/speech_test/controllers/speech_test_controller.dart +++ /dev/null @@ -1,426 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; - -import '../../../data/services/asr_service.dart'; -import '../../../data/services/tts_service.dart'; -import '../../../data/services/speech_factory.dart'; -import '../../../core/utils/logger.dart'; - -/// 语音测试控制器 -class SpeechTestController extends GetxController { - // 语音服务工厂 - final SpeechFactory _speechFactory = Get.find(); - - // 当前使用的TTS服务 - late TtsService _ttsService; - - // 当前使用的ASR服务 - late AsrService _asrService; - - // 状态变量 - final RxBool isInitialized = false.obs; - final RxBool isProcessing = false.obs; - final RxBool isListening = false.obs; - final RxBool isSpeaking = false.obs; - - // 识别结果 - final RxString intermediateResult = ''.obs; // 中间结果 - final RxString finalResult = ''.obs; // 最终结果 - final RxString recognitionStatus = '准备就绪'.obs; - final RxString detectedLanguage = ''.obs; // 检测到的语言 - - // TTS事件 - final RxString ttsStatus = '准备就绪'.obs; - - // 当前选择的语音服务类型 - final Rx selectedServiceType = SpeechServiceType.azure.obs; - // 音频输入源 - bool _audioSourceType = false; - // 连续识别结果流 - StreamSubscription? _recognitionSubscription; - - // TTS事件流 - StreamSubscription? _ttsEventSubscription; - - // 文本控制器 - final TextEditingController textController = TextEditingController(); - - // 连续播放的测试句子 - final List testSentences = [ - '这是第一句测试语音。', - '这是第二句测试语音,语音合成支持连续播放。', - '这是第三句测试语音,将在连续模式下播放完毕。' - ]; - - var _currsessionid = ''; - - @override - void onInit() { - super.onInit(); - // 设置默认待播放文本 - textController.text = '欢迎使用语音测试,这是一段默认的测试文本。您可以修改此文本,然后点击播放按钮测试语音合成效果。'; - _initServices(); - } - - /// 初始化服务 - Future _initServices() async { - try { - isProcessing.value = true; - - // 获取服务实例 - _ttsService = _speechFactory.getTtsService(); - _asrService = _speechFactory.getAsrService(); - - // 初始化服务 - await _ttsService.initialize(); - await _asrService.initialize(); - - // 监听TTS事件 - _setupTtsEventListener(); - // 获取识别事件流 - 这启动了异步识别过程 - final stream = await _asrService.recognizeCallback(); - // 设置事件监听 - 异步处理识别结果 - _setupAsrEventListener(stream); - isInitialized.value = true; - ttsStatus.value = '准备就绪'; - recognitionStatus.value = '准备就绪'; - } catch (e) { - ttsStatus.value = '初始化失败'; - recognitionStatus.value = '初始化失败'; - Logger.error('语音测试初始化失败: ${e.toString()}'); - } finally { - isProcessing.value = false; - } - } - - /// 设置TTS事件监听 - void _setupTtsEventListener() { - // 取消旧的订阅 - _ttsEventSubscription?.cancel(); - - // 设置新的订阅 - _ttsEventSubscription = _ttsService.onEvent.listen((event) { - switch (event.type) { - case TtsEventType.started: - isSpeaking.value = true; - ttsStatus.value = '正在播放...'; - break; - case TtsEventType.completed: - isSpeaking.value = false; - ttsStatus.value = '播放完成'; - break; - case TtsEventType.canceled: - isSpeaking.value = false; - ttsStatus.value = '播放已取消'; - break; - case TtsEventType.error: - isSpeaking.value = false; - ttsStatus.value = '播放错误: ${event.error}'; - break; - } - }); - } - - /// 设置ASR事件监听 - void _setupAsrEventListener(Stream stream) { - // 取消旧的订阅 - _recognitionSubscription?.cancel(); - - // 重置识别结果 - intermediateResult.value = ''; - finalResult.value = ''; - detectedLanguage.value = ''; - recognitionStatus.value = '正在等待语音输入...'; - - // 设置新的订阅 - _recognitionSubscription = stream.listen((event) { - switch (event.type) { - case RecognitionEventType.sessionStarted: - isListening.value = true; - recognitionStatus.value = '开始聆听...'; - break; - - case RecognitionEventType.sessionStopped: - isListening.value = false; - recognitionStatus.value = '聆听已结束'; - // 清空中间结果,保留最终结果 - intermediateResult.value = ''; - break; - - case RecognitionEventType.intermediateResult: - // 更新中间结果,显示为斜体或不同颜色 - intermediateResult.value = event.text; - if (event.detectedLanguage.isNotEmpty) { - detectedLanguage.value = event.detectedLanguage; - } - recognitionStatus.value = '正在识别...'; - break; - - case RecognitionEventType.finalResult: - // 更新最终结果,显示为正常文本 - finalResult.value = event.text; - intermediateResult.value = ''; // 清空中间结果 - if (event.detectedLanguage.isNotEmpty) { - detectedLanguage.value = event.detectedLanguage; - } - recognitionStatus.value = '识别完成'; - break; - case RecognitionEventType.finalResult1: - // 更新最终结果,显示为正常文本 - finalResult.value = event.text; - intermediateResult.value = ''; // 清空中间结果 - if (event.detectedLanguage.isNotEmpty) { - detectedLanguage.value = event.detectedLanguage; - } - recognitionStatus.value = '识别完成'; - break; - case RecognitionEventType.error: - isListening.value = false; - recognitionStatus.value = '识别错误: ${event.error}'; - break; - - case RecognitionEventType.canceled: - isListening.value = false; - recognitionStatus.value = '识别已取消'; - // 清空中间结果 - intermediateResult.value = ''; - break; - } - }, onError: (error) { - isListening.value = false; - recognitionStatus.value = '识别错误: $error'; - intermediateResult.value = ''; - }); - } - - /// 切换语音服务类型 - void switchServiceType(SpeechServiceType type) async { - if (selectedServiceType.value == type) return; - - try { - isProcessing.value = true; - - // 停止当前的所有服务状态 - await stopAll(); - - // 切换服务类型 - _speechFactory.setServiceType(type); - selectedServiceType.value = type; - - // 重新初始化服务 - await _initServices(); - } catch (e) { - Logger.error('切换语音服务类型失败: ${e.toString()}'); - } finally { - isProcessing.value = false; - } - } - - /// 停止所有活动 - Future stopAll() async { - // 停止ASR - if (isListening.value) { - await stopContinuousRecognition(); - } - - // 停止TTS - if (isSpeaking.value) { - await stopSpeaking(); - } - - // 取消所有订阅 - await _recognitionSubscription?.cancel(); - _recognitionSubscription = null; - } - - /// 单次播放文本 - Future speakOnce() async { - if (!isInitialized.value || isProcessing.value) return; - - final text = textController.text.trim(); - if (text.isEmpty) { - ttsStatus.value = '请输入要播放的文本'; - return; - } - - try { - isProcessing.value = true; - ttsStatus.value = '准备播放...'; - - final result = await _ttsService.speakOnce(_currsessionid, text); - - if (!result) { - ttsStatus.value = '播放失败'; - } - } catch (e) { - ttsStatus.value = '播放异常: $e'; - Logger.error('单次播放异常: ${e.toString()}'); - } finally { - isProcessing.value = false; - } - } - - /// 连续播放测试 - Future speakContinuously() async { - if (!isInitialized.value || isProcessing.value) return; - - try { - isProcessing.value = true; - ttsStatus.value = '准备连续播放...'; - - // 为避免阻塞,使用一个句子接一个句子的方式添加到队列 - for (final sentence in testSentences) { - if (!await _ttsService.speakStream(_currsessionid, sentence)) { - ttsStatus.value = '添加句子到队列失败'; - break; - } - // 给前一个句子一点处理时间 - await Future.delayed(const Duration(milliseconds: 50)); - } - - // 刷新队列,确保播放完成 - await _ttsService.flushStream(_currsessionid); - } catch (e) { - ttsStatus.value = '连续播放异常: $e'; - Logger.error('连续播放异常: ${e.toString()}'); - } finally { - isProcessing.value = false; - } - } - - /// 停止播放 - Future stopSpeaking() async { - if (!isInitialized.value) return; - - try { - await _ttsService.stop(); - } catch (e) { - ttsStatus.value = '停止播放异常: $e'; - Logger.error('停止播放异常: ${e.toString()}'); - } - } - - /// 一次性识别 - Future recognizeOnce() async { - if (!isInitialized.value || isProcessing.value || isListening.value) return; - - try { - isProcessing.value = true; - recognitionStatus.value = '开始识别...'; - - // 清空之前的结果 - intermediateResult.value = ''; - finalResult.value = ''; - detectedLanguage.value = ''; - - // 显示正在进行的状态 - recognitionStatus.value = '正在聆听...'; - - // 执行一次性识别 - 这是同步等待结果的操作 - final result = await _asrService.recognizeOnce(); - - if (result.isError) { - recognitionStatus.value = '识别错误: ${result.error}'; - Logger.error('一次性识别错误: ${result.error}'); - } else { - // 更新最终结果 - finalResult.value = result.text; - - // 更新语言信息 - if (result.detectedLanguage.isNotEmpty) { - detectedLanguage.value = result.detectedLanguage; - Logger.debug('检测到语言: ${result.detectedLanguage}'); - } - - recognitionStatus.value = '识别完成'; - } - } catch (e) { - recognitionStatus.value = '识别异常: $e'; - Logger.error('一次性识别异常: ${e.toString()}'); - } finally { - isProcessing.value = false; - } - } - - /// 开始连续识别 - Future startContinuousRecognition() async { - if (!isInitialized.value || isProcessing.value || isListening.value) return; - - try { - isProcessing.value = true; - - // 清空之前的结果 - intermediateResult.value = ''; - finalResult.value = ''; - detectedLanguage.value = ''; - recognitionStatus.value = '准备连续识别...'; - - // 获取识别事件流 - 这启动了异步识别过程 - final stream = - await _asrService.startContinuousRecognition(_audioSourceType); - } catch (e) { - recognitionStatus.value = '启动连续识别异常: $e'; - Logger.error('启动连续识别异常: ${e.toString()}'); - isListening.value = false; - intermediateResult.value = ''; - finalResult.value = ''; - } finally { - isProcessing.value = false; - } - } - - /// 停止连续识别 - Future stopContinuousRecognition() async { - if (!isInitialized.value || !isListening.value) return; - - try { - // 首先更新状态,防止重复调用 - isListening.value = false; - recognitionStatus.value = '正在停止识别...'; - - // 停止识别过程 - await _asrService.stopContinuousRecognition(); - - // 更新UI状态 - recognitionStatus.value = '连续识别已停止'; - intermediateResult.value = ''; - } catch (e) { - recognitionStatus.value = '停止连续识别异常: $e'; - Logger.error('停止连续识别异常: ${e.toString()}'); - } - } - - // 获取识别结果(用于UI显示) - String getDisplayResult() { - // 如果有最终结果,优先显示 - String displayText = ''; - - if (finalResult.value.isNotEmpty) { - displayText = finalResult.value; - } - - // 如果有中间结果,添加到最终结果后(可在UI中使用不同样式显示) - if (intermediateResult.value.isNotEmpty) { - if (displayText.isNotEmpty) { - displayText = '$displayText\n\n正在识别: ${intermediateResult.value}'; - } else { - displayText = '正在识别: ${intermediateResult.value}'; - } - } - - return displayText; - } - - @override - void onClose() { - // 取消所有订阅 - _recognitionSubscription?.cancel(); - _ttsEventSubscription?.cancel(); - - // 释放控制器 - textController.dispose(); - - super.onClose(); - } -} diff --git a/apps/client/lib/modules/speech_test/views/speech_test_view.dart b/apps/client/lib/modules/speech_test/views/speech_test_view.dart deleted file mode 100644 index 62a42e41..00000000 --- a/apps/client/lib/modules/speech_test/views/speech_test_view.dart +++ /dev/null @@ -1,359 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; - -import '../controllers/speech_test_controller.dart'; -import '../../../data/services/speech_factory.dart'; - -class SpeechTestView extends GetView { - const SpeechTestView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('语音测试'), - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => controller.switchServiceType(controller.selectedServiceType.value), - tooltip: '重新初始化', - ), - ], - ), - body: Obx(() { - return controller.isInitialized.value - ? _buildTestContent() - : _buildLoadingView(); - }), - ); - } - - Widget _buildLoadingView() { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('初始化语音服务中...'), - ], - ), - ); - } - - Widget _buildTestContent() { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildServiceTypeSelector(), - const SizedBox(height: 24), - _buildTtsSection(), - const SizedBox(height: 24), - const Divider(), - const SizedBox(height: 24), - _buildAsrSection(), - ], - ), - ); - } - - Widget _buildServiceTypeSelector() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '选择语音服务', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - Obx(() { - return Wrap( - spacing: 8, - children: [ - for (final type in SpeechServiceType.values) - ChoiceChip( - label: Text(_getServiceTypeName(type)), - selected: controller.selectedServiceType.value == type, - onSelected: (selected) { - if (selected) { - controller.switchServiceType(type); - } - }, - ), - ], - ); - }), - ], - ), - ), - ); - } - - String _getServiceTypeName(SpeechServiceType type) { - switch (type) { - case SpeechServiceType.azure: - return 'Azure语音'; - case SpeechServiceType.iflytek: - return '讯飞语音'; - case SpeechServiceType.google: - return 'Google语音'; - case SpeechServiceType.alibaba: - return '阿里语音(旧版)'; - case SpeechServiceType.alibaba35: - return '阿里语音3.5(声音复刻)'; - case SpeechServiceType.volcano: - return '豆包语音'; - case SpeechServiceType.flutter: - return 'Flutter本地'; - } - } - - Widget _buildTtsSection() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '语音合成测试', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - TextField( - controller: controller.textController, - decoration: const InputDecoration( - labelText: '输入要合成的文本', - border: OutlineInputBorder(), - ), - maxLines: 3, - ), - const SizedBox(height: 16), - Obx(() { - return Text( - '状态: ${controller.ttsStatus}', - style: TextStyle( - color: _getStatusColor(controller.ttsStatus.value), - fontWeight: FontWeight.bold, - ), - ); - }), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - Obx(() { - return ElevatedButton.icon( - icon: const Icon(Icons.play_arrow), - label: const Text('单次播放'), - onPressed: controller.isProcessing.value || - controller.isSpeaking.value - ? null - : controller.speakOnce, - ); - }), - Obx(() { - return ElevatedButton.icon( - icon: const Icon(Icons.playlist_play), - label: const Text('连续播放测试'), - onPressed: controller.isProcessing.value || - controller.isSpeaking.value - ? null - : controller.speakContinuously, - ); - }), - Obx(() { - return ElevatedButton.icon( - icon: const Icon(Icons.stop), - label: const Text('停止'), - onPressed: controller.isSpeaking.value - ? controller.stopSpeaking - : null, - ); - }), - ], - ), - const SizedBox(height: 8), - const Divider(), - const SizedBox(height: 8), - const Text( - '连续播放测试会依次播放以下句子:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - for (int i = 0; i < controller.testSentences.length; i++) - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text('${i + 1}. ${controller.testSentences[i]}'), - ), - ], - ), - ), - ); - } - - Widget _buildAsrSection() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '语音识别测试', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - Obx(() { - return Text( - '状态: ${controller.recognitionStatus}', - style: TextStyle( - color: _getStatusColor(controller.recognitionStatus.value), - fontWeight: FontWeight.bold, - ), - ); - }), - const SizedBox(height: 8), - Obx(() { - final hasLanguage = controller.detectedLanguage.value.isNotEmpty; - return Visibility( - visible: hasLanguage, - child: Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - '检测语言: ${controller.detectedLanguage.value}', - style: const TextStyle( - fontStyle: FontStyle.italic, - color: Colors.blue, - ), - ), - ), - ); - }), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - constraints: const BoxConstraints(minHeight: 100), - width: double.infinity, - child: Obx(() { - final displayText = controller.getDisplayResult(); - return RichText( - text: TextSpan( - style: const TextStyle(fontSize: 16, color: Colors.black), - children: [ - if (displayText.isEmpty) - const TextSpan( - text: '识别结果将显示在这里', - style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic), - ) - else if (displayText.contains('正在识别:')) - TextSpan( - text: displayText.split('正在识别:')[0], - children: [ - const TextSpan( - text: '\n\n正在识别: ', - style: TextStyle( - color: Colors.blue, - fontWeight: FontWeight.bold, - fontStyle: FontStyle.italic, - ), - ), - TextSpan( - text: displayText.split('正在识别:')[1].trim(), - style: const TextStyle( - color: Colors.blue, - fontStyle: FontStyle.italic, - ), - ), - ], - ) - else - TextSpan(text: displayText), - ], - ), - ); - }), - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - Obx(() { - return ElevatedButton.icon( - icon: const Icon(Icons.mic), - label: const Text('一次性识别'), - onPressed: controller.isProcessing.value || - controller.isListening.value || - controller.isSpeaking.value - ? null - : controller.recognizeOnce, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - ), - ); - }), - Obx(() { - return ElevatedButton.icon( - icon: Icon( - controller.isListening.value - ? Icons.mic_off - : Icons.mic_none, - ), - label: Text( - controller.isListening.value ? '停止连续识别' : '开始连续识别', - ), - onPressed: controller.isProcessing.value || - controller.isSpeaking.value - ? null - : controller.isListening.value - ? controller.stopContinuousRecognition - : controller.startContinuousRecognition, - style: ElevatedButton.styleFrom( - backgroundColor: controller.isListening.value - ? Colors.red - : Colors.blue, - ), - ); - }), - ], - ), - ], - ), - ), - ); - } - - Color _getStatusColor(String status) { - if (status.contains('错误') || status.contains('失败')) { - return Colors.red; - } else if (status.contains('完成') || status.contains('就绪')) { - return Colors.green; - } else if (status.contains('正在')) { - return Colors.blue; - } else { - return Colors.black; - } - } -} \ No newline at end of file diff --git a/apps/client/lib/modules/timbre/bindings/timbre_binding.dart b/apps/client/lib/modules/timbre/bindings/timbre_binding.dart deleted file mode 100644 index 15a9ecdb..00000000 --- a/apps/client/lib/modules/timbre/bindings/timbre_binding.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/timbre_controller.dart'; - -class TimbreBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => TimbreController()); - } -} diff --git a/apps/client/lib/modules/timbre/controllers/timbre_controller.dart b/apps/client/lib/modules/timbre/controllers/timbre_controller.dart deleted file mode 100644 index c5ebcc4b..00000000 --- a/apps/client/lib/modules/timbre/controllers/timbre_controller.dart +++ /dev/null @@ -1,291 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:crypto/crypto.dart'; -import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; -import 'package:record/record.dart'; - -class TimbreController extends GetxController { - final GetStorage _storage = GetStorage(); - final _record = AudioRecorder(); - - final String _appId = "43d87d22"; - final String _appKey = "2bd01ce06ac35957fc0c082f9b6284ec"; - - RxList dataList = [].obs; - RxString type = '1'.obs; - - RxString text = ''.obs; - - String _token = ''; - - int _textId = 0; - int _segId = 0; - String _taskId = ''; - - @override - void onInit() { - super.onInit(); - _getDataList(); - } - - @override - void onClose() { - _record.dispose(); - super.onClose(); - } - - void _getDataList() { - List? timbreList = _storage.read('timbreList'); - String? timbre = _storage.read('timbre'); - if (timbreList != null) { - dataList.value = timbreList; - } else { - dataList.add({ - 'type': '1', - 'name': 'defaultTimbre', - 'description': 'microsoftCloudTimbreService', - 'isUsable': true - }); - _storage.write('timbreList', [ - { - 'type': '1', - 'name': 'defaultTimbre', - 'description': 'microsoftCloudTimbreService', - 'isUsable': true - } - ]); - } - if (timbre != null) { - type.value = timbre; - } - } - - // 录制声音 - void startRecorder() async { - if (_token.isEmpty) { - await _getToken(); - } - await _getTraintext(); - await _addTask(); - final directory = await getApplicationDocumentsDirectory(); - final recordDir = Directory('${directory.path}/RecordSound'); - if (!await recordDir.exists()) { - await recordDir.create(recursive: true); - } - final path = - '${directory.path}/RecordSound/recording_${DateTime.now().millisecondsSinceEpoch}.wav'; - await _record.start( - const RecordConfig( - encoder: AudioEncoder.wav, - sampleRate: 24000, - numChannels: 1, - ), - path: path, - ); - } - - // 获取token - Future _getToken() async { - int timeStamp = DateTime.now().millisecondsSinceEpoch; - String data = - '{"base":{"appid":"$_appId","version":"v1","timestamp":"$timeStamp"},"model":"remote"}'; - String keySignInput = _appKey + timeStamp.toString(); - List keySignBytes = utf8.encode(keySignInput); - Digest keySignDigest = md5.convert(keySignBytes); - String keySign = keySignDigest.toString(); - String signInput = keySign + data; - List signBytes = utf8.encode(signInput); - Digest signDigest = md5.convert(signBytes); - String sign = signDigest.toString(); - - final response = await http.post( - Uri.parse('http://avatar-hci.xfyousheng.com/aiauth/v1/token'), - headers: { - 'Content-Type': 'application/json', - 'Authorization': sign, - }, - body: data, - ); - if (response.statusCode == 200) { - final body = json.decode(response.body); - if (body['retcode'] == '000000') { - _token = body['accesstoken']; - } - } - } - - // 获取训练文本 - Future _getTraintext() async { - String data = '{"textId":"5001"}'; - Map sign = _sign(data); - - final response = await http.post( - Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/traintext'), - headers: { - 'Content-Type': 'application/json', - 'X-Sign': sign['sign'], - 'X-Token': _token, - 'X-AppId': _appId, - 'X-Time': sign['timeStamp'], - }, - body: data, - ); - if (response.statusCode == 200) { - final body = json.decode(response.body); - if (body['code'] == 0) { - _textId = body['data']['textId']; - Map textSegs = body['data']['textSegs'].first; - _segId = textSegs['segId']; - text.value = _decodeCorruptedText(textSegs['segText']); - } - } - } - - // 创建训练任务 - Future _addTask() async { - String data = '{"resourceType":"12","engineVersion":"omni_v1"}'; - Map sign = _sign(data); - - final response = await http.post( - Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/add'), - headers: { - 'Content-Type': 'application/json', - 'X-Sign': sign['sign'], - 'X-Token': _token, - 'X-AppId': _appId, - 'X-Time': sign['timeStamp'], - }, - body: data, - ); - if (response.statusCode == 200) { - final body = json.decode(response.body); - if (body['code'] == 0) { - _taskId = body['data']; - } - } - } - - Map _sign(String data) { - int timeStamp = DateTime.now().millisecondsSinceEpoch; - String keySignInput = _appKey + timeStamp.toString(); - List keySignBytes = utf8.encode(keySignInput); - Digest keySignDigest = md5.convert(keySignBytes); - String keySign = keySignDigest.toString(); - String signInput = keySign + data; - List signBytes = utf8.encode(signInput); - Digest signDigest = md5.convert(signBytes); - String sign = signDigest.toString(); - return {'sign': sign, 'timeStamp': timeStamp.toString()}; - } - - String _decodeCorruptedText(String corruptedText) { - List latin1Bytes = - corruptedText.codeUnits.map((ch) => ch & 0xFF).toList(); - return utf8.decode(latin1Bytes); - } - - // 完成录制 - void completeRecorder() async { - final audioPath = await _record.stop(); - String data = - '{"taskId":"$_taskId","textId":"$_textId","textSegId":"$_segId"}'; - Map sign = _sign(data); - - final uri = Uri.parse( - 'http://opentrain.xfyousheng.com/voice_train/task/submitWithAudio'); - final request = http.MultipartRequest('POST', uri); - request.files.add(await http.MultipartFile.fromPath( - 'file', - audioPath!, - filename: path.basename(audioPath), - )); - request.fields.addAll({ - 'taskId': _taskId, - 'textId': '$_textId', - 'textSegId': '$_segId', - }); - request.headers.addAll({ - "X-Sign": sign['sign'], - "X-Token": _token, - "X-AppId": _appId, - "X-Time": sign['timeStamp'], - }); - final response = await request.send(); - if (response.statusCode == 200) { - final responseBody = await response.stream.bytesToString(); - final body = json.decode(responseBody); - if (body['code'] == 0) { - text.value = ''; - dataList.add({ - 'type': _taskId, - 'name': '声音复刻${dataList.length}', - 'description': '复刻本人音色服务', - 'isUsable': false - }); - await _getTaskResult(_taskId); - } - } - } - - // 查询训练任务状态 - Future _getTaskResult(String id) async { - String data = '{"taskId":"$id"}'; - Map sign = _sign(data); - - final response = await http.post( - Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/result'), - headers: { - 'Content-Type': 'application/json', - 'X-Sign': sign['sign'], - 'X-Token': _token, - 'X-AppId': _appId, - 'X-Time': sign['timeStamp'], - }, - body: data, - ); - if (response.statusCode == 200) { - final body = json.decode(response.body); - if (body['code'] == 0) { - if (body['data']['trainStatus'] == -1) { - await _getTaskResult(id); - } - if (body['data']['trainStatus'] == 1) { - Map? dataMap = - dataList.firstWhereOrNull((item) => item['type'] == id); - if (dataMap != null) { - dataMap['type'] = body['data']['assetId']; - dataMap['isUsable'] = true; - dataList.refresh(); - _storage.write('timbreList', dataList); - } - } - if (body['data']['trainStatus'] == 0) { - Get.snackbar( - 'error'.tr, // 错误 - _decodeCorruptedText(body['data']['failedDesc']), - snackPosition: SnackPosition.TOP, - ); - } - } - } - } - - // 取消 - void cancel() { - text.value = ''; - _record.stop(); - Get.back(); - } - - // 选择音色 - void selectTone(Map data) { - if (!data['isUsable'] || type.value == data['type']) return; - type.value = data['type']; - _storage.write('timbre', data['type']); - } -} diff --git a/apps/client/lib/modules/timbre/views/timbre_view.dart b/apps/client/lib/modules/timbre/views/timbre_view.dart deleted file mode 100644 index 876d10a9..00000000 --- a/apps/client/lib/modules/timbre/views/timbre_view.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../../../core/theme/app_colors.dart'; -import '../controllers/timbre_controller.dart'; - -class TimbreView extends GetView { - const TimbreView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - appBar: _buildAppBar(isDarkMode), - body: _buildBody(isDarkMode), - ); - } - - // 构建应用栏 - PreferredSizeWidget _buildAppBar(bool isDarkMode) { - return AppBar( - backgroundColor: isDarkMode ? Colors.black : Colors.white, - elevation: 0.5, - leading: IconButton( - icon: Icon( - Icons.arrow_back_ios, - color: isDarkMode ? Colors.white : Colors.black, - size: 20.sp, - ), - onPressed: () => Get.back(), - ), - title: Text( - 'selecttimbre'.tr, // 选择音色 - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - centerTitle: true, - ); - } - - // 构建主体内容 - Widget _buildBody(bool isDarkMode) { - return Column( - children: [ - Expanded( - child: Obx( - () => ListView.builder( - itemCount: controller.dataList.length, - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.w), - itemBuilder: (BuildContext context, int index) { - Map data = controller.dataList[index]; - return _item(isDarkMode, data); - }, - ), - ), - ), - GestureDetector( - onTap: () { - controller.startRecorder(); - Get.dialog( - barrierDismissible: false, - AlertDialog( - backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, - title: Text( - 'pleaseRead'.tr, // 请大声朗读 - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black87, - ), - ), - content: Obx( - () => controller.text.value.isNotEmpty - ? Text( - controller.text.value, - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ) - : Container( - width: 40.w, - height: 40.w, - alignment: Alignment.center, - child: CircularProgressIndicator( - strokeWidth: 2.w, - valueColor: const AlwaysStoppedAnimation( - Colors.blue), - ), - ), - ), - actions: [ - TextButton( - onPressed: () { - controller.cancel(); - }, - child: Text( - 'cancel'.tr, // 取消 - style: TextStyle( - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ), - Obx(() => controller.text.value.isNotEmpty - ? TextButton( - onPressed: () { - Get.back(); - controller.completeRecorder(); - }, - child: Text( - 'completeRecording'.tr, // 完成录音 - style: TextStyle( - color: isDarkMode - ? Colors.red[300] - : Colors.red[600], - ), - ), - ) - : const SizedBox()), - ], - ), - ); - }, - child: Container( - //width: 130.w, - height: 48.w, - margin: EdgeInsets.symmetric(vertical: 20.w), - padding: EdgeInsets.symmetric(horizontal: 14.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.primary : Colors.blue, - borderRadius: BorderRadius.circular(12.w), - ), - child: Row( - mainAxisSize: MainAxisSize.min, // 添加这行使Row宽度自适应内容 - //mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.keyboard_voice, color: Colors.white), - 5.horizontalSpace, - Text( - 'voiceClone'.tr, - style: TextStyle(color: Colors.white, fontSize: 16.sp), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _item(bool isDarkMode, Map data) { - return InkWell( - onTap: () { - controller.selectTone(data); - }, - child: Obx( - () => Container( - margin: EdgeInsets.symmetric(vertical: 6.h), - padding: EdgeInsets.all(12.w), - decoration: BoxDecoration( - color: controller.type.value == data['type'] - ? (isDarkMode - ? Colors.teal[900]!.withOpacity(0.3) - : Colors.teal[50]!) - : (isDarkMode - ? Colors.white.withOpacity(0.05) - : Colors.grey[100]!), - borderRadius: BorderRadius.circular(12.r), - border: Border.all( - color: controller.type.value == data['type'] - ? (isDarkMode ? Colors.teal[300]! : Colors.teal[600]!) - : Colors.transparent, - width: 1, - ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${data['name']}'.tr, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white : Colors.black87, - ), - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 4.h), - Text( - '${data['description']}'.tr, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - overflow: TextOverflow.ellipsis, - maxLines: 2, - ), - ], - ), - ), - data['isUsable'] - ? Container( - width: 24.w, - height: 24.w, - margin: EdgeInsets.only(left: 12.w), - decoration: BoxDecoration( - color: controller.type.value == data['type'] - ? (isDarkMode ? Colors.teal[300] : Colors.teal[600]) - : (isDarkMode - ? Colors.white.withOpacity(0.1) - : Colors.grey[300]), - shape: BoxShape.circle, - ), - child: controller.type.value == data['type'] - ? Icon( - Icons.check, - size: 16.w, - color: Colors.white, - ) - : null, - ) - : SizedBox( - width: 24.w, - height: 24.w, - child: CircularProgressIndicator( - strokeWidth: 2.w, - valueColor: - const AlwaysStoppedAnimation(Colors.blue), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/apps/client/lib/modules/usage/bindings/usage_binding.dart b/apps/client/lib/modules/usage/bindings/usage_binding.dart deleted file mode 100644 index e03af755..00000000 --- a/apps/client/lib/modules/usage/bindings/usage_binding.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/usage_controller.dart'; -import '../controllers/meeting_stats_controller.dart'; -import '../controllers/translation_stats_controller.dart'; -import '../controllers/aichat_stats_controller.dart'; -import '../../../data/services/usage_stats_service.dart'; - -class UsageBinding implements Bindings { - @override - void dependencies() { - if (!Get.isRegistered()) { - Get.put(UsageStatsService(), permanent: true); - } - - Get.lazyPut(() => UsageController()); - - Get.lazyPut(() => AiChatStatsController()); - - Get.lazyPut(() => TranslationStatsController()); - - Get.lazyPut(() => MeetingStatsController()); - } -} diff --git a/apps/client/lib/modules/usage/controllers/aichat_stats_controller.dart b/apps/client/lib/modules/usage/controllers/aichat_stats_controller.dart deleted file mode 100644 index efc285a6..00000000 --- a/apps/client/lib/modules/usage/controllers/aichat_stats_controller.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:get/get.dart'; -import '../../../data/models/user_Info.dart'; - -class AiChatStatsController extends GetxController { - final isLoading = false.obs; - - int get currentIntegral => User.instance.aichatintegral; - int get totalIntegral => User.instance.aichattotalintegral; -} - diff --git a/apps/client/lib/modules/usage/controllers/meeting_stats_controller.dart b/apps/client/lib/modules/usage/controllers/meeting_stats_controller.dart deleted file mode 100644 index 2aad0d20..00000000 --- a/apps/client/lib/modules/usage/controllers/meeting_stats_controller.dart +++ /dev/null @@ -1,226 +0,0 @@ -import 'package:get/get.dart'; -import '../../../data/services/usage_stats_service.dart'; -import '../../../data/models/meeting_usage_stats.dart'; -import '../../../core/utils/logger.dart'; - -class MeetingStatsController extends GetxController { - final UsageStatsService _usageService = Get.find(); - - final isLoading = false.obs; - final selectedTabIndex = 0.obs; - final selectedTimeRange = 'today'.tr.obs; // 今日 - - final Rx todayStats = MeetingAsrHistoryStats( - userId: '', - allAsrCalls: 0, - allAudioSeconds: 0, - allAudioFileBytes: 0, - allTranscriptBytes: 0, - ).obs; - final Rx historyStats = MeetingAsrHistoryStats( - userId: '', - allAsrCalls: 0, - allAudioSeconds: 0, - allAudioFileBytes: 0, - allTranscriptBytes: 0, - ).obs; - - final RxList historyDaily = - [].obs; - - final Rx todayStorageStats = - MeetingStorageHistoryStats( - userId: '', - allStorageUploads: 0, - allStorageBytes: 0, - allTransferBytes: 0, - ).obs; - - final Rx historyStorageStats = - MeetingStorageHistoryStats( - userId: '', - allStorageUploads: 0, - allStorageBytes: 0, - allTransferBytes: 0, - ).obs; - - final Rx rangeStart = Rx(null); - final Rx rangeEnd = Rx(null); - final Rx rangeStats = MeetingAsrHistoryStats( - userId: '', - allAsrCalls: 0, - allAudioSeconds: 0, - allAudioFileBytes: 0, - allTranscriptBytes: 0, - ).obs; - final Rx rangeStorageStats = - MeetingStorageHistoryStats( - userId: '', - allStorageUploads: 0, - allStorageBytes: 0, - allTransferBytes: 0, - ).obs; - - @override - void onInit() { - super.onInit(); - loadAllStats(); - } - - Future loadAllStats() async { - isLoading.value = true; - try { - final now = DateTime.now(); - - final todayList = await _usageService.getMeetingHistoryStats( - startDate: DateTime(now.year, now.month, now.day), - endDate: DateTime(now.year, now.month, now.day), - ); - todayStats.value = _usageService.aggregateMeetingStats(todayList); - - final historyStart = now.subtract(const Duration(days: 7)); - historyDaily.value = await _usageService.getMeetingHistoryStats( - startDate: - DateTime(historyStart.year, historyStart.month, historyStart.day), - endDate: DateTime(now.year, now.month, now.day), - ); - historyStats.value = - _usageService.aggregateMeetingStats(historyDaily.value); - - final todayStorageDaily = - await _usageService.getMeetingStorageHistoryStats( - startDate: DateTime(now.year, now.month, now.day), - endDate: DateTime(now.year, now.month, now.day), - ); - todayStorageStats.value = - _usageService.aggregateMeetingStorageStats(todayStorageDaily); - - final historyStorageDaily = - await _usageService.getMeetingStorageHistoryStats( - startDate: - DateTime(historyStart.year, historyStart.month, historyStart.day), - endDate: DateTime(now.year, now.month, now.day), - ); - historyStorageStats.value = - _usageService.aggregateMeetingStorageStats(historyStorageDaily); - - if (rangeStart.value != null && rangeEnd.value != null) { - final customAsrDaily = await _usageService.getMeetingHistoryStats( - startDate: DateTime(rangeStart.value!.year, rangeStart.value!.month, - rangeStart.value!.day), - endDate: DateTime( - rangeEnd.value!.year, rangeEnd.value!.month, rangeEnd.value!.day), - ); - rangeStats.value = _usageService.aggregateMeetingStats(customAsrDaily); - final customStorageDaily = - await _usageService.getMeetingStorageHistoryStats( - startDate: DateTime(rangeStart.value!.year, rangeStart.value!.month, - rangeStart.value!.day), - endDate: DateTime( - rangeEnd.value!.year, rangeEnd.value!.month, rangeEnd.value!.day), - ); - rangeStorageStats.value = - _usageService.aggregateMeetingStorageStats(customStorageDaily); - } else { - rangeStats.value = historyStats.value; - rangeStorageStats.value = historyStorageStats.value; - } - - Logger.info('会议统计数据加载完成'); - } catch (e) { - Logger.error('加载会议统计数据失败: $e'); - } finally { - isLoading.value = false; - } - } - - void switchTimeRange(int index) { - selectedTabIndex.value = index; - switch (index) { - case 0: - selectedTimeRange.value = 'today'.tr; // 今日 - break; - case 1: - selectedTimeRange.value = 'history'.tr; // 历史 - break; - default: - selectedTimeRange.value = 'today'.tr; // 今日 - break; - } - } - - MeetingAsrHistoryStats get currentStats { - switch (selectedTabIndex.value) { - case 0: - return todayStats.value; - case 1: - return rangeStats.value; - default: - return todayStats.value; - } - } - - String formatDuration(int seconds) { - if (seconds < 60) { - return '$seconds${'seconds'.tr}'; // 秒 - } else if (seconds < 3600) { - final minutes = seconds ~/ 60; - final remainingSeconds = seconds % 60; - return '$minutes${'minutes'.tr}$remainingSeconds${'seconds'.tr}'; // 分钟秒 - } else { - final hours = seconds ~/ 3600; - final minutes = (seconds % 3600) ~/ 60; - return '$hours${'hours'.tr}$minutes${'minutes'.tr}'; // 小时分钟 - } - } - - String formatBytes(int bytes) { - if (bytes < 1024) return '$bytes B'; - final kb = bytes / 1024; - if (kb < 1024) return '${kb.toStringAsFixed(1)} KB'; - final mb = kb / 1024; - if (mb < 1024) return '${mb.toStringAsFixed(1)} MB'; - final gb = mb / 1024; - return '${gb.toStringAsFixed(1)} GB'; - } - - String formatNumber(int number) { - if (number < 1000) return number.toString(); - if (number < 1000000) return '${(number / 1000).toStringAsFixed(1)}K'; - return '${(number / 1000000).toStringAsFixed(1)}M'; - } - - Future refreshData() async { - await loadAllStats(); - } - - Future setCustomRangeDates(DateTime start, DateTime end) async { - rangeStart.value = start; - rangeEnd.value = end; - isLoading.value = true; - try { - final asrDaily = await _usageService.getMeetingHistoryStats( - startDate: DateTime(start.year, start.month, start.day), - endDate: DateTime(end.year, end.month, end.day), - ); - rangeStats.value = _usageService.aggregateMeetingStats(asrDaily); - final storageDaily = await _usageService.getMeetingStorageHistoryStats( - startDate: DateTime(start.year, start.month, start.day), - endDate: DateTime(end.year, end.month, end.day), - ); - rangeStorageStats.value = - _usageService.aggregateMeetingStorageStats(storageDaily); - } catch (e) { - Logger.error('设置自定义日期范围失败: $e'); - } finally { - isLoading.value = false; - } - } - - String formatDate(DateTime d) { - final y = d.year.toString(); - final m = d.month.toString().padLeft(2, '0'); - final day = d.day.toString().padLeft(2, '0'); - return '$y-$m-$day'; - } -} diff --git a/apps/client/lib/modules/usage/controllers/translation_cost_controller.dart b/apps/client/lib/modules/usage/controllers/translation_cost_controller.dart deleted file mode 100644 index 934a709e..00000000 --- a/apps/client/lib/modules/usage/controllers/translation_cost_controller.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:get/get.dart'; -import 'package:flutter/material.dart'; -import '../../../data/services/usage_stats_service.dart'; -import '../../../core/utils/logger.dart'; - -class TranslationCostController extends GetxController { - final UsageStatsService _usageService = Get.find(); - final RxMap costConfig = {}.obs; - final RxDouble dailyCostThreshold = 10.0.obs; - final RxDouble monthlyCostThreshold = 300.0.obs; - final translationCostController = TextEditingController(); - final asrCostController = TextEditingController(); - final ttsCostController = TextEditingController(); - final aiSummaryCostController = TextEditingController(); - final dailyThresholdController = TextEditingController(); - final monthlyThresholdController = TextEditingController(); - final isLoading = false.obs; - final isSaving = false.obs; - - @override - void onInit() { - super.onInit(); - } - - @override - void onClose() { - translationCostController.dispose(); - asrCostController.dispose(); - ttsCostController.dispose(); - aiSummaryCostController.dispose(); - dailyThresholdController.dispose(); - monthlyThresholdController.dispose(); - super.onClose(); - } - - void _loadCurrentConfig() {} - - Future saveConfig() async {} - - void resetToDefault() {} - - bool _validateInputs() { - try { - final translationCost = double.parse(translationCostController.text); - final asrCost = double.parse(asrCostController.text); - final ttsCost = double.parse(ttsCostController.text); - final aiSummaryCost = double.parse(aiSummaryCostController.text); - final dailyThreshold = double.parse(dailyThresholdController.text); - final monthlyThreshold = double.parse(monthlyThresholdController.text); - if (translationCost < 0 || - asrCost < 0 || - ttsCost < 0 || - aiSummaryCost < 0) { - _showErrorSnackbar('成本不能为负数'); - return false; - } - if (dailyThreshold < 0 || monthlyThreshold < 0) { - _showErrorSnackbar('预警阈值不能为负数'); - return false; - } - if (dailyThreshold > monthlyThreshold) { - _showErrorSnackbar('日预警阈值不能大于月预警阈值'); - return false; - } - return true; - } catch (e) { - _showErrorSnackbar('请输入有效的数字'); - return false; - } - } - - double calculateEstimatedMonthlyCost() { - return 0.0; - } - - List getCostSavingTips() { - return []; - } - - void _showSuccessSnackbar(String message) { - Get.snackbar( - '成功', - message, - snackPosition: SnackPosition.TOP, - backgroundColor: Get.theme.colorScheme.primary.withValues(alpha: 0.1), - colorText: Get.theme.colorScheme.primary, - duration: const Duration(seconds: 3), - ); - } - - void _showErrorSnackbar(String message) { - Get.snackbar( - '错误', - message, - snackPosition: SnackPosition.TOP, - backgroundColor: Get.theme.colorScheme.error.withValues(alpha: 0.1), - colorText: Get.theme.colorScheme.error, - duration: const Duration(seconds: 3), - ); - } - - void _showInfoSnackbar(String message) { - Get.snackbar( - '提示', - message, - snackPosition: SnackPosition.TOP, - backgroundColor: Get.theme.colorScheme.surface.withValues(alpha: 0.9), - colorText: Get.theme.colorScheme.onSurface, - duration: const Duration(seconds: 3), - ); - } - - String formatCost(double cost) { - return '¥${cost.toStringAsFixed(2)}'; - } - - String formatPercentage(double percentage) { - return '${percentage.toStringAsFixed(1)}%'; - } -} diff --git a/apps/client/lib/modules/usage/controllers/translation_stats_controller.dart b/apps/client/lib/modules/usage/controllers/translation_stats_controller.dart deleted file mode 100644 index 7a326613..00000000 --- a/apps/client/lib/modules/usage/controllers/translation_stats_controller.dart +++ /dev/null @@ -1,328 +0,0 @@ -import 'package:get/get.dart'; -import '../../../data/services/usage_stats_service.dart'; -import '../../../data/models/translation_usage_stats.dart'; -import '../../../core/utils/logger.dart'; -import 'usage_controller.dart'; - -class TranslationStatsController extends GetxController { - final UsageStatsService _usageService = Get.find(); - - final isLoading = false.obs; - final selectedTabIndex = 0.obs; - final selectedTimeRange = 'today'.tr.obs; // 今日 - - final Rx todayStats = - TranslationUsageStats.empty('').obs; - final Rx weekStats = - TranslationUsageStats.empty('').obs; - final Rx monthStats = - TranslationUsageStats.empty('').obs; - final RxList historyStats = - [].obs; - - RealtimeUsageStats get realtimeStats => - _usageService.realtimeTranslationStats.value; - - final RxMap costConfig = {}.obs; - final RxDouble dailyCostThreshold = 10.0.obs; - final RxDouble monthlyCostThreshold = 300.0.obs; - - final RxList> chartData = >[].obs; - - @override - void onInit() { - super.onInit(); - _initializeData(); - } - - void _initializeData() { - ever(_usageService.todayTranslationStats, (stats) { - todayStats.value = stats; - _updateChartData(); - }); - - loadAllStats(); - } - - Future loadAllStats() async { - isLoading.value = true; - - try { - todayStats.value = _usageService.todayTranslationStats.value; - - await _loadWeekStats(); - - await _loadMonthStats(); - - await _loadHistoryStats(); - - _updateChartData(); - - Logger.info('统计数据加载完成'); - } catch (e) { - Logger.error('加载统计数据失败: $e'); - Get.snackbar( - 'loadFailed'.tr, // 加载失败 - 'statsLoadFailedTryLater'.tr, // 统计数据加载失败,请稍后重试 - snackPosition: SnackPosition.TOP, - backgroundColor: Get.theme.colorScheme.error.withValues(alpha: 0.1), - colorText: Get.theme.colorScheme.error, - ); - } finally { - isLoading.value = false; - } - } - - Future _loadWeekStats() async { - try { - final now = DateTime.now(); - final weekStart = now.subtract(Duration(days: now.weekday - 1)); - final weekEnd = weekStart.add(const Duration(days: 6)); - - final dailyStats = await _usageService.getHistoryStats( - startDate: weekStart, - endDate: weekEnd, - ); - - weekStats.value = _aggregateStats(dailyStats, 'week'); - } catch (e) { - Logger.error('加载本周统计失败: $e'); - } - } - - Future _loadMonthStats() async { - try { - final now = DateTime.now(); - monthStats.value = - await _usageService.getMonthlyStats(now.year, now.month); - } catch (e) { - Logger.error('加载本月统计失败: $e'); - } - } - - Future _loadHistoryStats() async { - try { - final endDate = DateTime.now(); - final startDate = endDate.subtract(const Duration(days: 7)); - - historyStats.value = await _usageService.getHistoryStats( - startDate: startDate, - endDate: endDate, - ); - - historyStats.sort((a, b) => b.date.compareTo(a.date)); - } catch (e) { - Logger.error('加载历史统计失败: $e'); - } - } - - TranslationUsageStats _aggregateStats( - List statsList, String period) { - if (statsList.isEmpty) { - return TranslationUsageStats.empty(_usageService.getCurrentUserId()); - } - - int totalTranslationCalls = 0; - int totalAsrCalls = 0; - int totalTtsCalls = 0; - int totalAiSummaryCalls = 0; - int totalSourceChars = 0; - int totalTargetChars = 0; - int totalRecordingDuration = 0; - int totalTtsPlayDuration = 0; - int totalSessions = 0; - - final Map combinedLanguagePairUsage = {}; - final Map combinedModeUsage = {}; - - for (final stats in statsList) { - totalTranslationCalls += stats.translationApiCalls; - totalAsrCalls += stats.asrApiCalls; - totalTtsCalls += stats.ttsApiCalls; - totalAiSummaryCalls += stats.aiSummaryApiCalls; - totalSourceChars += stats.sourceTextCharacters; - totalTargetChars += stats.ttsTextCharacters; - totalRecordingDuration += stats.effectiveAudioDuration; - totalTtsPlayDuration += stats.totalSessionDuration; - totalSessions += stats.sessionCount; - - stats.languagePairUsage.forEach((key, value) { - combinedLanguagePairUsage[key] = - (combinedLanguagePairUsage[key] ?? 0) + value; - }); - - stats.modeUsage.forEach((key, value) { - combinedModeUsage[key] = (combinedModeUsage[key] ?? 0) + value; - }); - } - - return TranslationUsageStats( - id: '${period}_${DateTime.now().millisecondsSinceEpoch}', - date: DateTime.now(), - userId: 'current_user', - timeGranularity: period, - translationApiCalls: totalTranslationCalls, - asrApiCalls: totalAsrCalls, - ttsApiCalls: totalTtsCalls, - aiSummaryApiCalls: totalAiSummaryCalls, - sourceTextCharacters: totalSourceChars, - ttsTextCharacters: totalTargetChars, - effectiveAudioDuration: totalRecordingDuration, - totalSessionDuration: totalTtsPlayDuration, - sessionCount: totalSessions, - estimatedCost: 0.0, - ); - } - - void _updateChartData() { - final List> data = []; - - switch (selectedTabIndex.value) { - case 0: - data.add({ - 'label': 'today'.tr, // 今日 - 'apiCalls': todayStats.value.totalApiCalls, - 'cost': 0.0, - 'characters': todayStats.value.totalCharacterCount, - }); - break; - case 1: - data.add({ - 'label': 'thisWeek'.tr, // 本周 - 'apiCalls': weekStats.value.totalApiCalls, - 'cost': 0.0, - 'characters': weekStats.value.totalCharacterCount, - }); - break; - case 2: - data.add({ - 'label': 'thisMonth'.tr, // 本月 - 'apiCalls': monthStats.value.totalApiCalls, - 'cost': 0.0, - 'characters': monthStats.value.totalCharacterCount, - }); - break; - case 3: - for (final stats in historyStats.take(7)) { - data.add({ - 'label': '${stats.date.month}/${stats.date.day}', - 'apiCalls': stats.totalApiCalls, - 'cost': 0.0, - 'characters': stats.totalCharacterCount, - }); - } - break; - } - - chartData.value = data; - } - - void switchTimeRange(int index) { - selectedTabIndex.value = index; - - switch (index) { - case 0: - selectedTimeRange.value = 'today'.tr; // 今日 - break; - case 1: - selectedTimeRange.value = 'thisWeek'.tr; // 本周 - break; - case 2: - selectedTimeRange.value = 'thisMonth'.tr; // 本月 - break; - case 3: - selectedTimeRange.value = 'history'.tr; // 历史 - break; - } - - _updateChartData(); - } - - TranslationUsageStats get currentStats { - switch (selectedTabIndex.value) { - case 0: - return todayStats.value; - case 1: - return weekStats.value; - case 2: - return monthStats.value; - default: - return todayStats.value; - } - } - - Future refreshData() async { - await loadAllStats(); - } - - void cleanupOldData() { - _usageService.cleanupOldData(); - Get.snackbar( - 'cleanupComplete'.tr, // 清理完成 - 'cleanedOldData'.tr, // 已清理90天前的过期数据 - snackPosition: SnackPosition.TOP, - backgroundColor: Get.theme.colorScheme.primary.withValues(alpha: 0.1), - colorText: Get.theme.colorScheme.primary, - ); - } - - Future exportStats() async { - try { - Get.snackbar( - 'exportFeature'.tr, // 导出功能 - 'exportInDevelopment'.tr, // 数据导出功能正在开发中 - snackPosition: SnackPosition.TOP, - backgroundColor: Get.theme.colorScheme.primary.withValues(alpha: 0.1), - colorText: Get.theme.colorScheme.primary, - ); - } catch (e) { - Logger.error('导出统计数据失败: $e'); - } - } - - String formatDuration(int seconds) { - if (seconds < 60) { - return '$seconds${'seconds'.tr}'; // 秒 - } else if (seconds < 3600) { - final minutes = seconds ~/ 60; - final remainingSeconds = seconds % 60; - return '$minutes${'minutes'.tr}$remainingSeconds${'seconds'.tr}'; // 分钟秒 - } else { - final hours = seconds ~/ 3600; - final minutes = (seconds % 3600) ~/ 60; - return '$hours${'hours'.tr}$minutes${'minutes'.tr}'; // 小时分钟 - } - } - - String formatCost(double cost) { - return '¥${cost.toStringAsFixed(2)}'; - } - - int _titleTapCount = 0; - int _lastTitleTapTs = 0; - - void onTitleTap() { - final now = DateTime.now().millisecondsSinceEpoch; - if (now - _lastTitleTapTs <= 800) { - _titleTapCount++; - } else { - _titleTapCount = 1; - } - _lastTitleTapTs = now; - if (_titleTapCount >= 3) { - _titleTapCount = 0; - final uc = Get.find(); - uc.toggleAiChat(!uc.showAiChat.value); - } - } - - String formatNumber(int number) { - if (number < 1000) { - return number.toString(); - } else if (number < 1000000) { - return '${(number / 1000).toStringAsFixed(1)}K'; - } else { - return '${(number / 1000000).toStringAsFixed(1)}M'; - } - } -} diff --git a/apps/client/lib/modules/usage/controllers/usage_controller.dart b/apps/client/lib/modules/usage/controllers/usage_controller.dart deleted file mode 100644 index 7407d5e0..00000000 --- a/apps/client/lib/modules/usage/controllers/usage_controller.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import '../views/translation_stats_view.dart'; -import '../views/meeting_stats_view.dart'; -import '../views/aichat_stats_view.dart'; - -class UsageController extends GetxController { - late PageController pageController; - final RxInt pageIndex = 0.obs; - - final RxBool showAiChat = false.obs; - - List get pageList => showAiChat.value - ? const [ - AiChatStatsView(), - TranslationStatsView(), - MeetingStatsView(), - ] - : const [ - TranslationStatsView(), - MeetingStatsView(), - ]; - - @override - void onInit() { - super.onInit(); - pageController = PageController(initialPage: 0); - } - - void toggleAiChat(bool enable) { - if (showAiChat.value == enable) return; - showAiChat.value = enable; - final int maxIndex = pageList.length - 1; - if (pageIndex.value > maxIndex) { - pageIndex.value = maxIndex; - pageController.jumpToPage(maxIndex); - } else { - pageController.jumpToPage(pageIndex.value); - } - } -} diff --git a/apps/client/lib/modules/usage/views/aichat_stats_view.dart b/apps/client/lib/modules/usage/views/aichat_stats_view.dart deleted file mode 100644 index 728bc180..00000000 --- a/apps/client/lib/modules/usage/views/aichat_stats_view.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../../../core/theme/app_colors.dart'; -import '../controllers/aichat_stats_controller.dart'; -import '../../../data/models/user_Info.dart'; - -class AiChatStatsView extends GetView { - const AiChatStatsView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.grey[50], - appBar: _buildAppBar(isDarkMode), - body: _buildBody(isDarkMode), - ); - } - - PreferredSizeWidget _buildAppBar(bool isDarkMode) { - return AppBar( - leading: IconButton( - icon: Icon(Icons.arrow_back_ios, - color: isDarkMode ? Colors.white : Colors.black, size: 20.sp), - onPressed: () => Get.back(), - ), - backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white, - elevation: 0.5, - title: Text( - 'aiChat'.tr, // AI聊天 - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black), - ), - centerTitle: true, - ); - } - - Widget _buildBody(bool isDarkMode) { - return Column( - children: [_buildIntegralRow(isDarkMode)], - ); - } - - Widget _buildIntegralRow(bool isDarkMode) { - return Obx(() { - final _ = User.refreshTick.value; - final u = User.instance; - return Padding( - padding: EdgeInsets.all(16.w), - child: Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2)), - ], - ), - child: Row( - children: [ - Icon(Icons.chat_bubble_outline, - color: AppColors.primary, size: 20.sp), - SizedBox(width: 8.w), - Expanded( - child: Text( - 'remainingQuota'.tr, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black), - ), - ), - Text( - '${u.aichatintegral}/${u.aichattotalintegral}', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black), - ), - ], - ), - ), - ); - }); - } -} diff --git a/apps/client/lib/modules/usage/views/meeting_stats_view.dart b/apps/client/lib/modules/usage/views/meeting_stats_view.dart deleted file mode 100644 index 0702a4aa..00000000 --- a/apps/client/lib/modules/usage/views/meeting_stats_view.dart +++ /dev/null @@ -1,266 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../../../core/theme/app_colors.dart'; -import '../controllers/meeting_stats_controller.dart'; -import '../../../data/models/user_Info.dart'; -import '../../../data/models/meeting_usage_stats.dart'; - -class MeetingStatsView extends GetView { - const MeetingStatsView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.grey[50], - appBar: _buildAppBar(context, isDarkMode), - body: Obx(() => controller.isLoading.value - ? const Center(child: CircularProgressIndicator()) - : _buildBody(isDarkMode)), - ); - } - - PreferredSizeWidget _buildAppBar(BuildContext context, bool isDarkMode) { - return AppBar( - leading: IconButton( - icon: Icon(Icons.arrow_back_ios, - color: isDarkMode ? Colors.white : Colors.black, size: 20.sp), - onPressed: () => Get.back(), - ), - backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white, - elevation: 0.5, - title: Text( - 'meetingStats'.tr, // 会议统计 - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black), - ), - centerTitle: true, - actions: [ - IconButton( - icon: Icon(Icons.date_range, - color: isDarkMode ? Colors.white : Colors.black), - onPressed: () async { - final now = DateTime.now(); - final initialStart = controller.rangeStart.value ?? - DateTime(now.year, now.month, now.day); - final initialEnd = controller.rangeEnd.value ?? - DateTime(now.year, now.month, now.day); - final picked = await showDateRangePicker( - context: context, - firstDate: DateTime(now.year - 1), - lastDate: DateTime(now.year + 1), - initialDateRange: - DateTimeRange(start: initialStart, end: initialEnd), - ); - if (picked != null) { - await controller.setCustomRangeDates(picked.start, picked.end); - } - }, - ), - IconButton( - icon: Icon(Icons.refresh, - color: isDarkMode ? Colors.white : Colors.black), - onPressed: controller.refreshData, - ), - ], - ); - } - - Widget _buildBody(bool isDarkMode) { - final hasRange = controller.rangeStart.value != null && - controller.rangeEnd.value != null; - final historyTitle = hasRange - ? "${'history'.tr} (${controller.formatDate(controller.rangeStart.value!)} ~ ${controller.formatDate(controller.rangeEnd.value!)})" - : 'history'.tr; - return Column( - children: [ - _buildIntegralRow(isDarkMode), - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.all(16.w), - child: Column( - children: [ - _buildOverviewCard( - 'today'.tr, // 今日 - isDarkMode, - controller.todayStats.value, - controller.todayStorageStats.value, - ), - SizedBox(height: 16.h), - _buildOverviewCard( - historyTitle, - isDarkMode, - controller.rangeStats.value, - controller.rangeStorageStats.value, - ), - ], - ), - ), - ), - ], - ); - } - - Widget _buildIntegralRow(bool isDarkMode) { - return Obx(() { - final _ = User.refreshTick.value; - final u = User.instance; - return Padding( - padding: EdgeInsets.all(16.w), - child: Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2)), - ], - ), - child: Row( - children: [ - Icon(Icons.people, color: AppColors.primary, size: 20.sp), - SizedBox(width: 8.w), - Expanded( - child: Text( - 'remainingQuota'.tr, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black), - ), - ), - Text( - '${u.meetintegral}/${u.meettotalintegral}', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black), - ), - ], - ), - ), - ); - }); - } - - Widget _buildOverviewCard( - String title, - bool isDarkMode, - MeetingAsrHistoryStats asrStats, - MeetingStorageHistoryStats storageStats, - ) { - return Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2)), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.dashboard, color: AppColors.primary, size: 20.sp), - SizedBox(width: 8.w), - Text( - '$title${'overview'.tr}', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black), - ), - ], - ), - SizedBox(height: 16.h), - Row( - children: [ - Expanded( - child: _buildStatItem( - 'asrApiCalls'.tr, // ASR调用次数 - controller.formatNumber(asrStats.allAsrCalls), - Icons.api, - isDarkMode)), - Expanded( - child: _buildStatItem( - 'totalAudioDuration'.tr, // 音频总时长 - controller.formatDuration(asrStats.allAudioSeconds), - Icons.mic, - isDarkMode)), - ], - ), - SizedBox(height: 12.h), - Row( - children: [ - Expanded( - child: _buildStatItem( - 'audioFileSize'.tr, // 音频文件大小 - controller.formatBytes(asrStats.allAudioFileBytes), - Icons.folder, - isDarkMode)), - Expanded( - child: _buildStatItem( - 'transcriptBytes'.tr, // 转写字节 - controller.formatNumber(asrStats.allTranscriptBytes), - Icons.text_snippet, - isDarkMode)), - ], - ), - SizedBox(height: 12.h), - Row( - children: [ - Expanded( - child: _buildStatItem( - 'storageUploads'.tr, // 对象存储上传次数 - controller.formatNumber(storageStats.allStorageUploads), - Icons.cloud_upload, - isDarkMode)), - Expanded( - child: _buildStatItem( - 'storageBytes'.tr, // 对象存储总大小 - controller.formatBytes(storageStats.allStorageBytes), - Icons.cloud, - isDarkMode)), - ], - ), - ], - ), - ); - } - - Widget _buildStatItem( - String label, String value, IconData icon, bool isDarkMode) { - return Column( - children: [ - Icon(icon, color: AppColors.primary, size: 24.sp), - SizedBox(height: 8.h), - Text( - value, - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black), - ), - SizedBox(height: 4.h), - Text( - label, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white70 : Colors.black54), - ), - ], - ); - } -} diff --git a/apps/client/lib/modules/usage/views/translation_stats_view.dart b/apps/client/lib/modules/usage/views/translation_stats_view.dart deleted file mode 100644 index 3600000b..00000000 --- a/apps/client/lib/modules/usage/views/translation_stats_view.dart +++ /dev/null @@ -1,807 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../controllers/translation_stats_controller.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../data/models/user_Info.dart'; - -class TranslationStatsView extends GetView { - const TranslationStatsView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.grey[50], - appBar: _buildAppBar(isDarkMode), - body: Obx(() => controller.isLoading.value - ? _buildLoadingWidget() - : _buildBody(isDarkMode)), - ); - } - - PreferredSizeWidget _buildAppBar(bool isDarkMode) { - return AppBar( - leading: IconButton( - icon: Icon( - Icons.arrow_back_ios, - color: isDarkMode ? Colors.white : Colors.black, - size: 20.sp, - ), - onPressed: () => Get.back(), - ), - backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white, - elevation: 0.5, - title: GestureDetector( - onTap: controller.onTitleTap, - child: Text( - 'translationStats'.tr, // - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - centerTitle: true, - actions: [ - IconButton( - icon: Icon( - Icons.refresh, - color: isDarkMode ? Colors.white : Colors.black, - ), - onPressed: controller.refreshData, - ), - // PopupMenuButton( - // icon: Icon( - // Icons.more_vert, - // color: isDarkMode ? Colors.white : Colors.black, - // ), - // onSelected: (value) { - // switch (value) { - // case 'cleanup': - // controller.cleanupOldData(); - // break; - // // case 'export': - // // controller.exportStats(); - // // break; - // } - // }, - // itemBuilder: (context) => const [ - // PopupMenuItem( - // value: 'cleanup', - // child: Text('清理过期数据'), - // ), - // // PopupMenuItem( - // // value: 'export', - // // child: Text('导出数据'), - // // ), - // ], - // ), - ], - ); - } - - Widget _buildLoadingWidget() { - return const Center( - child: CircularProgressIndicator(), - ); - } - - Widget _buildBody(bool isDarkMode) { - return Column( - children: [ - _buildIntegralRow(isDarkMode), - _buildTimeRangeSelector(isDarkMode), - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.all(16.w), - child: Column( - children: [ - // _buildIntegralCard(isDarkMode), - // SizedBox(height: 16.h), - if (controller.selectedTabIndex.value == 0) - _buildRealtimeStatsCard(isDarkMode), - SizedBox(height: 16.h), - _buildOverviewCard(isDarkMode), - SizedBox(height: 16.h), - _buildApiCallsCard(isDarkMode), - SizedBox(height: 16.h), - _buildDataVolumeCard(isDarkMode), - SizedBox(height: 16.h), - _buildUsageBehaviorCard(isDarkMode), - ], - ), - ), - ), - ], - ); - } - - Widget _buildIntegralRow(bool isDarkMode) { - return Obx(() { - final _ = User.refreshTick.value; - final u = User.instance; - return Padding( - padding: EdgeInsets.all(16.w), - child: Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2)), - ], - ), - child: Row( - children: [ - Icon(Icons.translate, color: AppColors.primary, size: 20.sp), - SizedBox(width: 8.w), - Expanded( - child: Text( - 'remainingQuota'.tr, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black), - ), - ), - Text( - '${u.tradeintegral}/${u.tradetotalintegral}', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black), - ), - ], - ), - ), - ); - }); - } - - Widget _buildTimeRangeSelector(bool isDarkMode) { - return Container( - margin: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Obx(() => Row( - children: [ - _buildTimeRangeTab('today'.tr, 0, isDarkMode), // 今日 - _buildTimeRangeTab('thisWeek'.tr, 1, isDarkMode), // 本周 - _buildTimeRangeTab('thisMonth'.tr, 2, isDarkMode), // 本月 - _buildTimeRangeTab('history'.tr, 3, isDarkMode), // 历史 - ], - )), - ); - } - - Widget _buildTimeRangeTab(String title, int index, bool isDarkMode) { - final isSelected = controller.selectedTabIndex.value == index; - - return Expanded( - child: GestureDetector( - onTap: () => controller.switchTimeRange(index), - child: Container( - padding: EdgeInsets.symmetric(vertical: 12.h), - decoration: BoxDecoration( - color: isSelected - ? (isDarkMode ? AppColors.primary : AppColors.primary) - : Colors.transparent, - borderRadius: BorderRadius.circular(12.r), - ), - child: Text( - title, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14.sp, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected - ? Colors.white - : (isDarkMode ? Colors.white70 : Colors.black54), - ), - ), - ), - ), - ); - } - - Widget _buildRealtimeStatsCard(bool isDarkMode) { - return Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.access_time, - color: AppColors.primary, - size: 20.sp, - ), - SizedBox(width: 8.w), - Text( - 'currentSessionStats'.tr, // 当前会话统计 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ], - ), - SizedBox(height: 16.h), - Obx(() => Row( - children: [ - Expanded( - child: _buildStatItem( - 'translationCount'.tr, // 翻译次数 - controller.realtimeStats.translationCount.toString(), - Icons.translate, - isDarkMode, - ), - ), - Expanded( - child: _buildStatItem( - 'sessionDuration'.tr, // 会话时长 - '${controller.realtimeStats.sessionDurationMinutes}${'minutes'.tr}', - Icons.timer, - isDarkMode, - ), - ), - ], - )), - SizedBox(height: 12.h), - Obx(() => Row( - children: [ - Expanded( - child: _buildStatItem( - 'characters'.tr, // 字符数 - controller.formatNumber( - controller.realtimeStats.sourceCharacters + - controller.realtimeStats.targetCharacters, - ), - Icons.text_fields, - isDarkMode, - ), - ), - Expanded( - child: _buildStatItem( - 'recordingDuration'.tr, // 录音时长 - controller.formatDuration( - controller.realtimeStats.recordingDuration), - Icons.mic, - isDarkMode, - ), - ), - ], - )), - ], - ), - ); - } - - Widget _buildOverviewCard(bool isDarkMode) { - return Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.dashboard, - color: AppColors.primary, - size: 20.sp, - ), - SizedBox(width: 8.w), - Obx(() => Text( - '${controller.selectedTimeRange.value}${'overview'.tr}', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - )), - ], - ), - SizedBox(height: 16.h), - Obx(() => Row( - children: [ - Expanded( - child: _buildStatItem( - 'totalApiCalls'.tr, // 总调用次数 - controller - .formatNumber(controller.currentStats.totalApiCalls), - Icons.api, - isDarkMode, - ), - ), - ], - )), - SizedBox(height: 12.h), - Obx(() => Row( - children: [ - Expanded( - child: _buildStatItem( - 'sessionCount'.tr, // 会话数量 - controller.currentStats.sessionCount.toString(), - Icons.chat, - isDarkMode, - ), - ), - Expanded( - child: _buildStatItem( - 'totalCharacters'.tr, // 总字符数 - controller.formatNumber( - controller.currentStats.totalCharacterCount), - Icons.text_snippet, - isDarkMode, - ), - ), - ], - )), - ], - ), - ); - } - - Widget _buildStatItem( - String label, String value, IconData icon, bool isDarkMode) { - return Column( - children: [ - Icon( - icon, - color: AppColors.primary, - size: 24.sp, - ), - SizedBox(height: 8.h), - Text( - value, - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - SizedBox(height: 4.h), - Text( - label, - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ], - ); - } - - Widget _buildApiCallsCard(bool isDarkMode) { - return Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.api, - color: AppColors.primary, - size: 20.sp, - ), - SizedBox(width: 8.w), - Text( - 'apiCallStats'.tr, // API调用统计 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ], - ), - SizedBox(height: 16.h), - Obx(() => Column( - children: [ - _buildApiCallRow( - 'translationApi'.tr, // 翻译API - controller.currentStats.translationApiCalls, - Icons.translate, - isDarkMode), - SizedBox(height: 12.h), - _buildApiCallRow( - 'asr'.tr, - controller.currentStats.asrApiCalls, - Icons.mic, - isDarkMode), - SizedBox(height: 12.h), - _buildApiCallRow( - 'tts'.tr, - controller.currentStats.ttsApiCalls, - Icons.volume_up, - isDarkMode), - SizedBox(height: 12.h), - _buildApiCallRow( - 'aiSummary'.tr, // AI总结 - controller.currentStats.aiSummaryApiCalls, - Icons.summarize, - isDarkMode), - ], - )), - ], - ), - ); - } - - Widget _buildApiCallRow( - String label, int count, IconData icon, bool isDarkMode) { - return Row( - children: [ - Icon( - icon, - color: AppColors.primary, - size: 18.sp, - ), - SizedBox(width: 12.w), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - Text( - controller.formatNumber(count), - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ], - ); - } - - Widget _buildDataVolumeCard(bool isDarkMode) { - return Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.data_usage, - color: AppColors.primary, - size: 20.sp, - ), - SizedBox(width: 8.w), - Text( - 'dataVolumeStats'.tr, // 数据量统计 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ], - ), - SizedBox(height: 16.h), - Obx(() => Column( - children: [ - Row( - children: [ - Expanded( - child: _buildStatItem( - 'sourceTextCharacters'.tr, // 源文本字符 - controller.formatNumber( - controller.currentStats.sourceTextCharacters), - Icons.text_fields, - isDarkMode, - ), - ), - Expanded( - child: _buildStatItem( - 'ttsTextCharacters'.tr, // TTS文本字符 - controller.formatNumber( - controller.currentStats.ttsTextCharacters), - Icons.translate, - isDarkMode, - ), - ), - ], - ), - SizedBox(height: 16.h), - Row( - children: [ - Expanded( - child: _buildStatItem( - 'effectiveAudioDuration'.tr, // 有效音频时长 - controller.formatDuration( - controller.currentStats.effectiveAudioDuration), - Icons.mic, - isDarkMode, - ), - ), - Expanded( - child: _buildStatItem( - 'totalSessionDuration'.tr, // 总会话时长 - controller.formatDuration( - controller.currentStats.totalSessionDuration), - Icons.volume_up, - isDarkMode, - ), - ), - ], - ), - ], - )), - ], - ), - ); - } - - Widget _buildCostCard(bool isDarkMode) { - return Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.attach_money, - color: AppColors.primary, - size: 20.sp, - ), - SizedBox(width: 8.w), - Text( - 'costStats'.tr, // 成本统计 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ], - ), - SizedBox(height: 16.h), - Obx(() => Column( - children: [ - Container( - padding: EdgeInsets.all(12.w), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8.r), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${controller.selectedTimeRange.value}${'estimatedCost'.tr}', - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - Text( - controller.formatCost( - controller.currentStats.estimatedCost), - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.bold, - color: AppColors.primary, - ), - ), - ], - ), - ), - SizedBox(height: 12.h), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'dailyCostThreshold'.tr, // 日成本预警阈值 - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - Text( - controller - .formatCost(controller.dailyCostThreshold.value), - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ], - ), - SizedBox(height: 4.h), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'monthlyCostThreshold'.tr, // 月成本预警阈值 - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - Text( - controller - .formatCost(controller.monthlyCostThreshold.value), - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ], - ), - ], - )), - ], - ), - ); - } - - Widget _buildUsageBehaviorCard(bool isDarkMode) { - return Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.analytics, - color: AppColors.primary, - size: 20.sp, - ), - SizedBox(width: 8.w), - Text( - 'usageBehaviorStats'.tr, // 使用行为统计 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ], - ), - SizedBox(height: 16.h), - Obx(() => Column( - children: [ - _buildUsageBehaviorRow( - 'mostUsedLanguagePair'.tr, // 最常用语言对 - controller.currentStats.mostUsedLanguagePair, - Icons.language, - isDarkMode, - ), - SizedBox(height: 12.h), - _buildUsageBehaviorRow( - 'mostUsedMode'.tr, // 最常用模式 - controller.currentStats.mostUsedMode, - Icons.settings, - isDarkMode, - ), - ], - )), - ], - ), - ); - } - - Widget _buildUsageBehaviorRow( - String label, String value, IconData icon, bool isDarkMode) { - return Row( - children: [ - Icon( - icon, - color: AppColors.primary, - size: 18.sp, - ), - SizedBox(width: 12.w), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ), - Text( - value, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - ], - ); - } -} diff --git a/apps/client/lib/modules/usage/views/usage_view.dart b/apps/client/lib/modules/usage/views/usage_view.dart deleted file mode 100644 index 7bf1c8ea..00000000 --- a/apps/client/lib/modules/usage/views/usage_view.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import '../../../core/theme/app_colors.dart'; -import '../controllers/usage_controller.dart'; - -class UsageView extends GetView { - const UsageView({super.key}); - - // 2. 颜色配置集中管理 - Color _getBgColor(bool isDark) => - isDark ? Colors.black : const Color(0xFFF9FAFB); - Color _getUnselectedColor(bool isDark) => - isDark ? Colors.grey[500]! : const Color(0xFF6B7280); - Color _getSelectedColor(bool isDark) => - isDark ? Colors.blue[200]! : const Color(0xFF3B82F6); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.grey[50], - body: Obx(() => PageView( - physics: const NeverScrollableScrollPhysics(), - controller: controller.pageController, - children: controller.pageList, - )), - bottomNavigationBar: Obx( - () { - final currentIndex = controller.pageIndex.value; - - return Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: isDarkMode ? Colors.grey[800]! : Colors.grey[200]!, - width: 0.5, - )), - ), - // 3. 添加安全区域适配(针对刘海屏) - child: BottomNavigationBar( - // 4. 使用生成的导航项 - items: (controller.showAiChat.value - ? const [ - _NavItem(Icons.chat_bubble_outline, 'aiChat'), - _NavItem(Icons.translate, 'interpretation'), - _NavItem(Icons.people, 'meeting'), - ] - : const [ - _NavItem(Icons.translate, 'interpretation'), - _NavItem(Icons.people, 'meeting'), - ]) - .map((item) => BottomNavigationBarItem( - icon: Icon(item.icon), - activeIcon: Icon(item.icon, - color: _getSelectedColor(isDarkMode)), // 5. 激活态图标 - label: item.label.tr, // 统一处理国际化 - )) - .toList(), - - backgroundColor: _getBgColor(isDarkMode), - currentIndex: currentIndex, - unselectedItemColor: _getUnselectedColor(isDarkMode), - selectedItemColor: _getSelectedColor(isDarkMode), - unselectedFontSize: 10.sp, - selectedFontSize: 10.sp, - - // 6. 添加动画过渡效果 - onTap: (index) => _onItemTapped(index, currentIndex), - - type: BottomNavigationBarType.fixed, - // 7. 增加图标和文字的间距 - iconSize: 22.w, // 响应式图标尺寸 - selectedLabelStyle: TextStyle(height: 1.5.sp), // 文字垂直间距 - unselectedLabelStyle: TextStyle(height: 1.5.sp), - ), - ); - }, - ), - ); - } - - // 9. 抽离点击逻辑 - void _onItemTapped(int index, int currentIndex) { - if (index == currentIndex) return; - - // 10. 添加页面切换动画 - controller.pageController.animateToPage( - index, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - controller.pageIndex.value = index; - } -} - -// 11. 使用数据类管理导航项配置 -class _NavItem { - final IconData icon; - final String label; - - const _NavItem(this.icon, this.label); -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/.gitignore b/apps/client/local_plugins/asr_tr_tts_manager/.gitignore deleted file mode 100644 index b9d7f25b..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock -**/doc/api/ -.dart_tool/ -.flutter-plugins-dependencies -/build/ -/coverage/ diff --git a/apps/client/local_plugins/asr_tr_tts_manager/.metadata b/apps/client/local_plugins/asr_tr_tts_manager/.metadata deleted file mode 100644 index 89d2197c..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/.metadata +++ /dev/null @@ -1,33 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" - channel: "stable" - -project_type: plugin - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: android - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: ios - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/client/local_plugins/asr_tr_tts_manager/CHANGELOG.md b/apps/client/local_plugins/asr_tr_tts_manager/CHANGELOG.md deleted file mode 100644 index 41cc7d81..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -## 0.0.1 - -* TODO: Describe initial release. diff --git a/apps/client/local_plugins/asr_tr_tts_manager/LICENSE b/apps/client/local_plugins/asr_tr_tts_manager/LICENSE deleted file mode 100644 index ba75c69f..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/LICENSE +++ /dev/null @@ -1 +0,0 @@ -TODO: Add your license here. diff --git a/apps/client/local_plugins/asr_tr_tts_manager/README.md b/apps/client/local_plugins/asr_tr_tts_manager/README.md deleted file mode 100644 index b2c95838..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# asr_tr_tts_manager - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter -[plug-in package](https://flutter.dev/to/develop-plugins), -a specialized package that includes platform-specific implementation code for -Android and/or iOS. - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev), which offers tutorials, -samples, guidance on mobile development, and a full API reference. - diff --git a/apps/client/local_plugins/asr_tr_tts_manager/analysis_options.yaml b/apps/client/local_plugins/asr_tr_tts_manager/analysis_options.yaml deleted file mode 100644 index a5744c1c..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/.gitignore b/apps/client/local_plugins/asr_tr_tts_manager/android/.gitignore deleted file mode 100644 index 161bdcda..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea/workspace.xml -/.idea/libraries -.DS_Store -/build -/captures -.cxx diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/build.gradle b/apps/client/local_plugins/asr_tr_tts_manager/android/build.gradle deleted file mode 100644 index 39edae14..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/build.gradle +++ /dev/null @@ -1,76 +0,0 @@ -group = "com.example.asr_tr_tts_manager" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.1.0" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.9.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.example.asr_tr_tts_manager" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - // 微软语音 SDK 传递依赖的 azure-core 使用了 MethodHandle.invoke/invokeExact, - // 仅 Android 8.0 (API 26) 起支持,故本模块要求 minSdk >= 26。 - minSdk = 26 - consumerProguardFiles "proguard-rules.pro" - } - - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} - -dependencies { - implementation(project(":bluetooth_manager")) - implementation(project(":audio_recorder")) - implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.50.0") - implementation("com.squareup.okhttp3:okhttp:4.12.0") -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/proguard-rules.pro b/apps/client/local_plugins/asr_tr_tts_manager/android/proguard-rules.pro deleted file mode 100644 index fc42f85e..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/proguard-rules.pro +++ /dev/null @@ -1,2 +0,0 @@ --keep class com.microsoft.cognitiveservices.speech.** { *; } --keep class com.microsoft.cognitiveservices.speech.audio.** { *; } \ No newline at end of file diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/settings.gradle b/apps/client/local_plugins/asr_tr_tts_manager/android/settings.gradle deleted file mode 100644 index 88ed34f5..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'asr_tr_tts_manager' diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/AndroidManifest.xml deleted file mode 100644 index 8cd27b71..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/AsrTrTtsHelper.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/AsrTrTtsHelper.kt deleted file mode 100644 index 74abdc3f..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/AsrTrTtsHelper.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.example.asr_tr_tts_manager - -import android.content.Context -import com.example.asr_tr_tts_manager.asr.* -import com.example.asr_tr_tts_manager.translation.* -import com.example.asr_tr_tts_manager.tts.* -import java.util.UUID - -/** - * ASR + 翻译 + TTS 一体化助手。 - * - * @param context Android 上下文 - * @param asrProvider 语音识别服务提供商 - * @param translationProvider 翻译服务提供商 - * @param ttsProvider 语音合成服务提供商 - * @param languages 语言列表,第一个为源语言,最后一个为目标语言 - * @param autoPlay 是否在合成完成后自动播放音频 - * @param autoDetectLanguage 是否自动检测输入语言并动态切换源/目标语言 - * @param forceBuiltInSpeaker 是否强制通过手机内置扬声器播放(忽略蓝牙等外部设备) - */ -class AsrTrTtsHelper( - context: Context, - asrProvider: AsrProvider = AsrProvider.AZURE, - translationProvider: TranslationProvider = TranslationProvider.AZURE, - ttsProvider: TtsProvider = TtsProvider.AZURE, - languages: List = listOf("zh-CN", "en-US"), - autoPlay: Boolean = false, - autoDetectLanguage: Boolean = false, - forceBuiltInSpeaker: Boolean = false, -) { - @Volatile - private var currentLanguages: List = languages - - private val asrManager = AsrManager(asrProvider, languages, autoDetectLanguage) - private val translationManager = TranslationManager(translationProvider) - private val ttsManager = TtsManager(context, ttsProvider, autoPlay, forceBuiltInSpeaker) - - @Volatile - private var sessionId = UUID.randomUUID().toString() - - @Volatile - private var lastRecognizingResult = AsrResult("", "") - - @Volatile - var callback: ((Map) -> Unit)? = null - - @Volatile - var onSynthesisEncoded: ((ByteArray) -> Unit)? = null - set(value) { - field = value - ttsManager.onSynthesisEncoded = value - } - - @Volatile - var autoPlay: Boolean = autoPlay - set(value) { - field = value - ttsManager.autoPlay = value - } - - @Volatile - var forceBuiltInSpeaker: Boolean = forceBuiltInSpeaker - set(value) { - field = value - ttsManager.forceBuiltInSpeaker = value - } - - @Volatile - var autoDetectLanguage: Boolean = autoDetectLanguage - - init { - asrManager.onRecognizing = { result -> - lastRecognizingResult = result - val sid = sessionId - val cb = callback - cb?.invoke(mapOf("sessionId" to sid, "type" to "recognizing", "recognizing" to result.text, "language" to result.language)) - } - - asrManager.onRecognized = handler@{ result -> - val sid = sessionId - val finalResult = if (result.text.isEmpty()) lastRecognizingResult else result - lastRecognizingResult = AsrResult("", "") - sessionId = UUID.randomUUID().toString() - - if (finalResult.text.isEmpty()) return@handler - - val languagesSnapshot = currentLanguages - val sourceLanguage: LanguageInfo? - val targetLanguage: LanguageInfo? - - if (autoDetectLanguage) { - val detectedLanguage = LanguageManager.findByCode(finalResult.language) - if (detectedLanguage != null) { - sourceLanguage = detectedLanguage - val other = languagesSnapshot.firstOrNull { - !it.equals(finalResult.language, ignoreCase = true) - } - targetLanguage = LanguageManager.findByCode(other) - } else { - sourceLanguage = LanguageManager.findByCode(languagesSnapshot.firstOrNull()) - targetLanguage = LanguageManager.findByCode(languagesSnapshot.lastOrNull()) - } - } else { - sourceLanguage = LanguageManager.findByCode(languagesSnapshot.firstOrNull()) - targetLanguage = LanguageManager.findByCode(languagesSnapshot.lastOrNull()) - } - - val translationResult = - translationManager.translateText( - text = finalResult.text, - sourceLanguage = sourceLanguage?.language ?: "", - targetLanguage = targetLanguage?.language ?: "", - ) - val translated = translationResult.translatedText.orEmpty() - val cb = callback - cb?.invoke(mapOf("sessionId" to sid, "type" to "recognized", "recognized" to finalResult.text, "translated" to translated, "language" to finalResult.language)) - if (translated.isNotEmpty()) { - ttsManager.speakText( - translated, - targetLanguage?.code ?: "", - targetLanguage?.voiceName ?: "" - ) - } - } - } - - fun switchProvider( - asrProvider: AsrProvider? = null, - translationProvider: TranslationProvider? = null, - ttsProvider: TtsProvider? = null, - languages: List? = null, - ) { - if (languages != null) { - currentLanguages = languages - } - - if (asrProvider != null && languages != null) { - asrManager.switchProvider(provider = asrProvider, languages = languages) - } else if (asrProvider != null) { - asrManager.switchProvider(provider = asrProvider) - } else if (languages != null) { - asrManager.switchProvider(languages = languages) - } - - if (translationProvider != null) { - translationManager.switchProvider(provider = translationProvider) - } - - if (ttsProvider != null) { - ttsManager.switchProvider(provider = ttsProvider) - } - } - - fun startRecognition() { - asrManager.startRecognition() - } - - fun stopRecognition() { - asrManager.stopRecognition() - } - - fun pushStreamWrite(bytes: ByteArray) { - asrManager.pushStreamWrite(bytes) - } - - fun pushStreamClose() { - asrManager.pushStreamClose() - } - - fun dispose() { - callback = null - onSynthesisEncoded = null - - ttsManager.dispose() - asrManager.dispose() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/AsrTrTtsManagerPlugin.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/AsrTrTtsManagerPlugin.kt deleted file mode 100644 index e9592f22..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/AsrTrTtsManagerPlugin.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.example.asr_tr_tts_manager - -import android.app.Activity -import android.content.Intent -import android.media.AudioManager -import android.os.Build -import com.example.asr_tr_tts_manager.translation_option.TranslationHelperManager -import com.example.asr_tr_tts_manager.translation_option.TranslationHelperProvider -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.embedding.engine.plugins.activity.ActivityAware -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.EventChannel.EventSink -import io.flutter.plugin.common.EventChannel.StreamHandler -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result - -/** AsrTrTtsManagerPlugin */ -class AsrTrTtsManagerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { - // The MethodChannel that will the communication between Flutter and native Android - // - // This local reference serves to register the plugin with the Flutter Engine and unregister it - // when the Flutter Engine is detached from the Activity - private lateinit var channel: MethodChannel - private lateinit var translationChannel: EventChannel - private var translationChannelSink: EventSink? = null - private var appContext: android.content.Context? = null - - private var translationHelper: TranslationHelperManager? = null - private var currentProvider: TranslationHelperProvider? = null - - // 面对面翻译外放走 USAGE_VOICE_COMMUNICATION(通话流),硬件音量键默认却控制媒体流, - // 导致用户调不动扬声器音量。会话期间把音量键绑定到通话流,结束后还原。 - private var activity: Activity? = null - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - appContext = flutterPluginBinding.applicationContext - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "asr_tr_tts_manager") - translationChannel = EventChannel(flutterPluginBinding.binaryMessenger, "asr_tr_tts_manager/translation") - channel.setMethodCallHandler(this) - translationChannel.setStreamHandler(object : StreamHandler { - override fun onCancel(arguments: Any?) { - translationChannelSink = null - } - - override fun onListen(arguments: Any?, events: EventSink?) { - translationChannelSink = events - } - }) - } - - override fun onMethodCall(call: MethodCall, result: Result) { - when (call.method) { - "translationInit" -> translationInit(call, result) - "translationStart" -> translationStart(call, result) - "translationStop" -> translationStop(result) - "translationDispose" -> translationDispose(result) - "enableRecord" -> { - val filePath = call.argument("filePath") ?: "" - translationHelper?.enableRecord(filePath) - result.success(true) - } - "stopRecord" -> { - val isSave = call.argument("isSave") ?: false - translationHelper?.stopRecord(isSave) - result.success(true) - } - else -> result.notImplemented() - } - } - - private fun translationInit(call: MethodCall, result: Result) { - val providerStr = call.argument("provider") ?: "CALL" - val provider = when (providerStr.uppercase()) { - "SIMULTANEOUS" -> TranslationHelperProvider.SIMULTANEOUS - "FACETOFACE" -> TranslationHelperProvider.FACETOFACE - else -> TranslationHelperProvider.CALL - } - translationHelper?.dispose() - currentProvider = provider - translationHelper = TranslationHelperManager(appContext!!, provider) - translationHelper?.callback = { data -> - translationChannelSink?.success(data) - } - result.success(true) - } - - private fun translationStart(call: MethodCall, result: Result) { - val sourceLanguage = call.argument("sourceLanguage") ?: "zh-CN" - val targetLanguage = call.argument("targetLanguage") ?: "en-US" - startForegroundService() - translationHelper?.start(sourceLanguage, targetLanguage) - applyVolumeControlStream() - result.success(true) - } - - private fun translationStop(result: Result) { - translationHelper?.stop() - restoreVolumeControlStream() - stopForegroundService() - result.success(true) - } - - private fun translationDispose(result: Result) { - translationHelper?.dispose() - translationHelper = null - currentProvider = null - restoreVolumeControlStream() - stopForegroundService() - result.success(true) - } - - /** 仅面对面翻译外放走通话流,把音量键绑定到 STREAM_VOICE_CALL,让用户能调扬声器音量。 */ - private fun applyVolumeControlStream() { - if (currentProvider == TranslationHelperProvider.FACETOFACE) { - activity?.volumeControlStream = AudioManager.STREAM_VOICE_CALL - } - } - - private fun restoreVolumeControlStream() { - activity?.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE - } - - private fun startForegroundService() { - val ctx = appContext ?: return - val intent = Intent(ctx, TranslationForegroundService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ctx.startForegroundService(intent) - } else { - ctx.startService(intent) - } - } - - private fun stopForegroundService() { - val ctx = appContext ?: return - ctx.stopService(Intent(ctx, TranslationForegroundService::class.java)) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - } - - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity - } - - override fun onDetachedFromActivityForConfigChanges() { - activity = null - } - - override fun onDetachedFromActivity() { - activity = null - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/LanguageManager.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/LanguageManager.kt deleted file mode 100644 index 760501f1..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/LanguageManager.kt +++ /dev/null @@ -1,222 +0,0 @@ -package com.example.asr_tr_tts_manager - -import java.util.Locale - -data class LanguageInfo( - val code: String, - val language: String, - val voiceName: String, -) - -object LanguageManager { - val all: List = - listOf( - LanguageInfo( - code = "zh-CN", - language = "zh", - voiceName = "zh-CN-XiaoxiaoNeural", - ), - LanguageInfo( - code = "zh-TW", - language = "zh-Hant", - voiceName = "zh-TW-HsiaoChenNeural", - ), - LanguageInfo( - code = "zh-HK", - language = "zh-Hant-hk", - voiceName = "zh-HK-HiuMaanNeural", - ), - LanguageInfo( - code = "en-US", - language = "en", - voiceName = "en-US-AriaNeural", - ), - LanguageInfo( - code = "ja-JP", - language = "ja", - voiceName = "ja-JP-NanamiNeural", - ), - LanguageInfo( - code = "ko-KR", - language = "ko", - voiceName = "ko-KR-SunHiNeural", - ), - LanguageInfo( - code = "fr-FR", - language = "fr", - voiceName = "fr-FR-DeniseNeural", - ), - LanguageInfo( - code = "es-ES", - language = "es", - voiceName = "es-ES-ElviraNeural", - ), - LanguageInfo( - code = "pt-BR", - language = "pt", - voiceName = "pt-BR-FranciscaNeural", - ), - LanguageInfo( - code = "it-IT", - language = "it", - voiceName = "it-IT-ElsaNeural", - ), - LanguageInfo( - code = "de-DE", - language = "de", - voiceName = "de-DE-KatjaNeural", - ), - LanguageInfo( - code = "ru-RU", - language = "ru", - voiceName = "ru-RU-SvetlanaNeural", - ), - LanguageInfo( - code = "pl-PL", - language = "pl", - voiceName = "pl-PL-AgnieszkaNeural", - ), - LanguageInfo( - code = "nl-NL", - language = "nl", - voiceName = "nl-NL-ColetteNeural", - ), - LanguageInfo( - code = "sv-SE", - language = "sv", - voiceName = "sv-SE-SofieNeural", - ), - LanguageInfo( - code = "cs-CZ", - language = "cs", - voiceName = "cs-CZ-VlastaNeural", - ), - LanguageInfo( - code = "el-GR", - language = "el", - voiceName = "el-GR-AthinaNeural", - ), - LanguageInfo( - code = "ro-RO", - language = "ro", - voiceName = "ro-RO-AlinaNeural", - ), - LanguageInfo( - code = "hu-HU", - language = "hu", - voiceName = "hu-HU-NoemiNeural", - ), - LanguageInfo( - code = "uk-UA", - language = "uk", - voiceName = "uk-UA-PolinaNeural", - ), - LanguageInfo( - code = "da-DK", - language = "da", - voiceName = "da-DK-ChristelNeural", - ), - LanguageInfo( - code = "fi-FI", - language = "fi", - voiceName = "fi-FI-NooraNeural", - ), - LanguageInfo( - code = "nb-NO", - language = "nb", - voiceName = "nb-NO-IselinNeural", - ), - LanguageInfo( - code = "hr-HR", - language = "hr", - voiceName = "hr-HR-GabrijelaNeural", - ), - LanguageInfo( - code = "ca-ES", - language = "ca", - voiceName = "ca-ES-JoanaNeural", - ), - LanguageInfo( - code = "ar-EG", - language = "ar", - voiceName = "ar-EG-SalmaNeural", - ), - LanguageInfo( - code = "th-TH", - language = "th", - voiceName = "th-TH-PremwadeeNeural", - ), - LanguageInfo( - code = "vi-VN", - language = "vi", - voiceName = "vi-VN-HoaiMyNeural", - ), - LanguageInfo( - code = "id-ID", - language = "id", - voiceName = "id-ID-GadisNeural", - ), - LanguageInfo( - code = "hi-IN", - language = "hi", - voiceName = "hi-IN-SwaraNeural", - ), - LanguageInfo( - code = "tr-TR", - language = "tr", - voiceName = "tr-TR-EmelNeural", - ), - LanguageInfo( - code = "ms-MY", - language = "ms", - voiceName = "ms-MY-YasminNeural", - ), - LanguageInfo( - code = "bn-IN", - language = "bn", - voiceName = "bn-IN-TanishaaNeural", - ), - LanguageInfo( - code = "ta-IN", - language = "ta", - voiceName = "ta-IN-PallaviNeural", - ), - LanguageInfo( - code = "te-IN", - language = "te", - voiceName = "te-IN-ShrutiNeural", - ), - LanguageInfo( - code = "mr-IN", - language = "mr", - voiceName = "mr-IN-AarohiNeural", - ), - LanguageInfo( - code = "ur-IN", - language = "ur", - voiceName = "ur-IN-GulNeural", - ), - LanguageInfo( - code = "fa-IR", - language = "fa", - voiceName = "fa-IR-DilaraNeural", - ), - LanguageInfo( - code = "sw-KE", - language = "sw", - voiceName = "sw-KE-ZuriNeural", - ), - ) - - private val byCode: Map = - all.associateBy { normalizeCode(it.code) } - - fun findByCode(code: String?): LanguageInfo? { - if (code.isNullOrBlank()) return null - return byCode[normalizeCode(code)] - } - - private fun normalizeCode(value: String): String { - return value.trim().replace('_', '-').lowercase(Locale.ROOT) - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/TranslationForegroundService.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/TranslationForegroundService.kt deleted file mode 100644 index f6711b7e..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/TranslationForegroundService.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.example.asr_tr_tts_manager - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import android.os.PowerManager -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC - -class TranslationForegroundService : Service() { - companion object { - private const val CHANNEL_ID = "TranslationServiceChannel" - private const val NOTIFICATION_ID = 2 - } - - private var wakeLock: PowerManager.WakeLock? = null - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onCreate() { - super.onCreate() - createNotificationChannel() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val notification = createNotification() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) - } else { - startForeground(NOTIFICATION_ID, notification) - } - - // 获取 WakeLock 防止 CPU 休眠 - if (wakeLock == null) { - val pm = getSystemService(POWER_SERVICE) as PowerManager - wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "DeapVoice::Translation").apply { - acquire() - } - } - - return START_NOT_STICKY - } - - override fun onDestroy() { - super.onDestroy() - - wakeLock?.let { - if (it.isHeld) it.release() - } - wakeLock = null - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - @Suppress("DEPRECATION") - stopForeground(true) - } - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "翻译服务", - NotificationManager.IMPORTANCE_LOW - ) - val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - } - } - - private fun createNotification(): Notification { - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("翻译服务") - .setContentText("翻译进行中...") - .setSmallIcon(android.R.drawable.ic_btn_speak_now) - .setSilent(true) - .setOngoing(true) - .setVisibility(VISIBILITY_PUBLIC) - .build() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/VolcengineHelper.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/VolcengineHelper.kt deleted file mode 100644 index 60271b17..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/VolcengineHelper.kt +++ /dev/null @@ -1,718 +0,0 @@ -package com.example.asr_tr_tts_manager - -import android.os.Handler -import android.os.Looper -import android.util.Log -import com.example.bluetooth_manager.G722Codec -import okhttp3.* -import okio.ByteString -import okio.ByteString.Companion.toByteString -import java.io.ByteArrayOutputStream -import java.util.UUID -import java.util.concurrent.TimeUnit - -class VolcengineHelper( - languages: List = listOf("zh-CN", "en-US"), -) { - companion object { - private const val TAG = "VolcengineHelper" - private const val WS_URL = "wss://openspeech.bytedance.com/api/v4/ast/v2/translate" - - // TODO: 替换为实际的凭证 - private const val APP_KEY = "8595121680" - private const val ACCESS_KEY = "ERAUYbJQBOv6M0ooFnUwo-tTWs08HWZn" - private const val RESOURCE_ID = "volc.service_type.10053" - - // Event types from events.proto - // Session 级别事件(AST 2.0 使用 Session 而非 Connection) - private const val EVENT_START_SESSION = 100 - private const val EVENT_FINISH_SESSION = 102 - private const val EVENT_SESSION_STARTED = 150 - private const val EVENT_SESSION_CANCELED = 151 - private const val EVENT_SESSION_FINISHED = 152 - private const val EVENT_SESSION_FAILED = 153 - private const val EVENT_TASK_REQUEST = 200 - private const val EVENT_SOURCE_SUBTITLE_START = 650 - private const val EVENT_SOURCE_SUBTITLE_RESPONSE = 651 - private const val EVENT_SOURCE_SUBTITLE_END = 652 - private const val EVENT_TRANSLATION_SUBTITLE_START = 653 - private const val EVENT_TRANSLATION_SUBTITLE_RESPONSE = 654 - private const val EVENT_TRANSLATION_SUBTITLE_END = 655 - private const val EVENT_TTS_SENTENCE_START = 350 - private const val EVENT_TTS_RESPONSE = 352 - private const val EVENT_TTS_SENTENCE_END = 351 - - // G722 编码常量 - private const val FRAME_PCM_BYTES = 640 // 16kHz × 20ms × 2 bytes - - // 重连策略 - private const val MAX_RECONNECT_ATTEMPTS = 3 - private const val RECONNECT_BASE_DELAY_MS = 1000L // 1s, 2s, 4s 指数退避 - - // FinishSession 超时:服务端未响应时兜底关闭 - private const val FINISH_SESSION_TIMEOUT_MS = 5000L - - // 多实例共享同一个 OkHttpClient(线程池和连接池) - private val httpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) // no read timeout for streaming - .writeTimeout(30, TimeUnit.SECONDS) - .pingInterval(15, TimeUnit.SECONDS) - .build() - } - - @Volatile - private var currentLanguages: List = languages - - @Volatile - var callback: ((Map) -> Unit)? = null - - @Volatile - var onSynthesisEncoded: ((ByteArray) -> Unit)? = null - - @Volatile - private var sessionId = UUID.randomUUID().toString() - - @Volatile - private var connectionId = UUID.randomUUID().toString() - - @Volatile - private var webSocket: WebSocket? = null - - @Volatile - private var isRecognizing = false - - @Volatile - private var isConnected = false - - // 用于上层区分不同翻译段的 ID(不影响协议层 sessionId) - @Volatile - private var segmentId = UUID.randomUUID().toString() - - // 当前识别中的原文(用于 recognizing 事件) - @Volatile - private var currentSourceText = "" - - // 重连相关 - @Volatile - private var reconnectAttempts = 0 - private val reconnectHandler = Handler(Looper.getMainLooper()) - - // sendTaskRequest 预构建的固定 header 部分(event=200 的 tag+varint + audio field 14 的外层 tag) - // 在 audioData 长度已知后,只需拼接 length + payload,避免每次创建 ProtoWriter - // 格式: [event field] [source_audio tag] [source_audio length] [binary_data tag] [binary_data length] [payload] - private val taskRequestHeader: ByteArray = run { - val pw = ProtoWriter() - pw.writeVarint(2, EVENT_TASK_REQUEST) - pw.toByteArray() - } - - // G722 编码相关(通过 audioLock 保护) - private val audioLock = Any() - private var g722Handle: Long = 0L - private var stashBuffer = ByteArray(8192) - private var stashSize = 0 - private val frameBuffer = ByteArray(FRAME_PCM_BYTES) // 复用的帧缓冲区 - - private val wsListener = object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - Log.d(TAG, "WebSocket connected") - sendStartSession() - } - - override fun onMessage(webSocket: WebSocket, bytes: ByteString) { - val raw = bytes.toByteArray() - parseResponse(raw) - } - - override fun onMessage(webSocket: WebSocket, text: String) { - Log.d(TAG, "Received text: $text") - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "WebSocket failure: ${t.message}", t) - isConnected = false - // 如果仍在识别中,尝试指数退避重连 - if (isRecognizing && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - val delay = RECONNECT_BASE_DELAY_MS shl reconnectAttempts - reconnectAttempts++ - Log.d(TAG, "Reconnect attempt $reconnectAttempts after ${delay}ms...") - reconnectHandler.postDelayed({ - if (isRecognizing) connectWebSocket() - }, delay) - } else { - callback?.invoke( - mapOf("type" to "error", "message" to (t.message ?: "connection failed")) - ) - } - } - - override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "WebSocket closing: $code $reason") - webSocket.close(code, reason) - } - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "WebSocket closed: $code $reason") - isConnected = false - } - } - - fun switchProvider(languages: List? = null) { - if (languages != null) { - currentLanguages = languages - } - } - - fun startRecognition() { - if (isRecognizing) return - isRecognizing = true - isConnected = false - reconnectAttempts = 0 - reconnectHandler.removeCallbacksAndMessages(null) - sessionId = UUID.randomUUID().toString() - connectionId = UUID.randomUUID().toString() - segmentId = UUID.randomUUID().toString() - currentSourceText = "" - synchronized(audioLock) { - releaseG722() - stashSize = 0 - } - connectWebSocket() - } - - fun stopRecognition() { - if (!isRecognizing) return - isRecognizing = false - reconnectHandler.removeCallbacksAndMessages(null) - sendFinishSession() - // 等待服务端返回 SESSION_FINISHED/SESSION_CANCELED 后关闭,超时兜底 - reconnectHandler.postDelayed({ - if (webSocket != null) { - Log.w(TAG, "FinishSession timeout, force closing WebSocket") - closeWebSocket() - } - }, FINISH_SESSION_TIMEOUT_MS) - } - - fun pushStreamWrite(bytes: ByteArray) { - if (!isRecognizing || !isConnected) return - sendTaskRequest(bytes) - } - - fun dispose() { - isRecognizing = false - isConnected = false - callback = null - onSynthesisEncoded = null - closeWebSocket() - reconnectHandler.removeCallbacksAndMessages(null) - synchronized(audioLock) { - releaseG722() - } - } - - // ============ WebSocket 连接管理 ============ - - private fun connectWebSocket() { - closeWebSocket() - val request = Request.Builder() - .url(WS_URL) - .header("X-Api-App-Key", APP_KEY) - .header("X-Api-Access-Key", ACCESS_KEY) - .header("X-Api-Resource-Id", RESOURCE_ID) - .build() - webSocket = httpClient.newWebSocket(request, wsListener) - } - - private fun closeWebSocket() { - val ws = webSocket ?: return - webSocket = null - isConnected = false - try { - ws.close(1000, "normal close") - } catch (_: Exception) { - // 连接可能已断开,强制取消 - try { ws.cancel() } catch (_: Exception) {} - } - } - - // ============ 发送 Protobuf 消息 ============ - - /** - * 发送 StartConnection (event=1) 建联请求 - * TranslateRequest 包含 request_meta, event, user, source_audio, target_audio, request - */ - private fun sendStartSession() { - val langs = currentLanguages - if (langs.size < 2) { - Log.e(TAG, "Need at least 2 languages, got: $langs") - isRecognizing = false - isConnected = false - callback?.invoke( - mapOf("type" to "error", "message" to "Need source and target language") - ) - closeWebSocket() - return - } - val sourceCode = langs[0] - val targetCode = langs[1] - val sourceLang = LanguageManager.findByCode(sourceCode)?.language ?: "zh" - val targetLang = LanguageManager.findByCode(targetCode)?.language ?: "en" - - val pw = ProtoWriter() - - // field 1: request_meta (RequestMeta) - val metaPw = ProtoWriter() - metaPw.writeString(2, APP_KEY) // AppKey - metaPw.writeString(4, RESOURCE_ID) // ResourceID - metaPw.writeString(5, connectionId) // ConnectionID - metaPw.writeString(6, sessionId) // SessionID - pw.writeBytes(1, metaPw.toByteArray()) - - // field 2: event (enum Type = StartSession = 100) - pw.writeVarint(2, EVENT_START_SESSION) - - // field 3: user (User) - val userPw = ProtoWriter() - userPw.writeString(1, "deapvoice_android") // uid - userPw.writeString(3, "android") // platform - pw.writeBytes(3, userPw.toByteArray()) - - // field 4: source_audio (Audio) - val srcAudioPw = ProtoWriter() - srcAudioPw.writeString(4, "pcm") // format - srcAudioPw.writeVarint(7, 16000) // rate - srcAudioPw.writeVarint(8, 16) // bits - srcAudioPw.writeVarint(9, 1) // channel - pw.writeBytes(4, srcAudioPw.toByteArray()) - - // field 5: target_audio (Audio) - val tgtAudioPw = ProtoWriter() - tgtAudioPw.writeString(4, "pcm") // format - tgtAudioPw.writeVarint(7, 16000) // rate - tgtAudioPw.writeVarint(8, 16) // bits - tgtAudioPw.writeVarint(9, 1) // channel - pw.writeBytes(5, tgtAudioPw.toByteArray()) - - // field 6: request (ast.ReqParams) - val reqPw = ProtoWriter() - reqPw.writeString(1, "s2s") // mode - reqPw.writeString(2, sourceLang) // source_language - reqPw.writeString(3, targetLang) // target_language - pw.writeBytes(6, reqPw.toByteArray()) - - val data = pw.toByteArray() - webSocket?.send(data.toByteString()) - Log.d(TAG, "Sent StartSession (${data.size} bytes), session=$sessionId, $sourceLang→$targetLang") - } - - /** - * 发送 TaskRequest (event=200) 包含音频数据 - */ - private fun sendTaskRequest(audioData: ByteArray) { - // 手动拼接避免每次创建 ProtoWriter: - // [taskRequestHeader] + field 4 (source_audio) { field 14 (binary_data) = audioData } - val innerSize = 1 + varintSize(audioData.size.toLong()) + audioData.size // tag(14) + len + payload - val outerSize = 1 + varintSize(innerSize.toLong()) + innerSize // tag(4) + len + inner - val buf = ByteArray(taskRequestHeader.size + outerSize) - var pos = 0 - - // 复制预构建的 header(event=200) - System.arraycopy(taskRequestHeader, 0, buf, 0, taskRequestHeader.size) - pos += taskRequestHeader.size - - // field 4: source_audio, wire type 2 - buf[pos++] = ((4 shl 3) or 2).toByte() - pos = writeRawVarintTo(buf, pos, innerSize.toLong()) - - // field 14: binary_data, wire type 2 - buf[pos++] = ((14 shl 3) or 2).toByte() - pos = writeRawVarintTo(buf, pos, audioData.size.toLong()) - - System.arraycopy(audioData, 0, buf, pos, audioData.size) - - val sent = webSocket?.send(buf.toByteString()) ?: false - if (!sent) { - Log.w(TAG, "sendTaskRequest failed, connection may be closed") - isConnected = false - } - } - - private fun varintSize(value: Long): Int { - var v = value - var size = 1 - while (v and 0x7FL.inv() != 0L) { size++; v = v ushr 7 } - return size - } - - private fun writeRawVarintTo(buf: ByteArray, offset: Int, value: Long): Int { - var pos = offset - var v = value - while (v and 0x7FL.inv() != 0L) { - buf[pos++] = ((v.toInt() and 0x7F) or 0x80).toByte() - v = v ushr 7 - } - buf[pos++] = (v.toInt() and 0x7F).toByte() - return pos - } - - /** - * 发送 FinishSession (event=102) 结束会话 - */ - private fun sendFinishSession() { - val pw = ProtoWriter() - pw.writeVarint(2, EVENT_FINISH_SESSION) - val data = pw.toByteArray() - webSocket?.send(data.toByteString()) - Log.d(TAG, "Sent FinishSession") - } - - // ============ 解析 Protobuf 响应 ============ - - /** - * 解析 TranslateResponse protobuf 消息 - */ - private fun parseResponse(data: ByteArray) { - try { - val pr = ProtoReader(data) - var event = 0 - var responseData: ByteArray? = null - var text = "" - var statusCode = 0 - var statusMessage = "" - - while (pr.hasMore()) { - val tag = pr.readTag() - val fieldNumber = tag shr 3 - val wireType = tag and 0x7 - - when (fieldNumber) { - 1 -> { // response_meta (ResponseMeta) - val metaBytes = pr.readLengthDelimited() - val metaPr = ProtoReader(metaBytes) - while (metaPr.hasMore()) { - val metaTag = metaPr.readTag() - val metaField = metaTag shr 3 - val metaWire = metaTag and 0x7 - when (metaField) { - 3 -> statusCode = metaPr.readVarint().toInt() - 4 -> statusMessage = metaPr.readStringField(metaWire) - else -> metaPr.skip(metaWire) - } - } - } - 2 -> event = pr.readVarint().toInt() // event (enum) - 3 -> responseData = pr.readLengthDelimited() // data (bytes) - 4 -> text = pr.readStringField(wireType) // text (string) - else -> pr.skip(wireType) - } - } - - handleEvent(event, text, responseData, statusCode, statusMessage) - } catch (e: Exception) { - Log.e(TAG, "Parse response failed: ${e.message}", e) - } - } - - private fun handleEvent( - event: Int, - text: String, - audioData: ByteArray?, - statusCode: Int, - statusMessage: String - ) { - when (event) { - EVENT_SESSION_STARTED -> { - Log.d(TAG, "Session started") - isConnected = true - reconnectAttempts = 0 - } - - EVENT_SESSION_FAILED -> { - Log.e(TAG, "Session failed: $statusCode $statusMessage") - isConnected = false - reconnectHandler.removeCallbacksAndMessages(null) - closeWebSocket() - callback?.invoke( - mapOf("type" to "error", "message" to "Session failed: $statusCode $statusMessage") - ) - } - - EVENT_SESSION_FINISHED -> { - Log.d(TAG, "Session finished") - isConnected = false - reconnectHandler.removeCallbacksAndMessages(null) - closeWebSocket() - } - - EVENT_SESSION_CANCELED -> { - Log.d(TAG, "Session canceled") - isConnected = false - reconnectHandler.removeCallbacksAndMessages(null) - closeWebSocket() - } - - EVENT_SOURCE_SUBTITLE_START -> { - currentSourceText = "" - } - - EVENT_SOURCE_SUBTITLE_RESPONSE -> { - // 原文识别中(部分结果) - currentSourceText = text - if (text.isNotEmpty()) { - val sourceCode = currentLanguages.firstOrNull() ?: "zh-CN" - callback?.invoke( - mapOf( - "sessionId" to segmentId, - "type" to "recognizing", - "recognizing" to text, - "language" to sourceCode, - ) - ) - } - } - - EVENT_SOURCE_SUBTITLE_END -> { - // 原文识别结束(最终结果) - if (text.isNotEmpty()) { - currentSourceText = text - } - } - - EVENT_TRANSLATION_SUBTITLE_RESPONSE -> { - if (audioData != null && audioData.isNotEmpty()) { - handleAudioData(audioData) - } - } - - EVENT_TRANSLATION_SUBTITLE_END -> { - // 翻译结束时可能还有音频 - if (audioData != null && audioData.isNotEmpty()) { - handleAudioData(audioData) - } - // 刷新 G722 缓冲区中残留的不足一帧的数据 - flushAudioStash() - - // 发出 recognized 事件 - val sourceCode = currentLanguages.firstOrNull() ?: "zh-CN" - val sourceText = currentSourceText - val translatedText = text - if (sourceText.isNotEmpty() || translatedText.isNotEmpty()) { - callback?.invoke( - mapOf( - "sessionId" to segmentId, - "type" to "recognized", - "recognized" to sourceText, - "translated" to translatedText, - "language" to sourceCode, - ) - ) - // 更新 segmentId 用于下一段(不影响协议层 sessionId) - segmentId = UUID.randomUUID().toString() - currentSourceText = "" - } - } - - EVENT_TTS_RESPONSE -> { - // TTS 音频数据 - if (audioData != null && audioData.isNotEmpty()) { - handleAudioData(audioData) - } - } - - EVENT_TTS_SENTENCE_END -> { - // TTS 句子结束,刷新残留音频 - flushAudioStash() - } - - else -> { - if (statusCode != 0 && statusMessage.isNotEmpty()) { - Log.w(TAG, "Event $event with status $statusCode: $statusMessage") - } - } - } - } - - // ============ 音频 G722 编码 ============ - - private fun handleAudioData(pcmData: ByteArray) { - val encodedCb = onSynthesisEncoded ?: return - if (pcmData.isEmpty()) return - - synchronized(audioLock) { - if (g722Handle == 0L) { - g722Handle = G722Codec.init(16000) - } - - val required = stashSize + pcmData.size - if (required > stashBuffer.size) { - var newCap = stashBuffer.size - while (newCap < required) newCap = newCap shl 1 - val newBuf = ByteArray(newCap) - System.arraycopy(stashBuffer, 0, newBuf, 0, stashSize) - stashBuffer = newBuf - } - - System.arraycopy(pcmData, 0, stashBuffer, stashSize, pcmData.size) - stashSize += pcmData.size - - var cursor = 0 - while (stashSize - cursor >= FRAME_PCM_BYTES) { - System.arraycopy(stashBuffer, cursor, frameBuffer, 0, FRAME_PCM_BYTES) - val encoded = try { - G722Codec.encodeSync(g722Handle, frameBuffer) - } catch (_: Exception) { - null - } - if (encoded != null) { - encodedCb.invoke(encoded) - } - cursor += FRAME_PCM_BYTES - } - - if (cursor > 0) { - val remaining = stashSize - cursor - if (remaining > 0) { - System.arraycopy(stashBuffer, cursor, stashBuffer, 0, remaining) - } - stashSize = remaining - } - } - } - - /** - * 刷新 G722 缓冲区中残留的不足一帧的 PCM 数据, - * 用零填充到完整帧后编码发出。 - */ - private fun flushAudioStash() { - val encodedCb = onSynthesisEncoded ?: return - - synchronized(audioLock) { - if (stashSize <= 0) return - if (g722Handle == 0L) return - - // 零填充复用的 frameBuffer - java.util.Arrays.fill(frameBuffer, stashSize, FRAME_PCM_BYTES, 0.toByte()) - System.arraycopy(stashBuffer, 0, frameBuffer, 0, stashSize) - stashSize = 0 - - val encoded = try { - G722Codec.encodeSync(g722Handle, frameBuffer) - } catch (_: Exception) { - null - } - if (encoded != null) { - encodedCb.invoke(encoded) - } - } - } - - private fun releaseG722() { - if (g722Handle != 0L) { - try { - G722Codec.release(g722Handle) - } catch (_: Exception) { - } - g722Handle = 0L - } - } - - // ============ Protobuf 编码工具 ============ - - private class ProtoWriter { - private val buffer = ByteArrayOutputStream() - - fun writeVarint(fieldNumber: Int, value: Int) { - writeVarint(fieldNumber, value.toLong()) - } - - fun writeVarint(fieldNumber: Int, value: Long) { - writeRawVarint((fieldNumber shl 3).toLong()) // wire type 0 - writeRawVarint(value) - } - - fun writeString(fieldNumber: Int, value: String) { - if (value.isEmpty()) return // proto3 默认值不写入 - val bytes = value.toByteArray(Charsets.UTF_8) - writeRawVarint(((fieldNumber shl 3) or 2).toLong()) // wire type 2 - writeRawVarint(bytes.size.toLong()) - buffer.write(bytes) - } - - fun writeBytes(fieldNumber: Int, value: ByteArray) { - if (value.isEmpty()) return - writeRawVarint(((fieldNumber shl 3) or 2).toLong()) // wire type 2 - writeRawVarint(value.size.toLong()) - buffer.write(value) - } - - private fun writeRawVarint(value: Long) { - var v = value - while (v and 0x7FL.inv() != 0L) { - buffer.write(((v.toInt() and 0x7F) or 0x80)) - v = v ushr 7 - } - buffer.write(v.toInt() and 0x7F) - } - - fun toByteArray(): ByteArray = buffer.toByteArray() - } - - // ============ Protobuf 解码工具 ============ - - private class ProtoReader(private val data: ByteArray) { - private var pos = 0 - - fun hasMore(): Boolean = pos < data.size - - fun readTag(): Int = readRawVarint().toInt() - - fun readVarint(): Long = readRawVarint() - - fun readLengthDelimited(): ByteArray { - val length = readRawVarint().toInt() - if (length < 0 || pos + length > data.size) { - throw IllegalStateException("Invalid length: $length at pos $pos, data size ${data.size}") - } - val result = data.copyOfRange(pos, pos + length) - pos += length - return result - } - - fun readStringField(wireType: Int): String { - return if (wireType == 2) { - String(readLengthDelimited(), Charsets.UTF_8) - } else { - readRawVarint() // skip varint - "" - } - } - - fun skip(wireType: Int) { - when (wireType) { - 0 -> readRawVarint() // varint - 1 -> pos += 8 // 64-bit - 2 -> { // length-delimited - val len = readRawVarint().toInt() - pos += len - } - 5 -> pos += 4 // 32-bit - else -> throw IllegalStateException("Unknown wire type: $wireType") - } - } - - private fun readRawVarint(): Long { - var result = 0L - var shift = 0 - while (pos < data.size) { - val b = data[pos++].toInt() and 0xFF - result = result or ((b.toLong() and 0x7F) shl shift) - if (b and 0x80 == 0) break - shift += 7 - if (shift >= 64) throw IllegalStateException("Varint too long") - } - return result - } - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/AsrInterface.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/AsrInterface.kt deleted file mode 100644 index 91451934..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/AsrInterface.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.asr_tr_tts_manager.asr - -enum class AsrProvider { - AZURE, - VOLCENGINE, -} - -data class AsrResult(val text: String, val language: String) - -interface AsrInterface { - var onRecognizing: ((AsrResult) -> Unit)? - var onRecognized: (suspend (AsrResult) -> Unit)? - - fun startRecognition() - fun stopRecognition() - fun pushStreamWrite(bytes: ByteArray) - fun pushStreamClose() - fun dispose() -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/AsrManager.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/AsrManager.kt deleted file mode 100644 index 17147439..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/AsrManager.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.example.asr_tr_tts_manager.asr - -class AsrManager( - provider: AsrProvider = AsrProvider.AZURE, - languages: List = listOf("zh-CN", "en-US"), - private var autoDetectLanguage: Boolean = false, -) : AsrInterface { - private var currentProvider: AsrProvider = provider - private var currentLanguages: List = languages - private var delegate: AsrInterface? = null - - override var onRecognizing: ((AsrResult) -> Unit)? = null - set(value) { - field = value - delegate?.onRecognizing = value - } - - override var onRecognized: (suspend (AsrResult) -> Unit)? = null - set(value) { - field = value - delegate?.onRecognized = value - } - - init { - switchProvider() - } - - fun switchProvider(provider: AsrProvider = currentProvider, languages: List = currentLanguages, autoDetectLanguage: Boolean = this.autoDetectLanguage) { - try { - delegate?.dispose() - } catch (_: Exception) { - } - - currentProvider = provider - currentLanguages = languages - this.autoDetectLanguage = autoDetectLanguage - - delegate = - when (provider) { - AsrProvider.AZURE -> AzureAsr(languages, autoDetectLanguage) - AsrProvider.VOLCENGINE -> VolcengineAsr(languages, autoDetectLanguage) - }.also { - it.onRecognizing = onRecognizing - it.onRecognized = onRecognized - } - } - - fun getProvider(): AsrProvider = currentProvider - - override fun startRecognition() { - delegate?.startRecognition() - } - - override fun stopRecognition() { - delegate?.stopRecognition() - } - - override fun pushStreamWrite(bytes: ByteArray) { - delegate?.pushStreamWrite(bytes) - } - - override fun pushStreamClose() { - delegate?.pushStreamClose() - } - - override fun dispose() { - onRecognizing = null - onRecognized = null - - val d = delegate - delegate = null - d?.dispose() - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/AzureAsr.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/AzureAsr.kt deleted file mode 100644 index 9fd1331e..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/AzureAsr.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.example.asr_tr_tts_manager.asr - -import android.util.Log -import com.microsoft.cognitiveservices.speech.* -import com.microsoft.cognitiveservices.speech.audio.* -import kotlinx.coroutines.launch - -class AzureAsr( - private val languages: List = listOf("zh-CN", "en-US"), - private val autoDetectLanguage: Boolean = false, -) : AsrInterface { - override var onRecognizing: ((AsrResult) -> Unit)? = null - override var onRecognized: (suspend (AsrResult) -> Unit)? = null - - private var pushStream: PushAudioInputStream? = null - private var recognizer: SpeechRecognizer? = null - private val SUBSCRIPTION_KEY: String = - "rLPdyGgD1v7z7khzpdrd4YNWUinjw6GzAbA1pPXBRmUjitfjDxdsJQQJ99BGAC3pKaRXJ3w3AAAYACOGAHZZ" - private val REGION: String = "eastasia" - - private val job = kotlinx.coroutines.SupervisorJob() - private val coroutineScope = - kotlinx.coroutines.CoroutineScope(job + kotlinx.coroutines.Dispatchers.IO) - - init { - val audioFormat = AudioStreamFormat.getWaveFormatPCM(16000L, 16, 1) - pushStream = AudioInputStream.createPushStream(audioFormat) - - val audioConfig = AudioConfig.fromStreamInput(pushStream) - val speechConfig = SpeechConfig.fromSubscription(SUBSCRIPTION_KEY, REGION) - - if (autoDetectLanguage) { - speechConfig.setProperty(PropertyId.SpeechServiceConnection_LanguageIdMode, "Continuous") - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(languages) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) - } else { - speechConfig.speechRecognitionLanguage = languages.first() - recognizer = SpeechRecognizer(speechConfig, audioConfig) - } - - recognizer?.recognizing?.addEventListener { _, event -> - val text = event.result.text.trim() - if (text.isEmpty()) return@addEventListener - - val language = if (autoDetectLanguage) { - AutoDetectSourceLanguageResult.fromResult(event.result).language ?: "" - } else { - languages.first() - } - onRecognizing?.invoke(AsrResult(text, language)) - } - - recognizer?.recognized?.addEventListener { _, event -> - if (event.result.reason != ResultReason.RecognizedSpeech) return@addEventListener - - val text = event.result.text.trim() - val language = if (autoDetectLanguage) { - AutoDetectSourceLanguageResult.fromResult(event.result).language ?: "" - } else { - languages.first() - } - coroutineScope.launch { - onRecognized?.invoke(AsrResult(text, language)) - } - } - } - - override fun startRecognition() { - recognizer?.startContinuousRecognitionAsync()?.get() - } - - override fun stopRecognition() { - recognizer?.stopContinuousRecognitionAsync()?.get() - } - - override fun pushStreamWrite(bytes: ByteArray) { - pushStream?.write(bytes) - } - - override fun pushStreamClose() { - pushStream?.close() - } - - override fun dispose() { - try { - recognizer?.stopContinuousRecognitionAsync()?.get() - } catch (e: Exception) { - Log.w("AzureAsr", "stop failed: ${e.message}") - } finally { - pushStream?.close() - recognizer?.close() - job.cancel() - } - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/VolcengineAsr.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/VolcengineAsr.kt deleted file mode 100644 index 8991b480..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/asr/VolcengineAsr.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.example.asr_tr_tts_manager.asr - -import android.util.Log -import com.microsoft.cognitiveservices.speech.* -import com.microsoft.cognitiveservices.speech.audio.* -import kotlinx.coroutines.launch - -class VolcengineAsr( - private val languages: List = listOf("zh-CN", "en-US"), - private val autoDetectLanguage: Boolean = false, -) : AsrInterface { - override var onRecognizing: ((AsrResult) -> Unit)? = null - override var onRecognized: (suspend (AsrResult) -> Unit)? = null - - private var pushStream: PushAudioInputStream? = null - private var recognizer: SpeechRecognizer? = null - private val SUBSCRIPTION_KEY: String = - "rLPdyGgD1v7z7khzpdrd4YNWUinjw6GzAbA1pPXBRmUjitfjDxdsJQQJ99BGAC3pKaRXJ3w3AAAYACOGAHZZ" - private val REGION: String = "eastasia" - - private val job = kotlinx.coroutines.SupervisorJob() - private val coroutineScope = - kotlinx.coroutines.CoroutineScope(job + kotlinx.coroutines.Dispatchers.IO) - - init { - val audioFormat = AudioStreamFormat.getWaveFormatPCM(16000L, 16, 1) - pushStream = AudioInputStream.createPushStream(audioFormat) - - val audioConfig = AudioConfig.fromStreamInput(pushStream) - val speechConfig = SpeechConfig.fromSubscription(SUBSCRIPTION_KEY, REGION) - - if (autoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(languages) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) - } else { - speechConfig.speechRecognitionLanguage = languages.first() - recognizer = SpeechRecognizer(speechConfig, audioConfig) - } - - recognizer?.recognizing?.addEventListener { _, event -> - val text = event.result.text.trim() - if (text.isEmpty()) return@addEventListener - - val language = if (autoDetectLanguage) { - AutoDetectSourceLanguageResult.fromResult(event.result).language ?: "" - } else { - languages.first() - } - onRecognizing?.invoke(AsrResult(text, language)) - } - - recognizer?.recognized?.addEventListener { _, event -> - if (event.result.reason != ResultReason.RecognizedSpeech) return@addEventListener - - val text = event.result.text.trim() - val language = if (autoDetectLanguage) { - AutoDetectSourceLanguageResult.fromResult(event.result).language ?: "" - } else { - languages.first() - } - coroutineScope.launch { - onRecognized?.invoke(AsrResult(text, language)) - } - } - } - - override fun startRecognition() { - recognizer?.startContinuousRecognitionAsync()?.get() - } - - override fun stopRecognition() { - recognizer?.stopContinuousRecognitionAsync()?.get() - } - - override fun pushStreamWrite(bytes: ByteArray) { - pushStream?.write(bytes) - } - - override fun pushStreamClose() { - pushStream?.close() - } - - override fun dispose() { - try { - recognizer?.stopContinuousRecognitionAsync()?.get() - } catch (e: Exception) { - Log.w("VolcengineAsr", "stop failed: ${e.message}") - } finally { - pushStream?.close() - recognizer?.close() - job.cancel() - } - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/recorder/AstStereoRecorder.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/recorder/AstStereoRecorder.kt deleted file mode 100644 index 1099688e..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/recorder/AstStereoRecorder.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.example.asr_tr_tts_manager.recorder - -/** - * 通话翻译模式立体声录音器 - * - * 由本端麦克风 PCM(来自 BLE 输入的左声道)作为时钟,按 16k/16bit 的节奏 - * 持续写入录音文件;对方翻译后的合成 PCM(B 路 AST 输出)异步排入右声道 FIFO, - * 与左声道按相同样本数交错合成立体声后写出。 - * - * 输出格式:16 kHz / 16-bit PCM / 2 channels WAV。 - */ -class AstStereoRecorder { - - private val lock = Any() - private var recordFile: RecordFile? = null - private val rightFifo = ArrayDeque() - - @Volatile - private var running = false - - @Volatile - private var paused = false - - /** - * 调用点用的轻量级开关:调用方可在不加锁的情况下快速判定是否需要把音频灌进录音器。 - * 在 start/pause/resume/stop 中同步更新;接受 start/stop 瞬间一两帧的偏差。 - */ - val isActive: Boolean - get() = running && !paused - - /** - * 开始录音 - * @param filePath 输出文件路径(.wav) - */ - fun start(filePath: String): Boolean { - synchronized(lock) { - // 已在录音,先停掉旧的 - recordFile?.closeFile(false) - recordFile = null - rightFifo.clear() - paused = false - - val file = RecordFile() - file.setAudioConfig(16000, 2) - file.creatingFiles(filePath) - recordFile = file - running = true - return true - } - } - - fun pause(): Boolean { - paused = true - return true - } - - fun resume(): Boolean { - paused = false - return true - } - - fun stop(isSave: Boolean): Boolean { - synchronized(lock) { - val file = recordFile ?: return false - running = false - paused = false - val ok = file.closeFile(isSave) - recordFile = null - rightFifo.clear() - return ok - } - } - - /** - * 排入右声道(B 路对方原音 / AST 合成出的对方翻译 PCM) - * @param pcm 16 kHz / 16-bit PCM mono - */ - fun enqueueRightChannel(pcm: ByteArray) { - if (pcm.isEmpty()) return - synchronized(lock) { - if (!running || paused) return - for (b in pcm) { - rightFifo.addLast(b) - } - // 左声道(时钟)长时间缺席时右声道 FIFO 会无限增长;超出上限丢弃最旧字节,避免 OOM - while (rightFifo.size > MAX_RIGHT_FIFO_BYTES) { - rightFifo.removeFirst() - } - } - } - - /** - * 写入一帧右声道(对方/远端 PCM),与 enqueueRightChannel 等价的别名, - * 让调用方按 writeLeftChannel / writeRightChannel 成对调用更直观。 - * @param pcm 16 kHz / 16-bit PCM mono - */ - fun writeRightChannel(pcm: ByteArray) { - enqueueRightChannel(pcm) - } - - /** - * 写入一帧左声道(本端麦克风 PCM)并消费等量右声道字节,交错后落盘 - * @param mono 16 kHz / 16-bit PCM mono(来自 BLE 输入解出的 leftBuffer) - */ - fun writeLeftChannel(mono: ByteArray) { - if (mono.isEmpty()) return - // 左声道字节数必须是偶数(16-bit 对齐) - val leftBytes = mono.size - (mono.size % 2) - if (leftBytes == 0) return - - val stereo: ByteArray - val fileToWrite: RecordFile? - synchronized(lock) { - if (!running || paused) return - val file = recordFile ?: return - - val right = ByteArray(leftBytes) - val popCount = minOf(rightFifo.size, leftBytes) - for (i in 0 until popCount) { - right[i] = rightFifo.removeFirst() - } - // 不足部分 right 数组初始为 0(静音填充) - - stereo = ByteArray(leftBytes * 2) - val sampleCount = leftBytes / 2 - for (i in 0 until sampleCount) { - val srcIdx = i * 2 - val dstIdx = i * 4 - // 左声道:本端麦克风原音,应用 LEFT_GAIN 拉响 - val lOutU = gainSoftClip(mono[srcIdx], mono[srcIdx + 1], LEFT_GAIN) - stereo[dstIdx] = (lOutU and 0xFF).toByte() - stereo[dstIdx + 1] = ((lOutU shr 8) and 0xFF).toByte() - // 右声道:对方原音 / B 路 TTS 合成音,应用 RIGHT_GAIN 压低 - val rOutU = gainSoftClip(right[srcIdx], right[srcIdx + 1], RIGHT_GAIN) - stereo[dstIdx + 2] = (rOutU and 0xFF).toByte() - stereo[dstIdx + 3] = ((rOutU shr 8) and 0xFF).toByte() - } - fileToWrite = file - } - - fileToWrite?.saveAudioDataToWav(stereo) - } - - /** - * 对一个 16-bit little-endian PCM 样本施加线性增益并 tanh 软削波。 - * 返回 [0, 0xFFFF] 区间的 Int(即 UInt16 的 bit pattern),交给调用方拆成两个字节。 - * gain==1.0 时短路返回原始 bit pattern,避免无谓浮点。 - */ - private fun gainSoftClip(lo: Byte, hi: Byte, gain: Float): Int { - val unsigned = (lo.toInt() and 0xFF) or ((hi.toInt() and 0xFF) shl 8) - if (gain == 1.0f) return unsigned - val s = unsigned.toShort().toInt() // 还原为带符号 Int16 - val x = (s.toFloat() / 32767.0f) * gain - val y = Math.tanh(x.toDouble()).toFloat() - var v = y * 32767.0f - if (v > 32767.0f) v = 32767.0f - if (v < -32768.0f) v = -32768.0f - return v.toInt() and 0xFFFF - } - - companion object { - // 通话翻译录音中麦克风原音偏小、对方偏响,对两路分别做线性增益再 tanh 软削波。 - // 左(麦克风) +12dB ≈ 3.98x,右(对方) -4dB ≈ 0.63x,与 iOS 端 AstStereoRecorder 保持一致。 - private const val LEFT_GAIN = 3.98f - private const val RIGHT_GAIN = 0.63f - - // 右声道 FIFO 上限(约 5 秒 @16kHz/16bit 单声道),防止无限增长导致 OOM - private const val MAX_RIGHT_FIFO_BYTES = 16000 * 2 * 5 - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/recorder/RecordFile.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/recorder/RecordFile.kt deleted file mode 100644 index f887cc25..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/recorder/RecordFile.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.example.asr_tr_tts_manager.recorder - -import android.util.Log -import java.io.File -import java.io.FileOutputStream -import java.io.RandomAccessFile -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean - -/** - * WAV 录音文件写入器(16-bit PCM,单/双声道可配)。 - * - * 流程:creatingFiles() 先写占位 WAV 头并启动后台写线程;saveAudioDataToWav() - * 把每帧 PCM 入队由后台线程追加到文件尾;closeFile() 停线程并回填真实长度, - * 得到合法 WAV(isSave=false 或只有头部则删除文件)。 - * - * 单声道:同声/设备辅助翻译直接使用。双声道:通话翻译由 AstStereoRecorder - * 先把左右声道交织成立体声帧后再调本类写入。 - */ -class RecordFile { - private var currentAudioFile: File? = null - private var fos: FileOutputStream? = null - - // 后台异步写入,避免在音频实时回调线程上做文件 I/O - private val writeQueue = LinkedBlockingQueue() - private val isWriting = AtomicBoolean(false) - private var writeThread: Thread? = null - - private var totalBytesWritten = 0 - - private var sampleRate = 16000 - private var channels = 1 // 1=单声道, 2=立体声 - - // 是否接收新帧。closeFile 时先置 false:保证写队列有限(join() 不会永久阻塞), - // 且关闭期间/之后回调再写入会被直接丢弃(防 use-after-close 与头长度错乱) - @Volatile - private var accepting = false - - fun setAudioConfig(sampleRate: Int, channels: Int) { - this.sampleRate = sampleRate - this.channels = channels - } - - fun creatingFiles(filePath: String) { - if (fos != null || currentAudioFile != null) return - - val file = File(filePath) - file.parentFile?.let { if (!it.exists()) it.mkdirs() } - currentAudioFile = file - file.createNewFile() - - // 先写一个 dataLength=0 的占位 WAV 头 - FileOutputStream(file).use { it.write(generateWavHeader(0)) } - - fos = FileOutputStream(file, true) - totalBytesWritten = 0 - startWriteThread() - accepting = true - } - - fun saveAudioDataToWav(buffer: ByteArray) { - if (!accepting) return - writeQueue.offer(buffer.copyOf()) - totalBytesWritten += buffer.size - } - - fun closeFile(isSave: Boolean): Boolean { - accepting = false // 先停止接收新帧,使写队列有限,join() 不会永久阻塞 - try { - if (fos == null || currentAudioFile == null) return false - isWriting.set(false) - // accepting=false 后队列有限,写线程通常毫秒级排空退出; - // 5s 兜底:极端 I/O 卡死时也不会永久阻塞调用线程 - writeThread?.join(5000) - fos?.close() - - val file = currentAudioFile ?: return false - return if (file.length() <= 44 || !isSave) { - file.delete() - false - } else { - // 回填真实的 RIFF/data 长度 - RandomAccessFile(file, "rw").use { raf -> - raf.seek(0) - raf.write(generateWavHeader(totalBytesWritten)) - } - true - } - } catch (e: Exception) { - Log.e(TAG, "关闭/回填 WAV 失败: ${e.message}") - return false - } finally { - fos = null - currentAudioFile = null - writeThread = null - totalBytesWritten = 0 - } - } - - private fun startWriteThread() { - if (isWriting.get()) return - isWriting.set(true) - writeThread = Thread { - try { - while (isWriting.get() || writeQueue.isNotEmpty()) { - // 阻塞等待最多 100ms,避免无超时 poll 的忙等空转 - val data = writeQueue.poll(100, TimeUnit.MILLISECONDS) ?: continue - fos?.write(data) - } - } catch (e: InterruptedException) { - // 收尾时被中断,正常退出 - } catch (e: Exception) { - Log.e(TAG, "异步写入音频失败: ${e.message}") - } - } - writeThread?.start() - } - - // 标准 16-bit PCM WAV 头(44 字节,little-endian) - private fun generateWavHeader(dataLength: Int): ByteArray { - val totalLength = 36 + dataLength - val bytesPerSample = 2 - val byteRate = sampleRate * channels * bytesPerSample - val blockAlign = channels * bytesPerSample - - return byteArrayOf( - 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), - (totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(), - ((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(), - 'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(), - 'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(), - 16, 0, 0, 0, // PCM 头长度 - 1, 0, // PCM 格式 - (channels and 0xFF).toByte(), ((channels shr 8) and 0xFF).toByte(), - (sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), - ((sampleRate shr 16) and 0xFF).toByte(), ((sampleRate shr 24) and 0xFF).toByte(), - (byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), - ((byteRate shr 16) and 0xFF).toByte(), ((byteRate shr 24) and 0xFF).toByte(), - (blockAlign and 0xFF).toByte(), ((blockAlign shr 8) and 0xFF).toByte(), - 16, 0, // 样本位数 - 'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), - (dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(), - ((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte() - ) - } - - companion object { - private const val TAG = "RecordFile" - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/AzureTranslation.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/AzureTranslation.kt deleted file mode 100644 index 9b64adb6..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/AzureTranslation.kt +++ /dev/null @@ -1,306 +0,0 @@ -package com.example.asr_tr_tts_manager.translation - -import kotlinx.coroutines.* -import java.util.concurrent.TimeUnit -import okhttp3.* -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.MediaType.Companion.toMediaType -import org.json.JSONObject -import org.json.JSONArray -import java.net.URLEncoder -import java.security.MessageDigest -import java.text.SimpleDateFormat -import java.util.* -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -class AzureTranslation : TranslationInterface { - private val BASE_URL = "https://translate.volcengineapi.com" - private val ENDPOINT = "/" - private val SERVICE = "translate" - private val VERSION = "2020-06-01" - private val ACTION = "TranslateText" - private val ALGORITHM = "HMAC-SHA256" - - private var accessKey: String = "AKLTZjJmODdkODQwN2RjNDcyMWI4NWM2ZWRmNDkxY2RiYzI" - private var secretKey: String = "T0Roa09EZGlNalZpTjJObE5HVmlZMkV3WVRkbVpUTTBORGMzTW1FNFlqYw==" - private var region: String = "cn-north-1" - private var maxRetryAttempts: Int = 3 - private var timeout: Long = 10000L - - private val httpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - override suspend fun translateText( - text: String, - sourceLanguage: String, - targetLanguage: String - ): TranslationResult { - return withContext(Dispatchers.IO) { - performTranslationWithRetry(text, sourceLanguage, targetLanguage) - } - } - - private suspend fun performTranslationWithRetry( - text: String, - sourceLanguage: String, - targetLanguage: String - ): TranslationResult { - var lastException: Exception? = null - - repeat(maxRetryAttempts) { attempt -> - try { - val result = performTranslation(text, sourceLanguage, targetLanguage) - if (result.success) { - return result - } - lastException = Exception(result.error) - } catch (e: Exception) { - lastException = e - - // 重试延迟 - if (attempt < maxRetryAttempts - 1) { - delay(500L * (attempt + 1)) - } - } - } - - return TranslationResult( - success = false, - error = "翻译失败: ${lastException?.message ?: "未知错误"}" - ) - } - - private suspend fun performTranslation( - text: String, - sourceLanguage: String, - targetLanguage: String - ): TranslationResult { - try { - // 构建请求体 - val requestBody = JSONObject().apply { - put("SourceLanguage", sourceLanguage) - put("TargetLanguage", targetLanguage) - put("TextList", JSONArray().put(text)) - } - - // 查询参数 - val queryParams = mapOf( - "Action" to ACTION, - "Version" to VERSION, - "Region" to region, - "Service" to SERVICE - ) - - // 生成签名 - val headers = generateSignature("POST", requestBody, queryParams) - - // 构建URL - val urlBuilder = BASE_URL.toHttpUrl().newBuilder() - queryParams.forEach { (key, value) -> - urlBuilder.addQueryParameter(key, value) - } - val url = urlBuilder.build() - - // 构建请求 - val requestBodyObj = RequestBody.create( - "application/json; charset=utf-8".toMediaType(), - requestBody.toString() - ) - - val request = Request.Builder() - .url(url) - .post(requestBodyObj) - .apply { - headers.forEach { (key, value) -> - addHeader(key, value) - } - } - .build() - - // 发送请求 - val response = httpClient.newCall(request).execute() - - if (response.isSuccessful) { - val responseBody = response.body?.string() - if (responseBody != null) { - val jsonResponse = JSONObject(responseBody) - val translation = extractTranslation(jsonResponse) - - if (translation != null) { - return TranslationResult( - success = true, - translatedText = translation, - confidence = 0.9f - ) - } else { - val error = extractError(jsonResponse) - return TranslationResult( - success = false, - error = error ?: "翻译结果为空" - ) - } - } - } - - return TranslationResult( - success = false, - error = "HTTP错误: ${response.code}" - ) - - } catch (e: Exception) { - return TranslationResult( - success = false, - error = "请求异常: ${e.message}" - ) - } - } - - private fun extractTranslation(jsonResponse: JSONObject): String? { - try { - // 1. 标准响应结构 - if (jsonResponse.has("TranslationList")) { - val translationList = jsonResponse.getJSONArray("TranslationList") - if (translationList.length() > 0) { - val translationItem = translationList.getJSONObject(0) - if (translationItem.has("Translation")) { - return translationItem.getString("Translation") - } - } - } - - // 2. 其他可能的响应结构 - if (jsonResponse.has("Translation")) { - return jsonResponse.getString("Translation") - } - - if (jsonResponse.has("Result")) { - val result = jsonResponse.getJSONObject("Result") - if (result.has("Translation")) { - return result.getString("Translation") - } - } - - if (jsonResponse.has("Data")) { - val data = jsonResponse.getJSONObject("Data") - if (data.has("Translation")) { - return data.getString("Translation") - } - if (data.has("TranslationList")) { - val translationList = data.getJSONArray("TranslationList") - if (translationList.length() > 0) { - val translationItem = translationList.getJSONObject(0) - if (translationItem.has("Translation")) { - return translationItem.getString("Translation") - } - } - } - } - } catch (_: Exception) { - - } - - return null - } - - private fun extractError(jsonResponse: JSONObject): String? { - try { - if (jsonResponse.has("ResponseMetadata")) { - val metadata = jsonResponse.getJSONObject("ResponseMetadata") - if (metadata.has("Error")) { - val error = metadata.getJSONObject("Error") - return error.optString("Message", error.optString("Code", "未知错误")) - } - } - - if (jsonResponse.has("Error")) { - val error = jsonResponse.getJSONObject("Error") - return error.optString("Message", error.optString("Code", "未知错误")) - } - } catch (_: Exception) { - } - - return null - } - - private fun generateSignature( - method: String, - requestBody: JSONObject, - queryParams: Map - ): Map { - try { - // 1. 准备时间相关参数 - val now = Date() - val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - val timestampFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - val date = dateFormat.format(now) - val timestamp = timestampFormat.format(now) - - // 2. 构建规范查询字符串 - val sortedParams = queryParams.toSortedMap() - val canonicalQueryString = sortedParams.map { (key, value) -> - "${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}" - }.joinToString("&") - - // 3. 创建规范请求 - val contentType = "application/json" - val payloadHash = sha256(requestBody.toString()) - val host = "translate.volcengineapi.com" - - val canonicalHeaders = "host:$host\nx-date:$timestamp\n" - val signedHeaders = "host;x-date" - - val canonicalRequest = - "$method\n$ENDPOINT\n$canonicalQueryString\n$canonicalHeaders\n$signedHeaders\n$payloadHash" - - // 4. 创建待签字符串 - val credentialScope = "$date/$region/$SERVICE/request" - val stringToSign = - "$ALGORITHM\n$timestamp\n$credentialScope\n${sha256(canonicalRequest)}" - - // 5. 计算签名 - val kSecret = secretKey.toByteArray(Charsets.UTF_8) - val kDate = hmacSha256(kSecret, date) - val kRegion = hmacSha256(kDate, region) - val kService = hmacSha256(kRegion, SERVICE) - val kSigning = hmacSha256(kService, "request") - val signature = - hmacSha256(kSigning, stringToSign).joinToString("") { "%02x".format(it) } - - // 6. 构建授权头 - val authorization = - "$ALGORITHM Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature" - - return mapOf( - "Content-Type" to contentType, - "X-Date" to timestamp, - "Authorization" to authorization, - "Host" to host - ) - - } catch (e: Exception) { - throw e - } - } - - private fun sha256(input: String): String { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(input.toByteArray(Charsets.UTF_8)) - return hash.joinToString("") { "%02x".format(it) } - } - - private fun hmacSha256(key: ByteArray, data: String): ByteArray { - val mac = Mac.getInstance("HmacSHA256") - val secretKeySpec = SecretKeySpec(key, "HmacSHA256") - mac.init(secretKeySpec) - return mac.doFinal(data.toByteArray(Charsets.UTF_8)) - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/TranslationInterface.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/TranslationInterface.kt deleted file mode 100644 index e8842d50..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/TranslationInterface.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.asr_tr_tts_manager.translation - -enum class TranslationProvider { - AZURE, - VOLCENGINE, -} - -data class TranslationResult( - val success: Boolean, - val translatedText: String? = null, - val error: String? = null, - val confidence: Float = 0f -) - -interface TranslationInterface { - suspend fun translateText(text: String, sourceLanguage: String, targetLanguage: String): TranslationResult -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/TranslationManager.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/TranslationManager.kt deleted file mode 100644 index 6a2095a2..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/TranslationManager.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.asr_tr_tts_manager.translation - -class TranslationManager( - provider: TranslationProvider = TranslationProvider.AZURE, -) : TranslationInterface { - - private var currentProvider: TranslationProvider = provider - private var delegate: TranslationInterface? = null - - init { - switchProvider() - } - - fun switchProvider(provider: TranslationProvider = currentProvider) { - currentProvider = provider - - delegate = - when (provider) { - TranslationProvider.AZURE -> AzureTranslation() - TranslationProvider.VOLCENGINE -> VolcengineTranslation() - } - } - - fun getProvider(): TranslationProvider = currentProvider - - override suspend fun translateText( - text: String, - sourceLanguage: String, - targetLanguage: String, - ): TranslationResult { - return delegate?.translateText(text, sourceLanguage, targetLanguage) - ?: TranslationResult(success = false, error = "TranslationManager not initialized") - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/VolcengineTranslation.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/VolcengineTranslation.kt deleted file mode 100644 index eac18ba3..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation/VolcengineTranslation.kt +++ /dev/null @@ -1,306 +0,0 @@ -package com.example.asr_tr_tts_manager.translation - -import kotlinx.coroutines.* -import java.util.concurrent.TimeUnit -import okhttp3.* -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.MediaType.Companion.toMediaType -import org.json.JSONObject -import org.json.JSONArray -import java.net.URLEncoder -import java.security.MessageDigest -import java.text.SimpleDateFormat -import java.util.* -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -class VolcengineTranslation : TranslationInterface { - private val BASE_URL = "https://translate.volcengineapi.com" - private val ENDPOINT = "/" - private val SERVICE = "translate" - private val VERSION = "2020-06-01" - private val ACTION = "TranslateText" - private val ALGORITHM = "HMAC-SHA256" - - private var accessKey: String = "AKLTZjJmODdkODQwN2RjNDcyMWI4NWM2ZWRmNDkxY2RiYzI" - private var secretKey: String = "T0Roa09EZGlNalZpTjJObE5HVmlZMkV3WVRkbVpUTTBORGMzTW1FNFlqYw==" - private var region: String = "cn-north-1" - private var maxRetryAttempts: Int = 3 - private var timeout: Long = 10000L - - private val httpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - override suspend fun translateText( - text: String, - sourceLanguage: String, - targetLanguage: String - ): TranslationResult { - return withContext(Dispatchers.IO) { - performTranslationWithRetry(text, sourceLanguage, targetLanguage) - } - } - - private suspend fun performTranslationWithRetry( - text: String, - sourceLanguage: String, - targetLanguage: String - ): TranslationResult { - var lastException: Exception? = null - - repeat(maxRetryAttempts) { attempt -> - try { - val result = performTranslation(text, sourceLanguage, targetLanguage) - if (result.success) { - return result - } - lastException = Exception(result.error) - } catch (e: Exception) { - lastException = e - - // 重试延迟 - if (attempt < maxRetryAttempts - 1) { - delay(500L * (attempt + 1)) - } - } - } - - return TranslationResult( - success = false, - error = "翻译失败: ${lastException?.message ?: "未知错误"}" - ) - } - - private suspend fun performTranslation( - text: String, - sourceLanguage: String, - targetLanguage: String - ): TranslationResult { - try { - // 构建请求体 - val requestBody = JSONObject().apply { - put("SourceLanguage", sourceLanguage) - put("TargetLanguage", targetLanguage) - put("TextList", JSONArray().put(text)) - } - - // 查询参数 - val queryParams = mapOf( - "Action" to ACTION, - "Version" to VERSION, - "Region" to region, - "Service" to SERVICE - ) - - // 生成签名 - val headers = generateSignature("POST", requestBody, queryParams) - - // 构建URL - val urlBuilder = BASE_URL.toHttpUrl().newBuilder() - queryParams.forEach { (key, value) -> - urlBuilder.addQueryParameter(key, value) - } - val url = urlBuilder.build() - - // 构建请求 - val requestBodyObj = RequestBody.create( - "application/json; charset=utf-8".toMediaType(), - requestBody.toString() - ) - - val request = Request.Builder() - .url(url) - .post(requestBodyObj) - .apply { - headers.forEach { (key, value) -> - addHeader(key, value) - } - } - .build() - - // 发送请求 - val response = httpClient.newCall(request).execute() - - if (response.isSuccessful) { - val responseBody = response.body?.string() - if (responseBody != null) { - val jsonResponse = JSONObject(responseBody) - val translation = extractTranslation(jsonResponse) - - if (translation != null) { - return TranslationResult( - success = true, - translatedText = translation, - confidence = 0.9f - ) - } else { - val error = extractError(jsonResponse) - return TranslationResult( - success = false, - error = error ?: "翻译结果为空" - ) - } - } - } - - return TranslationResult( - success = false, - error = "HTTP错误: ${response.code}" - ) - - } catch (e: Exception) { - return TranslationResult( - success = false, - error = "请求异常: ${e.message}" - ) - } - } - - private fun extractTranslation(jsonResponse: JSONObject): String? { - try { - // 1. 标准响应结构 - if (jsonResponse.has("TranslationList")) { - val translationList = jsonResponse.getJSONArray("TranslationList") - if (translationList.length() > 0) { - val translationItem = translationList.getJSONObject(0) - if (translationItem.has("Translation")) { - return translationItem.getString("Translation") - } - } - } - - // 2. 其他可能的响应结构 - if (jsonResponse.has("Translation")) { - return jsonResponse.getString("Translation") - } - - if (jsonResponse.has("Result")) { - val result = jsonResponse.getJSONObject("Result") - if (result.has("Translation")) { - return result.getString("Translation") - } - } - - if (jsonResponse.has("Data")) { - val data = jsonResponse.getJSONObject("Data") - if (data.has("Translation")) { - return data.getString("Translation") - } - if (data.has("TranslationList")) { - val translationList = data.getJSONArray("TranslationList") - if (translationList.length() > 0) { - val translationItem = translationList.getJSONObject(0) - if (translationItem.has("Translation")) { - return translationItem.getString("Translation") - } - } - } - } - } catch (_: Exception) { - - } - - return null - } - - private fun extractError(jsonResponse: JSONObject): String? { - try { - if (jsonResponse.has("ResponseMetadata")) { - val metadata = jsonResponse.getJSONObject("ResponseMetadata") - if (metadata.has("Error")) { - val error = metadata.getJSONObject("Error") - return error.optString("Message", error.optString("Code", "未知错误")) - } - } - - if (jsonResponse.has("Error")) { - val error = jsonResponse.getJSONObject("Error") - return error.optString("Message", error.optString("Code", "未知错误")) - } - } catch (_: Exception) { - } - - return null - } - - private fun generateSignature( - method: String, - requestBody: JSONObject, - queryParams: Map - ): Map { - try { - // 1. 准备时间相关参数 - val now = Date() - val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - val timestampFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - val date = dateFormat.format(now) - val timestamp = timestampFormat.format(now) - - // 2. 构建规范查询字符串 - val sortedParams = queryParams.toSortedMap() - val canonicalQueryString = sortedParams.map { (key, value) -> - "${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}" - }.joinToString("&") - - // 3. 创建规范请求 - val contentType = "application/json" - val payloadHash = sha256(requestBody.toString()) - val host = "translate.volcengineapi.com" - - val canonicalHeaders = "host:$host\nx-date:$timestamp\n" - val signedHeaders = "host;x-date" - - val canonicalRequest = - "$method\n$ENDPOINT\n$canonicalQueryString\n$canonicalHeaders\n$signedHeaders\n$payloadHash" - - // 4. 创建待签字符串 - val credentialScope = "$date/$region/$SERVICE/request" - val stringToSign = - "$ALGORITHM\n$timestamp\n$credentialScope\n${sha256(canonicalRequest)}" - - // 5. 计算签名 - val kSecret = secretKey.toByteArray(Charsets.UTF_8) - val kDate = hmacSha256(kSecret, date) - val kRegion = hmacSha256(kDate, region) - val kService = hmacSha256(kRegion, SERVICE) - val kSigning = hmacSha256(kService, "request") - val signature = - hmacSha256(kSigning, stringToSign).joinToString("") { "%02x".format(it) } - - // 6. 构建授权头 - val authorization = - "$ALGORITHM Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature" - - return mapOf( - "Content-Type" to contentType, - "X-Date" to timestamp, - "Authorization" to authorization, - "Host" to host - ) - - } catch (e: Exception) { - throw e - } - } - - private fun sha256(input: String): String { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(input.toByteArray(Charsets.UTF_8)) - return hash.joinToString("") { "%02x".format(it) } - } - - private fun hmacSha256(key: ByteArray, data: String): ByteArray { - val mac = Mac.getInstance("HmacSHA256") - val secretKeySpec = SecretKeySpec(key, "HmacSHA256") - mac.init(secretKeySpec) - return mac.doFinal(data.toByteArray(Charsets.UTF_8)) - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/CallTranslationHelper.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/CallTranslationHelper.kt deleted file mode 100644 index 6c56f2e6..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/CallTranslationHelper.kt +++ /dev/null @@ -1,217 +0,0 @@ -package com.example.asr_tr_tts_manager.translation_option - -import android.content.Context -import android.os.Handler -import android.os.Looper -import com.example.asr_tr_tts_manager.VolcengineHelper -import com.example.asr_tr_tts_manager.recorder.AstStereoRecorder -import com.example.bluetooth_manager.BluetoothManager - -class CallTranslationHelper(private val context: Context) : TranslationHelperInterface { - private val mainHandler = Handler(Looper.getMainLooper()) - - private var asrTrTtsHelperSpk: VolcengineHelper? = null - private var asrTrTtsHelperMic: VolcengineHelper? = null - - override var callback: ((Map) -> Unit)? = null - - // 通话翻译录音:stereo。左=本端麦克风(micData),右=对方原音(spkData),均为解码后 16k/16bit PCM。 - @Volatile - private var stereoRecorder: AstStereoRecorder? = null - - @Volatile - private var isSending = false - private val spkQueue = java.util.concurrent.LinkedBlockingQueue(4096) - private val micQueue = java.util.concurrent.LinkedBlockingQueue(4096) - - private val sendScheduler = java.util.concurrent.ScheduledThreadPoolExecutor(1) - private var sendTask: java.util.concurrent.ScheduledFuture<*>? = null - private var sendInterval: Long = 20L - private val packetBuf = ByteArray(84) - private var isRecognitionStopped = true - private val silenceData = byteArrayOf( - 0x0A.toByte(), 0x8E.toByte(), 0x30.toByte(), 0xBD.toByte(), 0x3F.toByte(), 0x83.toByte(), 0xFF.toByte(), 0x00.toByte(), - 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), - 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte() - ) - - init { - asrTrTtsHelperSpk = VolcengineHelper() - asrTrTtsHelperMic = VolcengineHelper() - BluetoothManager.translationCallback = { data -> - when (data["type"]) { - "startCall" -> startRecognition() - "stopCall" -> stopRecognition() - "spkData" -> spkData(data["data"] as ByteArray) - "micData" -> micData(data["data"] as ByteArray) - "speedAdjustment" -> handleSpeedAdjustment(data["data"] as ByteArray) - } - } - asrTrTtsHelperSpk?.callback = { data -> - publishData(data + ("side" to "right")) - } - asrTrTtsHelperMic?.callback = { data -> - publishData(data + ("side" to "left")) - } - asrTrTtsHelperSpk?.onSynthesisEncoded = { bytes -> - if (!spkQueue.offer(bytes)) { - spkQueue.poll() - spkQueue.offer(bytes) - } - } - asrTrTtsHelperMic?.onSynthesisEncoded = { bytes -> - if (!micQueue.offer(bytes)) { - micQueue.poll() - micQueue.offer(bytes) - } - } - } - - override fun start(sourceLanguage: String, targetLanguage: String) { - asrTrTtsHelperSpk?.switchProvider( - languages = listOf(targetLanguage, sourceLanguage) - ) - asrTrTtsHelperMic?.switchProvider( - languages = listOf(sourceLanguage, targetLanguage) - ) - BluetoothManager.writeBytes( - byteArrayOf(0xaa.toByte(), 0x51.toByte()) - ) - } - - override fun stop() { - BluetoothManager.writeBytes( - byteArrayOf(0xaa.toByte(), 0x52.toByte()) - ) - } - - private fun startRecognition() { - if (!isRecognitionStopped) return - isRecognitionStopped = false - BluetoothManager.writeBytes(byteArrayOf(0xaa.toByte(), 0x57.toByte())) - asrTrTtsHelperSpk?.startRecognition() - asrTrTtsHelperMic?.startRecognition() - spkQueue.clear() - micQueue.clear() - isSending = true - sendTask?.cancel(false) - sendTask = sendScheduler.scheduleWithFixedDelay( - { sendAudioData() }, - 0, - sendInterval, - java.util.concurrent.TimeUnit.MILLISECONDS - ) - publishData(mapOf("type" to "startCall")) - } - - private fun stopRecognition() { - if (isRecognitionStopped) return - isRecognitionStopped = true - BluetoothManager.writeBytes(byteArrayOf(0xaa.toByte(), 0x58.toByte())) - asrTrTtsHelperSpk?.stopRecognition() - asrTrTtsHelperMic?.stopRecognition() - isSending = false - sendTask?.cancel(false) - publishData(mapOf("type" to "stopCall")) - } - - private fun handleSpeedAdjustment(data: ByteArray) { - if (data.isEmpty()) return - val newInterval = when (data[6]) { - 0x01.toByte() -> 16L - 0x02.toByte() -> 20L - 0x03.toByte() -> 25L - else -> 20L - } - - if (sendInterval != newInterval) { - sendInterval = newInterval - if (isSending) { - sendTask?.cancel(false) - sendTask = sendScheduler.scheduleWithFixedDelay( - { sendAudioData() }, - 0, - sendInterval, - java.util.concurrent.TimeUnit.MILLISECONDS - ) - } - } - } - - private fun sendAudioData() { - if (!isSending) return - var any = false - var fillP = 4 - for (k in 0 until 2) { - System.arraycopy(silenceData, 0, packetBuf, fillP, 40) - fillP += 40 - } - var offset = 4 - for (i in 0 until 1) { - val spk = spkQueue.poll() - val mic = micQueue.poll() - if (spk != null) { - val sLen = minOf(spk.size, 40) - System.arraycopy(spk, 0, packetBuf, offset, sLen) - any = true - } - if (mic != null) { - val mLen = minOf(mic.size, 40) - System.arraycopy(mic, 0, packetBuf, offset + 40, mLen) - any = true - } - offset += 80 - } - if (!any) return - packetBuf[0] = 0xAA.toByte() - packetBuf[1] = 0x56.toByte() - packetBuf[2] = 84.toByte() - packetBuf[3] = 0x03.toByte() - BluetoothManager.writeBytes(packetBuf) - } - - private fun spkData(data: ByteArray) { - asrTrTtsHelperSpk?.pushStreamWrite(data) - // 录音 tap(右声道):spkData=对方原音,已在 BluetoothManager.g722DecodeData 解码为 16k/16bit mono PCM - stereoRecorder?.writeRightChannel(data) - } - - private fun micData(data: ByteArray) { - asrTrTtsHelperMic?.pushStreamWrite(data) - // 录音 tap(左声道,作时钟):micData=本端麦克风,已在 BluetoothManager.g722DecodeData 解码为 16k/16bit mono PCM - stereoRecorder?.writeLeftChannel(data) - } - - private fun publishData(data: Map) { - val cb = callback ?: return - mainHandler.post { cb.invoke(data) } - } - - // 通话翻译为双声道录音:左=本端麦克风(micData),右=对方原音(spkData) - override fun enableRecord(filePath: String) { - // 防御:未 stopRecord 就重复 enableRecord 时先停旧的 - stereoRecorder?.stop(false) - val recorder = AstStereoRecorder() - recorder.start(filePath) - stereoRecorder = recorder - } - - override fun stopRecord(isSave: Boolean) { - stereoRecorder?.stop(isSave) - stereoRecorder = null - } - - override fun dispose() { - stop() - stopRecognition() - // 兜底:若上层没显式 stopRecord,dispose 时保存已录内容 - stereoRecorder?.stop(true) - stereoRecorder = null - asrTrTtsHelperSpk?.dispose() - asrTrTtsHelperMic?.dispose() - callback = null - BluetoothManager.translationCallback = null - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/FaceToFaceTranslationHelper.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/FaceToFaceTranslationHelper.kt deleted file mode 100644 index cb99181d..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/FaceToFaceTranslationHelper.kt +++ /dev/null @@ -1,229 +0,0 @@ -package com.example.asr_tr_tts_manager.translation_option - -import android.content.Context -import android.media.AudioDeviceInfo -import android.media.AudioManager -import android.os.Build -import android.os.Handler -import android.os.Looper -import com.example.asr_tr_tts_manager.AsrTrTtsHelper -import com.example.asr_tr_tts_manager.recorder.AstStereoRecorder -import com.example.bluetooth_manager.BluetoothManager - -class FaceToFaceTranslationHelper(private val context: Context) : TranslationHelperInterface { - private val mainHandler = Handler(Looper.getMainLooper()) - - private var asrTrTtsHelperSpk: AsrTrTtsHelper? = null - private var asrTrTtsHelperMic: AsrTrTtsHelper? = null - - override var callback: ((Map) -> Unit)? = null - - @Volatile - private var isSending = false - private val micQueue = java.util.concurrent.LinkedBlockingQueue(4096) - - private val sendScheduler = java.util.concurrent.ScheduledThreadPoolExecutor(1) - private var sendTask: java.util.concurrent.ScheduledFuture<*>? = null - private var sendInterval: Long = 20L - private val packetBuf = ByteArray(44) - private var isRecognitionStopped = true - private var previousAudioMode: Int = AudioManager.MODE_NORMAL - private var previousSpeakerphone: Boolean = false - private val silenceData = byteArrayOf( - 0x0A.toByte(), 0x8E.toByte(), 0x30.toByte(), 0xBD.toByte(), 0x3F.toByte(), 0x83.toByte(), 0xFF.toByte(), 0x00.toByte(), - 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), - 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte() - ) - - private var microphoneRecorder: MicrophoneRecorder? = null - - // 面对面翻译录音:mono。写入的是本端麦克风解码后 16k/16bit PCM。 - @Volatile - private var stereoRecorder: AstStereoRecorder? = null - - init { - asrTrTtsHelperSpk = AsrTrTtsHelper(context) - asrTrTtsHelperMic = AsrTrTtsHelper(context, autoPlay = true, forceBuiltInSpeaker = true) - BluetoothManager.translationCallback = { data -> - when (data["type"]) { - "startFaceToFace" -> startRecognition() - "stopFaceToFace" -> stopRecognition() - "spkData" -> micData(data["data"] as ByteArray) - "speedAdjustment" -> handleSpeedAdjustment(data["data"] as ByteArray) - } - } - microphoneRecorder = MicrophoneRecorder(context) { data -> - spkData(data) - } - asrTrTtsHelperSpk?.callback = { data -> - publishData(data + ("side" to "right")) - } - asrTrTtsHelperMic?.callback = { data -> - publishData(data + ("side" to "left")) - } - asrTrTtsHelperSpk?.onSynthesisEncoded = { bytes -> - if (!micQueue.offer(bytes)) { - micQueue.poll() - micQueue.offer(bytes) - } - } - } - - override fun start(sourceLanguage: String, targetLanguage: String) { - // 设置通信模式,让平台级AEC生效 - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - previousAudioMode = audioManager.mode - previousSpeakerphone = audioManager.isSpeakerphoneOn - audioManager.mode = AudioManager.MODE_IN_COMMUNICATION - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val speaker = audioManager.availableCommunicationDevices - .firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } - if (speaker != null) { - audioManager.setCommunicationDevice(speaker) - } - } else { - @Suppress("DEPRECATION") - audioManager.isSpeakerphoneOn = true - } - - asrTrTtsHelperSpk?.switchProvider( - languages = listOf(targetLanguage, sourceLanguage) - ) - asrTrTtsHelperMic?.switchProvider( - languages = listOf(sourceLanguage, targetLanguage) - ) - BluetoothManager.writeBytes( - byteArrayOf(0xaa.toByte(), 0x66.toByte()) - ) - microphoneRecorder?.start() - } - - override fun stop() { - BluetoothManager.writeBytes( - byteArrayOf(0xaa.toByte(), 0x67.toByte()) - ) - microphoneRecorder?.stop() - - // 恢复音频设置 - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - audioManager.clearCommunicationDevice() - } else { - @Suppress("DEPRECATION") - audioManager.isSpeakerphoneOn = previousSpeakerphone - } - audioManager.mode = previousAudioMode - } - - private fun startRecognition() { - if (!isRecognitionStopped) return - isRecognitionStopped = false - asrTrTtsHelperSpk?.startRecognition() - asrTrTtsHelperMic?.startRecognition() - micQueue.clear() - isSending = true - sendTask?.cancel(false) - sendTask = sendScheduler.scheduleWithFixedDelay( - { sendAudioData() }, - 0, - sendInterval, - java.util.concurrent.TimeUnit.MILLISECONDS - ) - publishData(mapOf("type" to "startCall")) - } - - private fun stopRecognition() { - if (isRecognitionStopped) return - isRecognitionStopped = true - asrTrTtsHelperSpk?.stopRecognition() - asrTrTtsHelperMic?.stopRecognition() - isSending = false - sendTask?.cancel(false) - publishData(mapOf("type" to "stopCall")) - } - - private fun handleSpeedAdjustment(data: ByteArray) { - if (data.isEmpty()) return - val newInterval = when (data[6]) { - 0x01.toByte() -> 15L - 0x02.toByte() -> 20L - 0x03.toByte() -> 25L - else -> 20L - } - - if (sendInterval != newInterval) { - sendInterval = newInterval - if (isSending) { - sendTask?.cancel(false) - sendTask = sendScheduler.scheduleWithFixedDelay( - { sendAudioData() }, - 0, - sendInterval, - java.util.concurrent.TimeUnit.MILLISECONDS - ) - } - } - } - - private fun sendAudioData() { - if (!isSending) return - // 先填充静音数据 - System.arraycopy(silenceData, 0, packetBuf, 4, 40) - val mic = micQueue.poll() ?: return - val mLen = minOf(mic.size, 40) - System.arraycopy(mic, 0, packetBuf, 4, mLen) - packetBuf[0] = 0xAA.toByte() - packetBuf[1] = 0x69.toByte() - packetBuf[2] = 44.toByte() - packetBuf[3] = 0x01.toByte() - BluetoothManager.writeBytes(packetBuf) - } - - private fun spkData(data: ByteArray) { - asrTrTtsHelperSpk?.pushStreamWrite(data) - // 录音 tap(左声道,作时钟):spkData=本端手机麦克风,由 MicrophoneRecorder(PCMReader) - // 喂入,确认为 16k/16bit mono PCM - stereoRecorder?.writeLeftChannel(data) - } - - private fun micData(data: ByteArray) { - asrTrTtsHelperMic?.pushStreamWrite(data) - // 录音 tap(右声道):micData=对方(BLE 设备),已在 BluetoothManager 解码为 16k/16bit mono PCM - stereoRecorder?.writeRightChannel(data) - } - - private fun publishData(data: Map) { - val cb = callback ?: return - mainHandler.post { cb.invoke(data) } - } - - // 面对面翻译为双声道录音:左=本端手机麦克风(spkData),右=对方/BLE 设备(micData) - override fun enableRecord(filePath: String) { - // 防御:未 stopRecord 就重复 enableRecord 时先停旧的 - stereoRecorder?.stop(false) - val recorder = AstStereoRecorder() - recorder.start(filePath) - stereoRecorder = recorder - } - - override fun stopRecord(isSave: Boolean) { - stereoRecorder?.stop(isSave) - stereoRecorder = null - } - - override fun dispose() { - stop() - stopRecognition() - microphoneRecorder?.dispose() - microphoneRecorder = null - // 兜底:若上层没显式 stopRecord,dispose 时保存已录内容 - stereoRecorder?.stop(true) - stereoRecorder = null - asrTrTtsHelperSpk?.dispose() - asrTrTtsHelperMic?.dispose() - callback = null - BluetoothManager.translationCallback = null - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/MicrophoneRecorder.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/MicrophoneRecorder.kt deleted file mode 100644 index 9469d88d..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/MicrophoneRecorder.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.example.asr_tr_tts_manager.translation_option - -import android.content.Context -import android.media.MediaFormat -import android.media.MediaRecorder -import com.llfbandit.record.record.PCMReader -import com.llfbandit.record.record.RecordConfig -import java.util.concurrent.atomic.AtomicBoolean - -class MicrophoneRecorder( - private val context: Context, - private val sampleRate: Int = 16000, - private val onAudioData: (ByteArray) -> Unit -) { - private val isRecording = AtomicBoolean(false) - private var recordThread: Thread? = null - private var pcmReader: PCMReader? = null - - fun start() { - if (isRecording.get()) return - - // device 设为 null,不用 setPreferredDevice 指定录音设备 - // 由 SimultaneousTranslationHelper 的 setCommunicationDevice 统一管理音频路由 - // 避免与平台级 AEC 的参考信号路径冲突 - val config = RecordConfig( - path = null, - encoder = "pcm16bits", - bitRate = 128000, - sampleRate = sampleRate, - numChannels = 1, - device = null, - autoGain = true, - echoCancel = true, - noiseSuppress = true, - manageBluetooth = false, - audioSource = MediaRecorder.AudioSource.VOICE_COMMUNICATION, - audioInterruption = 0, - streamBufferSize = null - ) - - val mediaFormat = MediaFormat.createAudioFormat( - MediaFormat.MIMETYPE_AUDIO_RAW, sampleRate, 1 - ) - - val reader = try { - PCMReader(config, mediaFormat) - } catch (e: Exception) { - return - } - - pcmReader = reader - isRecording.set(true) - reader.start() - - recordThread = Thread({ - while (isRecording.get()) { - try { - val data = reader.read() - if (data.isNotEmpty()) { - onAudioData(data) - } - } catch (_: Exception) { - break - } - } - }, "MicrophoneRecorder") - recordThread?.start() - } - - fun stop() { - if (!isRecording.getAndSet(false)) return - - recordThread?.join(1000) - recordThread = null - - pcmReader?.stop() - pcmReader?.release() - pcmReader = null - } - - fun dispose() { - stop() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/SimultaneousTranslationHelper.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/SimultaneousTranslationHelper.kt deleted file mode 100644 index 2cbf26f4..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/SimultaneousTranslationHelper.kt +++ /dev/null @@ -1,332 +0,0 @@ -package com.example.asr_tr_tts_manager.translation_option - -import android.content.Context -import android.media.AudioDeviceCallback -import android.media.AudioDeviceInfo -import android.media.AudioManager -import android.os.Build -import android.os.Handler -import android.os.Looper -import com.example.asr_tr_tts_manager.AsrTrTtsHelper -import com.example.asr_tr_tts_manager.recorder.RecordFile -import com.example.bluetooth_manager.BluetoothManager -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit - -class SimultaneousTranslationHelper(private val context: Context) : TranslationHelperInterface { - private val mainHandler = Handler(Looper.getMainLooper()) - - /** - * 串行执行路由切换 / 录音重启,避免主线程被 recordThread.join(1000) 阻塞。 - * 只有「启动时已连普通蓝牙耳机」分支才用到,懒初始化避免 SPP/无 BT 场景白白多一个常驻线程。 - */ - private val routingExecutorLazy = lazy { - Executors.newSingleThreadExecutor { r -> Thread(r, "SimulRoutingExec") } - } - private val routingExecutor by routingExecutorLazy - - private var asrTrTtsHelper: AsrTrTtsHelper? = null - - override var callback: ((Map) -> Unit)? = null - - @Volatile - private var isRecognitionStopped = true - - private var microphoneRecorder: MicrophoneRecorder? = null - - // 同声传译录音:mono。写入的是送入 ASR 的解码后 16k/16bit PCM。 - @Volatile - private var recordFile: RecordFile? = null - - private var sourceLanguageCode: String = "" - private var targetLanguageCode: String = "" - private var previousAudioMode: Int = AudioManager.MODE_NORMAL - - @Volatile - private var forcedSpeaker: Boolean = false - - @Volatile - private var forcedBtSco: Boolean = false - - /** 本次会话是否走蓝牙 BLE 指令路径(连上耳机时启用,从耳机主麦经 mSBC 拉 PCM)。 */ - @Volatile - private var usingBtPath: Boolean = false - - private var audioDeviceCallback: AudioDeviceCallback? = null - - init { - asrTrTtsHelper = AsrTrTtsHelper(context, autoPlay = true, autoDetectLanguage = true) - microphoneRecorder = MicrophoneRecorder(context) { data -> - asrTrTtsHelper?.pushStreamWrite(data) - // 录音 tap:本地麦克风 PCMReader 输出,确认为 16k/16bit mono PCM - recordFile?.saveAudioDataToWav(data) - } - asrTrTtsHelper?.callback = { data -> - val language = data["language"] as? String ?: "" - val side = if (language.equals(sourceLanguageCode, ignoreCase = true)) "left" else "right" - publishData(data + ("side" to side)) - } - // BT 路径事件分发:0xD9/0xDA 触发 ASR 生命周期,mainMicData 喂 ASR(ffMicData 是辅助麦,丢弃) - BluetoothManager.translationCallback = { data -> - if (usingBtPath) { - when (data["type"]) { - "startMicRecording" -> startRecognition() - "stopMicRecording" -> stopRecognition() - "mainMicData" -> (data["data"] as? ByteArray)?.let { - asrTrTtsHelper?.pushStreamWrite(it) - // 录音 tap:BluetoothManager.mainMicData 已在 msbcDecodeData 解码为 16k/16bit mono PCM - recordFile?.saveAudioDataToWav(it) - } - } - } - } - } - - override fun start(sourceLanguage: String, targetLanguage: String) { - sourceLanguageCode = sourceLanguage - targetLanguageCode = targetLanguage - usingBtPath = BluetoothManager.isConnected - - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - if (!usingBtPath) { - // 非 BT 路径:本地麦克风采集,需要 MODE_IN_COMMUNICATION 让平台 AEC 生效 - previousAudioMode = audioManager.mode - audioManager.mode = AudioManager.MODE_IN_COMMUNICATION - - // 没有连接蓝牙音频设备时,强制走扬声器(否则 MODE_IN_COMMUNICATION 默认走听筒,声音很小); - // 系统配对了普通蓝牙耳机(A2DP/HFP)时,强制走 SCO,让录音也走耳机麦(代价:播放从 - // A2DP 高音质降为 SCO 8/16kHz 电话音质,这是 Android 协议层面无法兼得的) - if (!hasBluetoothAudioDevice(audioManager)) { - applySpeakerRoute(audioManager) - } else { - applyBtScoRoute(audioManager) - // SCO 中途断开后,AcousticEchoCanceler 不会自动迁移到新路由,必须监听并重建 - registerAudioDeviceCallback(audioManager) - } - } - // BT 路径:录音走 BLE 指令通道,不需要改 audio mode;TTS 默认会走耳机 A2DP - - asrTrTtsHelper?.switchProvider( - languages = listOf(targetLanguage, sourceLanguage) - ) - - if (usingBtPath) { - // 复用会议助手「现场录音」指令:通知耳机启动主麦 mSBC 数据流 - // ASR 开启 + startCall 等设备回 0xD9 后在 startRecognition 里发起 - BluetoothManager.writeBytes(byteArrayOf(0xaa.toByte(), 0x59.toByte())) - } else { - microphoneRecorder?.start() - startRecognition() - } - } - - override fun stop() { - // 缓存一次:避免在 stop() 执行过程中被 translationCallback 的 stopRecognition 改写后 - // 出现「前半段当 BT 路径处理、后半段当非 BT 处理」的不一致 - val bt = usingBtPath - if (bt) { - // ASR 关闭 + stopCall 等设备回 0xDA 后在 stopRecognition 里发起 - BluetoothManager.writeBytes(byteArrayOf(0xaa.toByte(), 0x5a.toByte())) - } else { - microphoneRecorder?.stop() - } - - // 音频会话同步回滚(仅非 BT 路径才需要,BT 路径下没改 audio mode) - if (!bt) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - unregisterAudioDeviceCallback(audioManager) - releaseSpeakerRoute(audioManager) - releaseBtScoRoute(audioManager) - audioManager.mode = previousAudioMode - } - - // 非 BT 路径在所有同步清理结束后再触发 stopRecognition + publishStopCall, - // 让 UI 看到 stopCall 时资源已全部释放,与 BT 路径下 0xDA 触发的时机一致 - if (!bt) { - stopRecognition() - } - } - - private fun startRecognition() { - if (!isRecognitionStopped) return - isRecognitionStopped = false - asrTrTtsHelper?.startRecognition() - publishData(mapOf("type" to "startCall")) - } - - private fun stopRecognition() { - if (isRecognitionStopped) return - isRecognitionStopped = true - asrTrTtsHelper?.stopRecognition() - publishData(mapOf("type" to "stopCall")) - usingBtPath = false - } - - // MARK: - 音频路由切换辅助 - - private fun applySpeakerRoute(audioManager: AudioManager) { - forcedSpeaker = true - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val speaker = audioManager.availableCommunicationDevices - .firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } - if (speaker != null) { - audioManager.setCommunicationDevice(speaker) - } - } else { - @Suppress("DEPRECATION") - audioManager.isSpeakerphoneOn = true - } - } - - private fun releaseSpeakerRoute(audioManager: AudioManager) { - if (!forcedSpeaker) return - forcedSpeaker = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - audioManager.clearCommunicationDevice() - } else { - @Suppress("DEPRECATION") - audioManager.isSpeakerphoneOn = false - } - } - - private fun applyBtScoRoute(audioManager: AudioManager) { - forcedBtSco = true - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val btSco = audioManager.availableCommunicationDevices.firstOrNull { - it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || - it.type == AudioDeviceInfo.TYPE_BLE_HEADSET - } - if (btSco != null) { - audioManager.setCommunicationDevice(btSco) - } - } else { - @Suppress("DEPRECATION") - audioManager.startBluetoothSco() - @Suppress("DEPRECATION") - audioManager.isBluetoothScoOn = true - } - } - - private fun releaseBtScoRoute(audioManager: AudioManager) { - if (!forcedBtSco) return - forcedBtSco = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - audioManager.clearCommunicationDevice() - } else { - @Suppress("DEPRECATION") - audioManager.isBluetoothScoOn = false - @Suppress("DEPRECATION") - audioManager.stopBluetoothSco() - } - } - - // MARK: - 中途断开蓝牙耳机的热切换 - - private fun registerAudioDeviceCallback(audioManager: AudioManager) { - if (audioDeviceCallback != null) return - val cb = object : AudioDeviceCallback() { - override fun onAudioDevicesRemoved(removedDevices: Array) { - val btRemoved = removedDevices.any { - it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || - it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || - it.type == AudioDeviceInfo.TYPE_BLE_HEADSET - } - if (btRemoved && forcedBtSco) { - // 在后台线程做路由切换 + 录音重启,避免 main 被 recordThread.join 阻塞 - routingExecutor.execute { handleBtAudioLost() } - } - } - } - audioDeviceCallback = cb - audioManager.registerAudioDeviceCallback(cb, mainHandler) - } - - private fun unregisterAudioDeviceCallback(audioManager: AudioManager) { - audioDeviceCallback?.let { - audioManager.unregisterAudioDeviceCallback(it) - } - audioDeviceCallback = null - } - - private fun handleBtAudioLost() { - // 重入守门:会话已停止或不再处于 SCO 状态时直接退出 - if (isRecognitionStopped || !forcedBtSco) return - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - // 1. 释放 SCO 状态 + 切到内置扬声器(不切回的话播放会落到听筒,声音很小); - // AudioManager 罕见情况会抛异常,单独包住保证下面 mic 重启仍会执行 - try { - releaseBtScoRoute(audioManager) - applySpeakerRoute(audioManager) - } catch (e: Exception) { - android.util.Log.e("SimulHelper", "route switch failed", e) - } - - // 2. 重启 MicrophoneRecorder 让 AudioRecord 在新路由上重新挂载 AEC effect - // dispose 的 shutdownNow 会让 recordThread.join 抛 InterruptedException, - // 此时不要 restart(让 dispose 接管收尾),并恢复中断标志 - try { - microphoneRecorder?.stop() - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - return - } - if (!isRecognitionStopped) { - microphoneRecorder?.start() - } - } - - private fun hasBluetoothAudioDevice(audioManager: AudioManager): Boolean { - return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { - it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || - it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP - } - } - - private fun publishData(data: Map) { - val cb = callback ?: return - mainHandler.post { cb.invoke(data) } - } - - // 同声传译为单声道录音:写入送入 ASR 的解码后 PCM - override fun enableRecord(filePath: String) { - // 防御:未 stopRecord 就重复 enableRecord 时先关旧的,避免泄漏挂起写入与文件句柄 - recordFile?.closeFile(false) - val file = RecordFile() - file.setAudioConfig(16000, 1) - file.creatingFiles(filePath) - recordFile = file - } - - override fun stopRecord(isSave: Boolean) { - recordFile?.closeFile(isSave) - recordFile = null - } - - override fun dispose() { - stop() - // 若 BT 路径下设备没回 0xDA,stopRecognition 还没跑过;这里兜底强制收尾 - stopRecognition() - // shutdownNow 中断在飞任务、awaitTermination 等其退出,避免 handleBtAudioLost - // 与下面的 microphoneRecorder.dispose() 竞争同一份资源; - // 懒初始化的 executor 没用过就不必关闭 - if (routingExecutorLazy.isInitialized()) { - routingExecutor.shutdownNow() - try { - routingExecutor.awaitTermination(500, TimeUnit.MILLISECONDS) - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - } - } - microphoneRecorder?.dispose() - microphoneRecorder = null - // 兜底:若上层没显式 stopRecord,dispose 时保存已录内容 - recordFile?.closeFile(true) - recordFile = null - asrTrTtsHelper?.dispose() - asrTrTtsHelper = null - callback = null - BluetoothManager.translationCallback = null - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/TranslationHelperInterface.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/TranslationHelperInterface.kt deleted file mode 100644 index 354b3295..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/TranslationHelperInterface.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.example.asr_tr_tts_manager.translation_option - -enum class TranslationHelperProvider { - SIMULTANEOUS, - CALL, - FACETOFACE, -} - -interface TranslationHelperInterface { - var callback: ((Map) -> Unit)? - - fun start(sourceLanguage: String = "zh-CN", targetLanguage: String = "en-US") - fun stop() - fun dispose() - - /** 开始把正在送入 ASR 的解码后 PCM 同步写入 WAV 文件(mono 或 stereo 由实现决定)。 */ - fun enableRecord(filePath: String) - - /** 结束录音;isSave=false 时丢弃文件。 */ - fun stopRecord(isSave: Boolean) -} \ No newline at end of file diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/TranslationHelperManager.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/TranslationHelperManager.kt deleted file mode 100644 index 1ec2ec76..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/translation_option/TranslationHelperManager.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.example.asr_tr_tts_manager.translation_option - -import android.content.Context - -class TranslationHelperManager( - private val context: Context, - provider: TranslationHelperProvider = TranslationHelperProvider.CALL, -) : TranslationHelperInterface { - private var currentProvider: TranslationHelperProvider = provider - private var delegate: TranslationHelperInterface? = null - - override var callback: ((Map) -> Unit)? = null - set(value) { - field = value - delegate?.callback = value - } - - init { - switchProvider() - } - - fun switchProvider(provider: TranslationHelperProvider = currentProvider) { - try { - delegate?.dispose() - } catch (_: Exception) { - } - - currentProvider = provider - - delegate = - when (provider) { - TranslationHelperProvider.SIMULTANEOUS -> SimultaneousTranslationHelper(context) - TranslationHelperProvider.CALL -> CallTranslationHelper(context) - TranslationHelperProvider.FACETOFACE -> FaceToFaceTranslationHelper(context) - }.also { - it.callback = callback - } - } - - fun getProvider(): TranslationHelperProvider = currentProvider - - override fun start(sourceLanguage: String, targetLanguage: String) { - delegate?.start(sourceLanguage, targetLanguage) - } - - override fun stop() { - delegate?.stop() - } - - override fun enableRecord(filePath: String) { - delegate?.enableRecord(filePath) - } - - override fun stopRecord(isSave: Boolean) { - delegate?.stopRecord(isSave) - } - - override fun dispose() { - callback = null - val d = delegate - delegate = null - d?.dispose() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/AzureTts.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/AzureTts.kt deleted file mode 100644 index 4ee5680e..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/AzureTts.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.example.asr_tr_tts_manager.tts - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioDeviceInfo -import android.media.AudioFormat -import android.media.AudioManager -import android.media.AudioTrack -import android.os.Build -import com.example.bluetooth_manager.G722Codec -import com.microsoft.cognitiveservices.speech.SpeechConfig -import com.microsoft.cognitiveservices.speech.SpeechSynthesisOutputFormat -import com.microsoft.cognitiveservices.speech.SpeechSynthesizer -import com.microsoft.cognitiveservices.speech.audio.AudioConfig -import com.microsoft.cognitiveservices.speech.audio.AudioOutputStream -import com.microsoft.cognitiveservices.speech.audio.PushAudioOutputStreamCallback - -class AzureTts(private val context: Context) : TtsInterface { - private val SUBSCRIPTION_KEY: String = - "rLPdyGgD1v7z7khzpdrd4YNWUinjw6GzAbA1pPXBRmUjitfjDxdsJQQJ99BGAC3pKaRXJ3w3AAAYACOGAHZZ" - private val REGION: String = "eastasia" - - private var g722Handle: Long = 0L - override var onSynthesisEncoded: ((ByteArray) -> Unit)? = null - override var autoPlay: Boolean = false - override var forceBuiltInSpeaker: Boolean = false - set(value) { - if (field == value) return - field = value - // AudioAttributes 在 build() 后不可变,usage 随 forceBuiltInSpeaker 变化必须重建 AudioTrack - rebuildAudioTrack() - } - - private var synthesizer: SpeechSynthesizer? = null - private var audioTrack: AudioTrack? = null - - private inner class MemoryPushAudioOutputStreamCallback : PushAudioOutputStreamCallback() { - private var stashBuffer: ByteArray = ByteArray(8192) - private var stashSize: Int = 0 - private val frameBytes: Int = 320 * 2 - - override fun write(data: ByteArray): Int { - if (data.isEmpty()) return 0 - - if (autoPlay) { - audioTrack?.write(data, 0, data.size) - return data.size - } - - val required = stashSize + data.size - if (required > stashBuffer.size) { - var newCap = stashBuffer.size - while (newCap < required) newCap = newCap shl 1 - val newBuf = ByteArray(newCap) - System.arraycopy(stashBuffer, 0, newBuf, 0, stashSize) - stashBuffer = newBuf - } - - System.arraycopy(data, 0, stashBuffer, stashSize, data.size) - stashSize += data.size - - var cursor = 0 - if (g722Handle == 0L) { - g722Handle = G722Codec.init(16000) - } - - while (stashSize - cursor >= frameBytes) { - val frame = ByteArray(frameBytes) - System.arraycopy(stashBuffer, cursor, frame, 0, frameBytes) - val encoded = try { - G722Codec.encodeSync(g722Handle, frame) - } catch (_: Exception) { - null - } - if (encoded != null) { - onSynthesisEncoded?.invoke(encoded) - } - cursor += frameBytes - } - - if (cursor > 0) { - val remaining = stashSize - cursor - if (remaining > 0) { - System.arraycopy(stashBuffer, cursor, stashBuffer, 0, remaining) - } - stashSize = remaining - } - - return data.size - } - - override fun close() { - stashSize = 0 - } - - fun reset() { - stashSize = 0 - } - } - - private val synthesisOutputCallback = MemoryPushAudioOutputStreamCallback() - - init { - val speechConfig = SpeechConfig.fromSubscription(SUBSCRIPTION_KEY, REGION) - speechConfig.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Raw16Khz16BitMonoPcm) - val outputStream = AudioOutputStream.createPushStream(synthesisOutputCallback) - val audioOutputConfig = AudioConfig.fromStreamOutput(outputStream) - synthesizer = SpeechSynthesizer(speechConfig, audioOutputConfig) - - buildAudioTrack() - } - - private fun buildAudioTrack() { - val bufSize = AudioTrack.getMinBufferSize( - 16000, - AudioFormat.CHANNEL_OUT_MONO, - AudioFormat.ENCODING_PCM_16BIT - ) - // forceBuiltInSpeaker(面对面翻译外放)走通信通路:只有 USAGE_VOICE_COMMUNICATION 才会被 - // MODE_IN_COMMUNICATION + setCommunicationDevice(内置扬声器) 正确路由到扬声器; - // USAGE_MEDIA 在小米/HyperOS 通话模式下会被压制,导致外放无声而接耳机正常。 - val usage = if (forceBuiltInSpeaker) { - AudioAttributes.USAGE_VOICE_COMMUNICATION - } else { - AudioAttributes.USAGE_MEDIA - } - audioTrack = AudioTrack.Builder() - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(usage) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - ) - .setAudioFormat( - AudioFormat.Builder() - .setSampleRate(16000) - .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) - .setEncoding(AudioFormat.ENCODING_PCM_16BIT) - .build() - ) - .setBufferSizeInBytes(bufSize) - .setTransferMode(AudioTrack.MODE_STREAM) - .build() - applyPreferredDevice() - } - - private fun rebuildAudioTrack() { - try { - audioTrack?.release() - } catch (_: Exception) { - } - audioTrack = null - buildAudioTrack() - } - - override fun speakText(text: String, targetLanguage: String, voiceName: String) { - speakSsml(buildSsml(text = text, targetLanguage = targetLanguage, voiceName = voiceName)) - } - - fun speakSsml(ssml: String) { - synthesisOutputCallback.reset() - if (autoPlay) { - audioTrack?.play() - } - synthesizer?.SpeakSsmlAsync(ssml)?.get() - if (autoPlay) { - audioTrack?.stop() - } - } - - private fun applyPreferredDevice() { - val track = audioTrack ?: return - if (forceBuiltInSpeaker && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val speaker = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) - .firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } - track.preferredDevice = speaker - } else { - track.preferredDevice = null - } - } - - override fun dispose() { - try { - synthesizer?.close() - } catch (_: Exception) { - } - synthesizer = null - try { - audioTrack?.release() - } catch (_: Exception) { - } - audioTrack = null - try { - if (g722Handle != 0L) { - G722Codec.release(g722Handle) - g722Handle = 0L - } - } catch (_: Exception) { - } - } - - private fun buildSsml(text: String, targetLanguage: String, voiceName: String): String { - return """ - - - - $text - - - - """.trimIndent() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/TtsInterface.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/TtsInterface.kt deleted file mode 100644 index b333f514..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/TtsInterface.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.asr_tr_tts_manager.tts - -enum class TtsProvider { - AZURE, - VOLCENGINE, -} - -interface TtsInterface { - var onSynthesisEncoded: ((ByteArray) -> Unit)? - var autoPlay: Boolean - var forceBuiltInSpeaker: Boolean - - fun speakText(text: String, targetLanguage: String, voiceName: String) - fun dispose() -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/TtsManager.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/TtsManager.kt deleted file mode 100644 index d4ca48da..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/TtsManager.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.example.asr_tr_tts_manager.tts - -import android.content.Context - -class TtsManager( - private val context: Context, - provider: TtsProvider = TtsProvider.AZURE, - autoPlay: Boolean = false, - forceBuiltInSpeaker: Boolean = false, -) : TtsInterface { - - private var currentProvider: TtsProvider = provider - private var delegate: TtsInterface? = null - - override var onSynthesisEncoded: ((ByteArray) -> Unit)? = null - set(value) { - field = value - delegate?.onSynthesisEncoded = value - } - - override var autoPlay: Boolean = autoPlay - set(value) { - field = value - delegate?.autoPlay = value - } - - override var forceBuiltInSpeaker: Boolean = forceBuiltInSpeaker - set(value) { - field = value - delegate?.forceBuiltInSpeaker = value - } - - init { - switchProvider() - } - - fun switchProvider(provider: TtsProvider = currentProvider) { - try { - delegate?.dispose() - } catch (_: Exception) { - } - - currentProvider = provider - - delegate = - when (provider) { - TtsProvider.AZURE -> AzureTts(context) - TtsProvider.VOLCENGINE -> VolcengineTts(context) - }.also { - it.onSynthesisEncoded = onSynthesisEncoded - it.autoPlay = autoPlay - it.forceBuiltInSpeaker = forceBuiltInSpeaker - } - } - - fun getProvider(): TtsProvider = currentProvider - - override fun speakText(text: String, targetLanguage: String, voiceName: String) { - delegate?.speakText(text, targetLanguage, voiceName) - } - - override fun dispose() { - onSynthesisEncoded = null - - val d = delegate - delegate = null - d?.dispose() - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/VolcengineTts.kt b/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/VolcengineTts.kt deleted file mode 100644 index 60f28312..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/android/src/main/kotlin/com/example/asr_tr_tts_manager/tts/VolcengineTts.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.example.asr_tr_tts_manager.tts - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioDeviceInfo -import android.media.AudioFormat -import android.media.AudioManager -import android.media.AudioTrack -import android.os.Build -import com.example.bluetooth_manager.G722Codec -import com.microsoft.cognitiveservices.speech.SpeechConfig -import com.microsoft.cognitiveservices.speech.SpeechSynthesisOutputFormat -import com.microsoft.cognitiveservices.speech.SpeechSynthesizer -import com.microsoft.cognitiveservices.speech.audio.AudioConfig -import com.microsoft.cognitiveservices.speech.audio.AudioOutputStream -import com.microsoft.cognitiveservices.speech.audio.PushAudioOutputStreamCallback - -class VolcengineTts(private val context: Context) : TtsInterface { - private val SUBSCRIPTION_KEY: String = - "rLPdyGgD1v7z7khzpdrd4YNWUinjw6GzAbA1pPXBRmUjitfjDxdsJQQJ99BGAC3pKaRXJ3w3AAAYACOGAHZZ" - private val REGION: String = "eastasia" - - private var g722Handle: Long = 0L - override var onSynthesisEncoded: ((ByteArray) -> Unit)? = null - override var autoPlay: Boolean = false - override var forceBuiltInSpeaker: Boolean = false - set(value) { - if (field == value) return - field = value - // AudioAttributes 在 build() 后不可变,usage 随 forceBuiltInSpeaker 变化必须重建 AudioTrack - rebuildAudioTrack() - } - - private var synthesizer: SpeechSynthesizer? = null - private var audioTrack: AudioTrack? = null - - private inner class MemoryPushAudioOutputStreamCallback : PushAudioOutputStreamCallback() { - private var stashBuffer: ByteArray = ByteArray(8192) - private var stashSize: Int = 0 - private val frameBytes: Int = 320 * 2 - - override fun write(data: ByteArray): Int { - if (data.isEmpty()) return 0 - - if (autoPlay) { - audioTrack?.write(data, 0, data.size) - return data.size - } - - val required = stashSize + data.size - if (required > stashBuffer.size) { - var newCap = stashBuffer.size - while (newCap < required) newCap = newCap shl 1 - val newBuf = ByteArray(newCap) - System.arraycopy(stashBuffer, 0, newBuf, 0, stashSize) - stashBuffer = newBuf - } - - System.arraycopy(data, 0, stashBuffer, stashSize, data.size) - stashSize += data.size - - var cursor = 0 - if (g722Handle == 0L) { - g722Handle = G722Codec.init(16000) - } - - while (stashSize - cursor >= frameBytes) { - val frame = ByteArray(frameBytes) - System.arraycopy(stashBuffer, cursor, frame, 0, frameBytes) - val encoded = try { - G722Codec.encodeSync(g722Handle, frame) - } catch (_: Exception) { - null - } - if (encoded != null) { - onSynthesisEncoded?.invoke(encoded) - } - cursor += frameBytes - } - - if (cursor > 0) { - val remaining = stashSize - cursor - if (remaining > 0) { - System.arraycopy(stashBuffer, cursor, stashBuffer, 0, remaining) - } - stashSize = remaining - } - - return data.size - } - - override fun close() { - stashSize = 0 - } - - fun reset() { - stashSize = 0 - } - } - - private val synthesisOutputCallback = MemoryPushAudioOutputStreamCallback() - - init { - val speechConfig = SpeechConfig.fromSubscription(SUBSCRIPTION_KEY, REGION) - speechConfig.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Raw16Khz16BitMonoPcm) - val outputStream = AudioOutputStream.createPushStream(synthesisOutputCallback) - val audioOutputConfig = AudioConfig.fromStreamOutput(outputStream) - synthesizer = SpeechSynthesizer(speechConfig, audioOutputConfig) - - buildAudioTrack() - } - - private fun buildAudioTrack() { - val bufSize = AudioTrack.getMinBufferSize( - 16000, - AudioFormat.CHANNEL_OUT_MONO, - AudioFormat.ENCODING_PCM_16BIT - ) - // forceBuiltInSpeaker(面对面翻译外放)走通信通路:只有 USAGE_VOICE_COMMUNICATION 才会被 - // MODE_IN_COMMUNICATION + setCommunicationDevice(内置扬声器) 正确路由到扬声器; - // USAGE_MEDIA 在小米/HyperOS 通话模式下会被压制,导致外放无声而接耳机正常。 - val usage = if (forceBuiltInSpeaker) { - AudioAttributes.USAGE_VOICE_COMMUNICATION - } else { - AudioAttributes.USAGE_MEDIA - } - audioTrack = AudioTrack.Builder() - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(usage) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - ) - .setAudioFormat( - AudioFormat.Builder() - .setSampleRate(16000) - .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) - .setEncoding(AudioFormat.ENCODING_PCM_16BIT) - .build() - ) - .setBufferSizeInBytes(bufSize) - .setTransferMode(AudioTrack.MODE_STREAM) - .build() - applyPreferredDevice() - } - - private fun rebuildAudioTrack() { - try { - audioTrack?.release() - } catch (_: Exception) { - } - audioTrack = null - buildAudioTrack() - } - - override fun speakText(text: String, targetLanguage: String, voiceName: String) { - speakSsml(buildSsml(text = text, targetLanguage = targetLanguage, voiceName = voiceName)) - } - - fun speakSsml(ssml: String) { - synthesisOutputCallback.reset() - if (autoPlay) { - audioTrack?.play() - } - synthesizer?.SpeakSsmlAsync(ssml)?.get() - if (autoPlay) { - audioTrack?.stop() - } - } - - private fun applyPreferredDevice() { - val track = audioTrack ?: return - if (forceBuiltInSpeaker && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val speaker = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) - .firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } - track.preferredDevice = speaker - } else { - track.preferredDevice = null - } - } - - override fun dispose() { - try { - synthesizer?.close() - } catch (_: Exception) { - } - synthesizer = null - try { - audioTrack?.release() - } catch (_: Exception) { - } - audioTrack = null - try { - if (g722Handle != 0L) { - G722Codec.release(g722Handle) - g722Handle = 0L - } - } catch (_: Exception) { - } - } - - private fun buildSsml(text: String, targetLanguage: String, voiceName: String): String { - return """ - - - - $text - - - - """.trimIndent() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/.gitignore b/apps/client/local_plugins/asr_tr_tts_manager/ios/.gitignore deleted file mode 100644 index 034771fc..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -.idea/ -.vagrant/ -.sconsign.dblite -.svn/ - -.DS_Store -*.swp -profile - -DerivedData/ -build/ -GeneratedPluginRegistrant.h -GeneratedPluginRegistrant.m - -.generated/ - -*.pbxuser -*.mode1v3 -*.mode2v3 -*.perspectivev3 - -!default.pbxuser -!default.mode1v3 -!default.mode2v3 -!default.perspectivev3 - -xcuserdata - -*.moved-aside - -*.pyc -*sync/ -Icon? -.tags* - -/Flutter/Generated.xcconfig -/Flutter/ephemeral/ -/Flutter/flutter_export_environment.sh diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager.podspec b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager.podspec deleted file mode 100644 index 7a81424b..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager.podspec +++ /dev/null @@ -1,41 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint asr_tr_tts_manager.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'asr_tr_tts_manager' - s.version = '0.0.1' - s.summary = 'ASR, Translation and TTS manager Flutter plugin.' - s.description = <<-DESC -Flutter plugin providing Azure-based ASR, Volcengine translation, and Azure TTS for call translation. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - s.source = { :path => '.' } - - s.source_files = 'asr_tr_tts_manager/Sources/asr_tr_tts_manager/**/*.swift' - - s.dependency 'Flutter' - # bluetooth_manager 提供 BluetoothManager.translationCallback / writeBytes / G722Codec - s.dependency 'bluetooth_manager' - # audio_recorder 提供 PCMReader 用于麦克风录音 - s.dependency 'audio_recorder' - # Azure 认知语音服务 SDK(ASR + TTS) - s.dependency 'MicrosoftCognitiveServicesSpeech-iOS', '~> 1.50.0' - - s.platform = :ios, '15.0' - - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', - } - - s.swift_version = '5.0' - - # If your plugin requires a privacy manifest, for example if it uses any - # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your - # plugin's privacy impact, and then uncomment this line. For more information, - # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - # s.resource_bundles = {'asr_tr_tts_manager_privacy' => ['asr_tr_tts_manager/Sources/asr_tr_tts_manager/PrivacyInfo.xcprivacy']} -end diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/AsrTrTtsHelper.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/AsrTrTtsHelper.swift deleted file mode 100644 index bb57c991..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/AsrTrTtsHelper.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation - -class AsrTrTtsHelper { - private let asrManager: AsrManager - private let translationManager: TranslationManager - private let ttsManager: TtsManager - - private var currentLanguages: [String] - private var sessionId = UUID().uuidString - private var lastRecognizingResult = AsrResult(text: "", language: "") - - private let workQueue = DispatchQueue(label: "asrtrtts.work", qos: .userInitiated) - - var autoDetectLanguage: Bool - - var callback: ((Dictionary) -> Void)? - var onSynthesisEncoded: ((Data) -> Void)? { - didSet { ttsManager.onSynthesisEncoded = onSynthesisEncoded } - } - - var autoPlay: Bool = false { - didSet { ttsManager.autoPlay = autoPlay } - } - - var forceBuiltInSpeaker: Bool = false { - didSet { ttsManager.forceBuiltInSpeaker = forceBuiltInSpeaker } - } - - init(asrProvider: AsrProvider = .azure, - translationProvider: TranslationProvider = .azure, - ttsProvider: TtsProvider = .azure, - languages: [String] = ["zh-CN", "en-US"], - autoPlay: Bool = false, - autoDetectLanguage: Bool = false, - forceBuiltInSpeaker: Bool = false) { - self.autoDetectLanguage = autoDetectLanguage - currentLanguages = languages - asrManager = AsrManager(provider: asrProvider, languages: languages, autoDetectLanguage: autoDetectLanguage) - translationManager = TranslationManager(provider: translationProvider) - ttsManager = TtsManager(provider: ttsProvider, autoPlay: autoPlay, forceBuiltInSpeaker: forceBuiltInSpeaker) - self.autoPlay = autoPlay - self.forceBuiltInSpeaker = forceBuiltInSpeaker - setupCallbacks() - } - - private func setupCallbacks() { - asrManager.onRecognizing = { [weak self] result in - guard let self = self else { return } - self.lastRecognizingResult = result - let sid = self.sessionId - self.callback?(["sessionId": sid, "type": "recognizing", "recognizing": result.text, "language": result.language]) - } - - asrManager.onRecognized = { [weak self] result in - guard let self = self else { return } - let sid = self.sessionId - let finalResult = result.text.isEmpty ? self.lastRecognizingResult : result - self.lastRecognizingResult = AsrResult(text: "", language: "") - self.sessionId = UUID().uuidString - - guard !finalResult.text.isEmpty else { return } - - let languagesSnapshot = self.currentLanguages - let sourceLanguage: LanguageInfo? - let targetLanguage: LanguageInfo? - - if self.autoDetectLanguage { - let detectedLanguage = LanguageManager.findByCode(finalResult.language) - if detectedLanguage != nil { - sourceLanguage = detectedLanguage - let other = languagesSnapshot.first { $0.caseInsensitiveCompare(finalResult.language) != .orderedSame } - targetLanguage = LanguageManager.findByCode(other) - } else { - sourceLanguage = LanguageManager.findByCode(languagesSnapshot.first) - targetLanguage = LanguageManager.findByCode(languagesSnapshot.last) - } - } else { - sourceLanguage = LanguageManager.findByCode(languagesSnapshot.first) - targetLanguage = LanguageManager.findByCode(languagesSnapshot.last) - } - - self.workQueue.async { - let translationResult = self.translationManager.translateText( - text: finalResult.text, - sourceLanguage: sourceLanguage?.language ?? "", - targetLanguage: targetLanguage?.language ?? "" - ) - let translated = translationResult.translatedText ?? "" - self.callback?(["sessionId": sid, "type": "recognized", "recognized": finalResult.text, "translated": translated, "language": finalResult.language]) - - if !translated.isEmpty, let target = targetLanguage { - self.ttsManager.speakText(text: translated, - targetLanguage: target.code, - voiceName: target.voiceName) - } - } - } - } - - func switchProvider(asrProvider: AsrProvider? = nil, - translationProvider: TranslationProvider? = nil, - ttsProvider: TtsProvider? = nil, - languages: [String]? = nil) { - if let languages = languages { - currentLanguages = languages - } - if asrProvider != nil || languages != nil { - asrManager.switchProvider(provider: asrProvider, languages: currentLanguages) - } - if let tp = translationProvider { - translationManager.switchProvider(provider: tp) - } - if let tp = ttsProvider { - ttsManager.switchProvider(provider: tp) - } - } - - /// 预热 TTS 自动播放引擎(须在麦克风 VPIO 启用前调用),让第一句翻译即保持最大音量。 - func prepareForPlayback() { - ttsManager.prepareForPlayback() - } - - func startRecognition() { - asrManager.startRecognition() - } - - func stopRecognition() { - asrManager.stopRecognition() - } - - func pushStreamWrite(_ bytes: Data) { - asrManager.pushStreamWrite(bytes) - } - - func dispose() { - callback = nil - onSynthesisEncoded = nil - ttsManager.dispose() - asrManager.dispose() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/AsrTrTtsManagerPlugin.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/AsrTrTtsManagerPlugin.swift deleted file mode 100644 index 0b08f51a..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/AsrTrTtsManagerPlugin.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Flutter -import UIKit - -// MARK: - Event Channel Handler - -private class TranslationStreamHandler: NSObject, FlutterStreamHandler { - weak var plugin: AsrTrTtsManagerPlugin? - - init(plugin: AsrTrTtsManagerPlugin) { self.plugin = plugin } - - func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - plugin?.translationSink = events - return nil - } - - func onCancel(withArguments arguments: Any?) -> FlutterError? { - plugin?.translationSink = nil - return nil - } -} - -// MARK: - Plugin - -public class AsrTrTtsManagerPlugin: NSObject, FlutterPlugin { - private var translationHelper: TranslationHelperManager? - var translationSink: FlutterEventSink? - - private var translationStreamHandler: TranslationStreamHandler? - - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel( - name: "asr_tr_tts_manager", - binaryMessenger: registrar.messenger() - ) - let eventChannel = FlutterEventChannel( - name: "asr_tr_tts_manager/translation", - binaryMessenger: registrar.messenger() - ) - - let instance = AsrTrTtsManagerPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - - instance.translationStreamHandler = TranslationStreamHandler(plugin: instance) - eventChannel.setStreamHandler(instance.translationStreamHandler) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "translationInit": - translationInit(call: call, result: result) - case "translationStart": - translationStart(call: call, result: result) - case "translationStop": - translationStop(result: result) - case "translationDispose": - translationDispose(result: result) - case "enableRecord": - enableRecord(call: call, result: result) - case "stopRecord": - stopRecord(call: call, result: result) - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - Method Implementations - - private func translationInit(call: FlutterMethodCall, result: @escaping FlutterResult) { - let args = call.arguments as? [String: Any] - let providerStr = (args?["provider"] as? String ?? "CALL").uppercased() - let provider: TranslationHelperProvider - switch providerStr { - case "SIMULTANEOUS": provider = .simultaneous - case "FACETOFACE": provider = .faceToFace - default: provider = .call - } - translationHelper?.dispose() - translationHelper = TranslationHelperManager(provider: provider) - translationHelper?.callback = { [weak self] data in - self?.translationSink?(data) - } - result(true) - } - - private func translationStart(call: FlutterMethodCall, result: @escaping FlutterResult) { - let args = call.arguments as? [String: Any] - let sourceLanguage = args?["sourceLanguage"] as? String ?? "zh-CN" - let targetLanguage = args?["targetLanguage"] as? String ?? "en-US" - translationHelper?.start(sourceLanguage: sourceLanguage, targetLanguage: targetLanguage) - result(true) - } - - private func translationStop(result: @escaping FlutterResult) { - translationHelper?.stop() - result(true) - } - - private func translationDispose(result: @escaping FlutterResult) { - translationHelper?.dispose() - translationHelper = nil - result(true) - } - - // 开启录音:把当前激活的翻译模式正在处理的解码后 PCM 写入 WAV 文件 - private func enableRecord(call: FlutterMethodCall, result: @escaping FlutterResult) { - let args = call.arguments as? [String: Any] - guard let filePath = args?["filePath"] as? String else { - result(FlutterError(code: "INVALID_ARGS", message: "filePath is required", details: nil)) - return - } - translationHelper?.enableRecord(filePath: filePath) - result(true) - } - - // 停止录音,isSave=false 时丢弃文件 - private func stopRecord(call: FlutterMethodCall, result: @escaping FlutterResult) { - let args = call.arguments as? [String: Any] - let isSave = args?["isSave"] as? Bool ?? true - translationHelper?.stopRecord(isSave: isSave) - result(true) - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/LanguageManager.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/LanguageManager.swift deleted file mode 100644 index 5570b11d..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/LanguageManager.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -public struct LanguageInfo { - public let code: String - public let language: String - public let voiceName: String -} - -public enum LanguageManager { - public static let all: [LanguageInfo] = [ - LanguageInfo(code: "zh-CN", language: "zh", voiceName: "zh-CN-XiaoxiaoNeural"), - LanguageInfo(code: "zh-TW", language: "zh-Hant", voiceName: "zh-TW-HsiaoChenNeural"), - LanguageInfo(code: "zh-HK", language: "zh-Hant-hk",voiceName: "zh-HK-HiuMaanNeural"), - LanguageInfo(code: "en-US", language: "en", voiceName: "en-US-AriaNeural"), - LanguageInfo(code: "ja-JP", language: "ja", voiceName: "ja-JP-NanamiNeural"), - LanguageInfo(code: "ko-KR", language: "ko", voiceName: "ko-KR-SunHiNeural"), - LanguageInfo(code: "fr-FR", language: "fr", voiceName: "fr-FR-DeniseNeural"), - LanguageInfo(code: "es-ES", language: "es", voiceName: "es-ES-ElviraNeural"), - LanguageInfo(code: "pt-BR", language: "pt", voiceName: "pt-BR-FranciscaNeural"), - LanguageInfo(code: "it-IT", language: "it", voiceName: "it-IT-ElsaNeural"), - LanguageInfo(code: "de-DE", language: "de", voiceName: "de-DE-KatjaNeural"), - LanguageInfo(code: "ru-RU", language: "ru", voiceName: "ru-RU-SvetlanaNeural"), - LanguageInfo(code: "pl-PL", language: "pl", voiceName: "pl-PL-AgnieszkaNeural"), - LanguageInfo(code: "nl-NL", language: "nl", voiceName: "nl-NL-ColetteNeural"), - LanguageInfo(code: "sv-SE", language: "sv", voiceName: "sv-SE-SofieNeural"), - LanguageInfo(code: "cs-CZ", language: "cs", voiceName: "cs-CZ-VlastaNeural"), - LanguageInfo(code: "el-GR", language: "el", voiceName: "el-GR-AthinaNeural"), - LanguageInfo(code: "ro-RO", language: "ro", voiceName: "ro-RO-AlinaNeural"), - LanguageInfo(code: "hu-HU", language: "hu", voiceName: "hu-HU-NoemiNeural"), - LanguageInfo(code: "uk-UA", language: "uk", voiceName: "uk-UA-PolinaNeural"), - LanguageInfo(code: "da-DK", language: "da", voiceName: "da-DK-ChristelNeural"), - LanguageInfo(code: "fi-FI", language: "fi", voiceName: "fi-FI-NooraNeural"), - LanguageInfo(code: "nb-NO", language: "nb", voiceName: "nb-NO-IselinNeural"), - LanguageInfo(code: "hr-HR", language: "hr", voiceName: "hr-HR-GabrijelaNeural"), - LanguageInfo(code: "ca-ES", language: "ca", voiceName: "ca-ES-JoanaNeural"), - LanguageInfo(code: "ar-EG", language: "ar", voiceName: "ar-EG-SalmaNeural"), - LanguageInfo(code: "th-TH", language: "th", voiceName: "th-TH-PremwadeeNeural"), - LanguageInfo(code: "vi-VN", language: "vi", voiceName: "vi-VN-HoaiMyNeural"), - LanguageInfo(code: "id-ID", language: "id", voiceName: "id-ID-GadisNeural"), - LanguageInfo(code: "hi-IN", language: "hi", voiceName: "hi-IN-SwaraNeural"), - LanguageInfo(code: "tr-TR", language: "tr", voiceName: "tr-TR-EmelNeural"), - LanguageInfo(code: "ms-MY", language: "ms", voiceName: "ms-MY-YasminNeural"), - LanguageInfo(code: "bn-IN", language: "bn", voiceName: "bn-IN-TanishaaNeural"), - LanguageInfo(code: "ta-IN", language: "ta", voiceName: "ta-IN-PallaviNeural"), - LanguageInfo(code: "te-IN", language: "te", voiceName: "te-IN-ShrutiNeural"), - LanguageInfo(code: "mr-IN", language: "mr", voiceName: "mr-IN-AarohiNeural"), - LanguageInfo(code: "ur-IN", language: "ur", voiceName: "ur-IN-GulNeural"), - LanguageInfo(code: "fa-IR", language: "fa", voiceName: "fa-IR-DilaraNeural"), - LanguageInfo(code: "sw-KE", language: "sw", voiceName: "sw-KE-ZuriNeural"), - ] - - private static let byCode: [String: LanguageInfo] = Dictionary(uniqueKeysWithValues: all.map { (normalize($0.code), $0) }) - - public static func findByCode(_ code: String?) -> LanguageInfo? { - guard let code = code, !code.isEmpty else { return nil } - return byCode[normalize(code)] - } - - private static func normalize(_ value: String) -> String { - value.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "_", with: "-").lowercased() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/PrivacyInfo.xcprivacy b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/PrivacyInfo.xcprivacy deleted file mode 100644 index a34b7e2e..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,14 +0,0 @@ - - - - - NSPrivacyTrackingDomains - - NSPrivacyAccessedAPITypes - - NSPrivacyCollectedDataTypes - - NSPrivacyTracking - - - diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/VolcengineHelper.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/VolcengineHelper.swift deleted file mode 100644 index e810a8f1..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/VolcengineHelper.swift +++ /dev/null @@ -1,782 +0,0 @@ -import Foundation -import bluetooth_manager - -final class VolcengineHelper: NSObject { - // MARK: - 常量 - - private static let TAG = "VolcengineHelper" - private static let WS_URL = "wss://openspeech.bytedance.com/api/v4/ast/v2/translate" - - private static let APP_KEY = "8595121680" - private static let ACCESS_KEY = "ERAUYbJQBOv6M0ooFnUwo-tTWs08HWZn" - private static let RESOURCE_ID = "volc.service_type.10053" - - private static let EVENT_START_SESSION = 100 - private static let EVENT_FINISH_SESSION = 102 - private static let EVENT_SESSION_STARTED = 150 - private static let EVENT_SESSION_CANCELED = 151 - private static let EVENT_SESSION_FINISHED = 152 - private static let EVENT_SESSION_FAILED = 153 - private static let EVENT_TASK_REQUEST = 200 - private static let EVENT_SOURCE_SUBTITLE_START = 650 - private static let EVENT_SOURCE_SUBTITLE_RESPONSE = 651 - private static let EVENT_SOURCE_SUBTITLE_END = 652 - private static let EVENT_TRANSLATION_SUBTITLE_START = 653 - private static let EVENT_TRANSLATION_SUBTITLE_RESPONSE = 654 - private static let EVENT_TRANSLATION_SUBTITLE_END = 655 - private static let EVENT_TTS_SENTENCE_START = 350 - private static let EVENT_TTS_RESPONSE = 352 - private static let EVENT_TTS_SENTENCE_END = 351 - - private static let FRAME_PCM_BYTES = 640 // 16kHz × 20ms × 2 bytes - - private static let MAX_RECONNECT_ATTEMPTS = 3 - private static let RECONNECT_BASE_DELAY_MS = 1000 // 1s / 2s / 4s 指数退避 - - private static let FINISH_SESSION_TIMEOUT_MS = 5000 - - private static let PING_INTERVAL_SECONDS: TimeInterval = 15 - - // 预构建 TaskRequest header(event=200),所有实例共享:3 字节 [0x10, 0xC8, 0x01] - private static let taskRequestHeader: Data = { - let pw = ProtoWriter() - pw.writeVarint(field: 2, value: Int64(EVENT_TASK_REQUEST)) - return pw.toData() - }() - - // MARK: - 回调 - - var callback: ((Dictionary) -> Void)? - var onSynthesisEncoded: ((Data) -> Void)? - - // MARK: - 状态(workQueue 串行化) - - private var currentLanguages: [String] - private var session: URLSession? - private var webSocketTask: URLSessionWebSocketTask? - private var isRecognizing = false - private var isConnected = false - private var sessionId = UUID().uuidString - private var connectionId = UUID().uuidString - private var segmentId = UUID().uuidString - private var currentSourceText = "" - - private var reconnectAttempts = 0 - private var reconnectWork: DispatchWorkItem? - private var finishTimeoutWork: DispatchWorkItem? - private var pingTimer: DispatchSourceTimer? - - private let workQueue = DispatchQueue(label: "volcengine.helper.work", qos: .userInitiated) - - // MARK: - 音频 G722 编码(所有访问都在 workQueue 上,无需额外加锁) - - private var g722: G722Codec? - private var stashBuffer = Data(count: 8192) - private var stashSize = 0 - - // MARK: - 初始化 - - init(languages: [String] = ["zh-CN", "en-US"]) { - self.currentLanguages = languages - super.init() - } - - // MARK: - 对外 API - - func switchProvider(languages: [String]?) { - workQueue.async { [weak self] in - guard let self = self else { return } - if let langs = languages { - self.currentLanguages = langs - } - } - } - - func startRecognition() { - workQueue.async { [weak self] in - guard let self = self else { return } - if self.isRecognizing { return } - self.isRecognizing = true - self.isConnected = false - self.reconnectAttempts = 0 - self.cancelReconnect() - self.cancelFinishTimeout() - self.sessionId = UUID().uuidString - self.connectionId = UUID().uuidString - self.segmentId = UUID().uuidString - self.currentSourceText = "" - - self.releaseG722() - self.stashSize = 0 - - self.connectWebSocket() - } - } - - func stopRecognition() { - workQueue.async { [weak self] in - guard let self = self else { return } - if !self.isRecognizing { return } - self.isRecognizing = false - self.cancelReconnect() - self.sendFinishSession() - - // 等待服务端返回 SESSION_FINISHED/SESSION_CANCELED 后关闭,超时兜底 - let work = DispatchWorkItem { [weak self] in - guard let self = self else { return } - if self.webSocketTask != nil { - print("[\(VolcengineHelper.TAG)] FinishSession timeout, force closing WebSocket") - self.closeWebSocket() - } - } - self.finishTimeoutWork = work - self.workQueue.asyncAfter( - deadline: .now() + .milliseconds(VolcengineHelper.FINISH_SESSION_TIMEOUT_MS), - execute: work - ) - } - } - - func pushStreamWrite(_ bytes: Data) { - // 热路径,不入队列;依赖 URLSessionWebSocketTask.send 自身线程安全 - if !isRecognizing || !isConnected { return } - sendTaskRequest(bytes) - } - - func dispose() { - workQueue.async { [weak self] in - guard let self = self else { return } - self.isRecognizing = false - self.isConnected = false - self.callback = nil - self.onSynthesisEncoded = nil - self.cancelReconnect() - self.cancelFinishTimeout() - self.closeWebSocket() - - self.releaseG722() - self.stashSize = 0 - } - } - - // MARK: - WebSocket 连接管理 - - private func connectWebSocket() { - closeWebSocket() - - guard let url = URL(string: VolcengineHelper.WS_URL) else { - print("[\(VolcengineHelper.TAG)] Invalid WS_URL") - return - } - - var request = URLRequest(url: url) - request.setValue(VolcengineHelper.APP_KEY, forHTTPHeaderField: "X-Api-App-Key") - request.setValue(VolcengineHelper.ACCESS_KEY, forHTTPHeaderField: "X-Api-Access-Key") - request.setValue(VolcengineHelper.RESOURCE_ID, forHTTPHeaderField: "X-Api-Resource-Id") - - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 10 - let newSession = URLSession(configuration: config, delegate: self, delegateQueue: nil) - let task = newSession.webSocketTask(with: request) - self.session = newSession - self.webSocketTask = task - - receiveLoop(task: task) - task.resume() - startPingTimer() - } - - private func closeWebSocket() { - stopPingTimer() - let task = self.webSocketTask - self.webSocketTask = nil - self.isConnected = false - task?.cancel(with: .normalClosure, reason: "normal close".data(using: .utf8)) - - // 释放 session(打破 URLSession → delegate 强引用) - let s = self.session - self.session = nil - s?.finishTasksAndInvalidate() - } - - private func receiveLoop(task: URLSessionWebSocketTask) { - task.receive { [weak self, weak task] result in - guard let self = self, let task = task else { return } - switch result { - case .success(let message): - switch message { - case .data(let data): - self.workQueue.async { self.parseResponse(data) } - case .string(let text): - print("[\(VolcengineHelper.TAG)] Received text: \(text)") - @unknown default: - break - } - // 只要当前仍是同一个 task,继续递归监听 - if task.state == .running { - self.receiveLoop(task: task) - } - case .failure(let error): - self.workQueue.async { - self.handleConnectionFailure(error: error) - } - } - } - } - - private func handleConnectionFailure(error: Error) { - isConnected = false - // 主动停止时的取消失败是预期行为,静默丢弃,不抛 error 事件 - if !isRecognizing { - print("[\(VolcengineHelper.TAG)] WebSocket closed while stopping: \(error.localizedDescription)") - return - } - print("[\(VolcengineHelper.TAG)] WebSocket failure: \(error.localizedDescription)") - if reconnectAttempts < VolcengineHelper.MAX_RECONNECT_ATTEMPTS { - let delay = VolcengineHelper.RECONNECT_BASE_DELAY_MS << reconnectAttempts - reconnectAttempts += 1 - print("[\(VolcengineHelper.TAG)] Reconnect attempt \(reconnectAttempts) after \(delay)ms...") - let work = DispatchWorkItem { [weak self] in - guard let self = self else { return } - if self.isRecognizing { self.connectWebSocket() } - } - reconnectWork = work - workQueue.asyncAfter(deadline: .now() + .milliseconds(delay), execute: work) - } else { - // 重连用尽:清理 task/session/pingTimer,打破 URLSession → delegate 强引用。 - // 保留 isRecognizing=true 与 Android 一致(要求用户主动 stop 确认错误后再 start) - closeWebSocket() - callback?([ - "type": "error", - "message": error.localizedDescription, - ]) - } - } - - private func cancelReconnect() { - reconnectWork?.cancel() - reconnectWork = nil - } - - private func cancelFinishTimeout() { - finishTimeoutWork?.cancel() - finishTimeoutWork = nil - } - - private func startPingTimer() { - stopPingTimer() - let timer = DispatchSource.makeTimerSource(queue: workQueue) - timer.schedule( - deadline: .now() + VolcengineHelper.PING_INTERVAL_SECONDS, - repeating: VolcengineHelper.PING_INTERVAL_SECONDS - ) - timer.setEventHandler { [weak self] in - guard let self = self, let task = self.webSocketTask else { return } - task.sendPing { err in - if let err = err { - print("[\(VolcengineHelper.TAG)] Ping failed: \(err.localizedDescription)") - } - } - } - timer.resume() - pingTimer = timer - } - - private func stopPingTimer() { - pingTimer?.cancel() - pingTimer = nil - } - - // MARK: - 发送 Protobuf 消息 - - private func sendStartSession() { - // handshake 完成前若已 stop,直接关闭、不开 session,避免服务端回的 - // SESSION_STARTED 把 isConnected 置回 true 产生幻觉状态 - if !isRecognizing { - closeWebSocket() - return - } - let langs = currentLanguages - if langs.count < 2 { - print("[\(VolcengineHelper.TAG)] Need at least 2 languages, got: \(langs)") - isRecognizing = false - isConnected = false - callback?([ - "type": "error", - "message": "Need source and target language", - ]) - closeWebSocket() - return - } - let sourceCode = langs[0] - let targetCode = langs[1] - let sourceLang = LanguageManager.findByCode(sourceCode)?.language ?? "zh" - let targetLang = LanguageManager.findByCode(targetCode)?.language ?? "en" - - let pw = ProtoWriter() - - // field 1: request_meta (RequestMeta) - let metaPw = ProtoWriter() - metaPw.writeString(field: 2, value: VolcengineHelper.APP_KEY) - metaPw.writeString(field: 4, value: VolcengineHelper.RESOURCE_ID) - metaPw.writeString(field: 5, value: connectionId) - metaPw.writeString(field: 6, value: sessionId) - pw.writeBytes(field: 1, value: metaPw.toData()) - - // field 2: event (enum Type = StartSession = 100) - pw.writeVarint(field: 2, value: Int64(VolcengineHelper.EVENT_START_SESSION)) - - // field 3: user (User) - let userPw = ProtoWriter() - userPw.writeString(field: 1, value: "deapvoice_ios") - userPw.writeString(field: 3, value: "ios") - pw.writeBytes(field: 3, value: userPw.toData()) - - // field 4: source_audio (Audio) - let srcAudioPw = ProtoWriter() - srcAudioPw.writeString(field: 4, value: "pcm") - srcAudioPw.writeVarint(field: 7, value: 16000) - srcAudioPw.writeVarint(field: 8, value: 16) - srcAudioPw.writeVarint(field: 9, value: 1) - pw.writeBytes(field: 4, value: srcAudioPw.toData()) - - // field 5: target_audio (Audio) - let tgtAudioPw = ProtoWriter() - tgtAudioPw.writeString(field: 4, value: "pcm") - tgtAudioPw.writeVarint(field: 7, value: 16000) - tgtAudioPw.writeVarint(field: 8, value: 16) - tgtAudioPw.writeVarint(field: 9, value: 1) - pw.writeBytes(field: 5, value: tgtAudioPw.toData()) - - // field 6: request (ast.ReqParams) - let reqPw = ProtoWriter() - reqPw.writeString(field: 1, value: "s2s") - reqPw.writeString(field: 2, value: sourceLang) - reqPw.writeString(field: 3, value: targetLang) - pw.writeBytes(field: 6, value: reqPw.toData()) - - let data = pw.toData() - webSocketTask?.send(.data(data)) { err in - if let err = err { - print("[\(VolcengineHelper.TAG)] Send StartSession failed: \(err.localizedDescription)") - } - } - print("[\(VolcengineHelper.TAG)] Sent StartSession (\(data.count) bytes), session=\(sessionId), \(sourceLang)→\(targetLang)") - } - - private func sendTaskRequest(_ audioData: Data) { - let audioLen = audioData.count - // 外层嵌套结构:[header] field4(length=innerSize) { field14(length=audioLen) = audioData } - let innerSize = 1 + varintByteCount(audioLen) + audioLen - let outerSize = 1 + varintByteCount(innerSize) + innerSize - - var buf = Data() - buf.reserveCapacity(VolcengineHelper.taskRequestHeader.count + outerSize) - buf.append(VolcengineHelper.taskRequestHeader) - - buf.append(UInt8((4 << 3) | 2)) // field 4, wire type 2 - appendVarint(&buf, value: innerSize) - buf.append(UInt8((14 << 3) | 2)) // field 14, wire type 2 - appendVarint(&buf, value: audioLen) - buf.append(audioData) - - webSocketTask?.send(.data(buf)) { [weak self] err in - if let err = err { - print("[\(VolcengineHelper.TAG)] sendTaskRequest failed: \(err.localizedDescription)") - self?.workQueue.async { self?.isConnected = false } - } - } - } - - private func varintByteCount(_ value: Int) -> Int { - var v = UInt(bitPattern: value) - var size = 1 - while (v & ~0x7F) != 0 { - size += 1 - v >>= 7 - } - return size - } - - private func appendVarint(_ buf: inout Data, value: Int) { - var v = UInt(bitPattern: value) - while (v & ~0x7F) != 0 { - buf.append(UInt8((v & 0x7F) | 0x80)) - v >>= 7 - } - buf.append(UInt8(v & 0x7F)) - } - - private func sendFinishSession() { - let pw = ProtoWriter() - pw.writeVarint(field: 2, value: Int64(VolcengineHelper.EVENT_FINISH_SESSION)) - let data = pw.toData() - webSocketTask?.send(.data(data)) { err in - if let err = err { - print("[\(VolcengineHelper.TAG)] Send FinishSession failed: \(err.localizedDescription)") - } - } - print("[\(VolcengineHelper.TAG)] Sent FinishSession") - } - - // MARK: - 解析 Protobuf 响应 - - private func parseResponse(_ data: Data) { - do { - var pr = ProtoReader(data: data) - var event = 0 - var responseData: Data? - var text = "" - var statusCode = 0 - var statusMessage = "" - - while pr.hasMore { - let tag = try pr.readTag() - let fieldNumber = tag >> 3 - let wireType = tag & 0x7 - - switch fieldNumber { - case 1: // response_meta - let metaBytes = try pr.readLengthDelimited() - var metaPr = ProtoReader(data: metaBytes) - while metaPr.hasMore { - let metaTag = try metaPr.readTag() - let metaField = metaTag >> 3 - let metaWire = metaTag & 0x7 - switch metaField { - case 3: - statusCode = Int(try metaPr.readVarint()) - case 4: - statusMessage = try metaPr.readStringField(wireType: metaWire) - default: - try metaPr.skip(wireType: metaWire) - } - } - case 2: - event = Int(try pr.readVarint()) - case 3: - responseData = try pr.readLengthDelimited() - case 4: - text = try pr.readStringField(wireType: wireType) - default: - try pr.skip(wireType: wireType) - } - } - - handleEvent( - event: event, - text: text, - audioData: responseData, - statusCode: statusCode, - statusMessage: statusMessage - ) - } catch { - print("[\(VolcengineHelper.TAG)] Parse response failed: \(error.localizedDescription)") - } - } - - private func handleEvent( - event: Int, - text: String, - audioData: Data?, - statusCode: Int, - statusMessage: String - ) { - switch event { - case VolcengineHelper.EVENT_SESSION_STARTED: - print("[\(VolcengineHelper.TAG)] Session started") - isConnected = true - reconnectAttempts = 0 - - case VolcengineHelper.EVENT_SESSION_FAILED: - print("[\(VolcengineHelper.TAG)] Session failed: \(statusCode) \(statusMessage)") - isConnected = false - cancelReconnect() - closeWebSocket() - callback?([ - "type": "error", - "message": "Session failed: \(statusCode) \(statusMessage)", - ]) - - case VolcengineHelper.EVENT_SESSION_FINISHED: - print("[\(VolcengineHelper.TAG)] Session finished") - isConnected = false - cancelReconnect() - closeWebSocket() - - case VolcengineHelper.EVENT_SESSION_CANCELED: - print("[\(VolcengineHelper.TAG)] Session canceled") - isConnected = false - cancelReconnect() - closeWebSocket() - - case VolcengineHelper.EVENT_SOURCE_SUBTITLE_START: - currentSourceText = "" - - case VolcengineHelper.EVENT_SOURCE_SUBTITLE_RESPONSE: - currentSourceText = text - if !text.isEmpty { - let sourceCode = currentLanguages.first ?? "zh-CN" - callback?([ - "sessionId": segmentId, - "type": "recognizing", - "recognizing": text, - "language": sourceCode, - ]) - } - - case VolcengineHelper.EVENT_SOURCE_SUBTITLE_END: - if !text.isEmpty { - currentSourceText = text - } - - case VolcengineHelper.EVENT_TRANSLATION_SUBTITLE_RESPONSE: - if let audio = audioData, !audio.isEmpty { - handleAudioData(audio) - } - - case VolcengineHelper.EVENT_TRANSLATION_SUBTITLE_END: - if let audio = audioData, !audio.isEmpty { - handleAudioData(audio) - } - flushAudioStash() - - let sourceCode = currentLanguages.first ?? "zh-CN" - let sourceText = currentSourceText - let translatedText = text - if !sourceText.isEmpty || !translatedText.isEmpty { - callback?([ - "sessionId": segmentId, - "type": "recognized", - "recognized": sourceText, - "translated": translatedText, - "language": sourceCode, - ]) - segmentId = UUID().uuidString - currentSourceText = "" - } - - case VolcengineHelper.EVENT_TTS_RESPONSE: - if let audio = audioData, !audio.isEmpty { - handleAudioData(audio) - } - - case VolcengineHelper.EVENT_TTS_SENTENCE_END: - flushAudioStash() - - default: - if statusCode != 0 && !statusMessage.isEmpty { - print("[\(VolcengineHelper.TAG)] Event \(event) with status \(statusCode): \(statusMessage)") - } - } - } - - // MARK: - 音频 G722 编码 - - private func handleAudioData(_ pcmData: Data) { - guard let encodedCb = onSynthesisEncoded, !pcmData.isEmpty else { return } - - if g722 == nil { - g722 = G722Codec(sampleRate: 16000) - } - - let required = stashSize + pcmData.count - if required > stashBuffer.count { - var newCap = stashBuffer.count - while newCap < required { newCap <<= 1 } - var newBuf = Data(count: newCap) - newBuf.replaceSubrange(0..= frameBytes { - // slice 下标共享底层存储,比 subdata(in:) 少一次 640 字节拷贝 - let frame = stashBuffer[cursor.. 0 { - let remaining = stashSize - cursor - if remaining > 0 { - // 用 subdata 避免源/目标 slice 共享存储引发的 aliasing(保守但可读) - let tail = stashBuffer.subdata(in: cursor..>= 7 - } - buffer.append(UInt8(v & 0x7F)) - } - - func toData() -> Data { buffer } - } - - // MARK: - Protobuf 解码工具 - - private struct ProtoReader { - let data: Data - private var pos: Int = 0 - - init(data: Data) { self.data = data } - - var hasMore: Bool { pos < data.count } - - mutating func readTag() throws -> Int { Int(try readRawVarint()) } - - mutating func readVarint() throws -> Int64 { try readRawVarint() } - - mutating func readLengthDelimited() throws -> Data { - let length = Int(try readRawVarint()) - if length < 0 || pos + length > data.count { - throw VolcengineProtoError.invalidLength(length: length, pos: pos, size: data.count) - } - let result = data.subdata(in: pos.. String { - if wireType == 2 { - let bytes = try readLengthDelimited() - return String(data: bytes, encoding: .utf8) ?? "" - } else { - _ = try readRawVarint() - return "" - } - } - - mutating func skip(wireType: Int) throws { - switch wireType { - case 0: - _ = try readRawVarint() - case 1: - pos += 8 - case 2: - let len = Int(try readRawVarint()) - pos += len - case 5: - pos += 4 - default: - throw VolcengineProtoError.unknownWireType(wireType) - } - } - - private mutating func readRawVarint() throws -> Int64 { - var result: UInt64 = 0 - var shift = 0 - while pos < data.count { - let b = UInt64(data[pos]) - pos += 1 - result |= (b & 0x7F) << shift - if (b & 0x80) == 0 { - return Int64(bitPattern: result) - } - shift += 7 - if shift >= 64 { - throw VolcengineProtoError.varintTooLong - } - } - return Int64(bitPattern: result) - } - } - - private enum VolcengineProtoError: Error { - case invalidLength(length: Int, pos: Int, size: Int) - case unknownWireType(Int) - case varintTooLong - } -} - -// MARK: - URLSessionWebSocketDelegate - -extension VolcengineHelper: URLSessionWebSocketDelegate { - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didOpenWithProtocol protocolName: String? - ) { - workQueue.async { [weak self] in - guard let self = self else { return } - // 避免旧 task 的迟到回调 - guard self.webSocketTask === webSocketTask else { return } - print("[\(VolcengineHelper.TAG)] WebSocket connected") - self.sendStartSession() - } - } - - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, - reason: Data? - ) { - workQueue.async { [weak self] in - guard let self = self else { return } - guard self.webSocketTask === webSocketTask else { return } - let reasonStr = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "" - print("[\(VolcengineHelper.TAG)] WebSocket closed: \(closeCode.rawValue) \(reasonStr)") - self.isConnected = false - } - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/AsrManager.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/AsrManager.swift deleted file mode 100644 index 53b0cde4..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/AsrManager.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation - -class AsrManager: AsrProtocol { - private var currentProvider: AsrProvider - private var currentLanguages: [String] - private var autoDetectLanguage: Bool - private var delegate: AsrProtocol? - - var onRecognizing: ((AsrResult) -> Void)? { - didSet { delegate?.onRecognizing = onRecognizing } - } - var onRecognized: ((AsrResult) -> Void)? { - didSet { delegate?.onRecognized = onRecognized } - } - - init(provider: AsrProvider = .azure, languages: [String] = ["zh-CN", "en-US"], autoDetectLanguage: Bool = false) { - self.currentProvider = provider - self.currentLanguages = languages - self.autoDetectLanguage = autoDetectLanguage - createDelegate() - } - - func switchProvider(provider: AsrProvider? = nil, languages: [String]? = nil, autoDetectLanguage: Bool? = nil) { - if let p = provider { currentProvider = p } - if let l = languages { currentLanguages = l } - if let a = autoDetectLanguage { self.autoDetectLanguage = a } - delegate?.dispose() - createDelegate() - } - - private func createDelegate() { - switch currentProvider { - case .azure: - delegate = AzureAsr(languages: currentLanguages, autoDetectLanguage: autoDetectLanguage) - case .volcengine: - delegate = VolcengineAsr(languages: currentLanguages, autoDetectLanguage: autoDetectLanguage) - } - delegate?.onRecognizing = onRecognizing - delegate?.onRecognized = onRecognized - } - - func getProvider() -> AsrProvider { currentProvider } - - func startRecognition() { delegate?.startRecognition() } - func stopRecognition() { delegate?.stopRecognition() } - func pushStreamWrite(_ bytes: Data) { delegate?.pushStreamWrite(bytes) } - func pushStreamClose() { delegate?.pushStreamClose() } - - func dispose() { - onRecognizing = nil - onRecognized = nil - let d = delegate - delegate = nil - d?.dispose() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/AsrProtocol.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/AsrProtocol.swift deleted file mode 100644 index b12aab56..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/AsrProtocol.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -enum AsrProvider { - case azure - case volcengine -} - -struct AsrResult { - let text: String - let language: String -} - -protocol AsrProtocol: AnyObject { - var onRecognizing: ((AsrResult) -> Void)? { get set } - var onRecognized: ((AsrResult) -> Void)? { get set } - - func startRecognition() - func stopRecognition() - func pushStreamWrite(_ bytes: Data) - func pushStreamClose() - func dispose() -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/AzureAsr.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/AzureAsr.swift deleted file mode 100644 index 12ee230b..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/AzureAsr.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -import MicrosoftCognitiveServicesSpeech - -class AzureAsr: AsrProtocol { - var onRecognizing: ((AsrResult) -> Void)? - var onRecognized: ((AsrResult) -> Void)? - - private var pushStream: SPXPushAudioInputStream? - private var recognizer: SPXSpeechRecognizer? - - private let subscriptionKey = "rLPdyGgD1v7z7khzpdrd4YNWUinjw6GzAbA1pPXBRmUjitfjDxdsJQQJ99BGAC3pKaRXJ3w3AAAYACOGAHZZ" - private let region = "eastasia" - - private let workQueue = DispatchQueue(label: "asr.work", qos: .userInitiated) - private let autoDetectLanguage: Bool - private let languages: [String] - - init(languages: [String] = ["zh-CN", "en-US"], autoDetectLanguage: Bool = false) { - self.autoDetectLanguage = autoDetectLanguage - self.languages = languages - setup(languages: languages) - } - - private func setup(languages: [String]) { - do { - let speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - guard let audioFormat = SPXAudioStreamFormat(usingPCMWithSampleRate: 16000, bitsPerSample: 16, channels: 1) else { return } - guard let stream = try SPXPushAudioInputStream(audioFormat: audioFormat) else { return } - guard let audioConfig = try SPXAudioConfiguration(streamInput: stream) else { return } - - let rec: SPXSpeechRecognizer - if autoDetectLanguage { - try speechConfig.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) - let autoDetect = try SPXAutoDetectSourceLanguageConfiguration(languages) - rec = try SPXSpeechRecognizer( - speechConfiguration: speechConfig, - autoDetectSourceLanguageConfiguration: autoDetect, - audioConfiguration: audioConfig - ) - } else { - speechConfig.speechRecognitionLanguage = languages.first ?? "zh-CN" - rec = try SPXSpeechRecognizer( - speechConfiguration: speechConfig, - audioConfiguration: audioConfig - ) - } - - rec.addRecognizingEventHandler { [weak self] (_: SPXSpeechRecognizer, event: SPXSpeechRecognitionEventArgs) in - guard let self = self else { return } - let text = event.result.text?.trimmingCharacters(in: CharacterSet.whitespaces) ?? "" - guard !text.isEmpty else { return } - let language: String - if self.autoDetectLanguage { - language = SPXAutoDetectSourceLanguageResult(event.result).language ?? "" - } else { - language = self.languages.first ?? "" - } - self.onRecognizing?(AsrResult(text: text, language: language)) - } - - rec.addRecognizedEventHandler { [weak self] (_: SPXSpeechRecognizer, event: SPXSpeechRecognitionEventArgs) in - guard let self = self else { return } - guard event.result.reason == SPXResultReason.recognizedSpeech else { return } - let text = event.result.text?.trimmingCharacters(in: CharacterSet.whitespaces) ?? "" - let language: String - if self.autoDetectLanguage { - language = SPXAutoDetectSourceLanguageResult(event.result).language ?? "" - } else { - language = self.languages.first ?? "" - } - self.onRecognized?(AsrResult(text: text, language: language)) - } - - self.pushStream = stream - self.recognizer = rec - } catch { - } - } - - func startRecognition() { - workQueue.async { [weak self] in - try? self?.recognizer?.startContinuousRecognition() - } - } - - func stopRecognition() { - workQueue.async { [weak self] in - try? self?.recognizer?.stopContinuousRecognition() - } - } - - func pushStreamWrite(_ bytes: Data) { - pushStream?.write(bytes) - } - - func pushStreamClose() { - pushStream?.close() - } - - func dispose() { - workQueue.async { [weak self] in - try? self?.recognizer?.stopContinuousRecognition() - self?.pushStream?.close() - self?.recognizer = nil - self?.pushStream = nil - } - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/VolcengineAsr.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/VolcengineAsr.swift deleted file mode 100644 index fc162547..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/asr/VolcengineAsr.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -import MicrosoftCognitiveServicesSpeech - -class VolcengineAsr: AsrProtocol { - var onRecognizing: ((AsrResult) -> Void)? - var onRecognized: ((AsrResult) -> Void)? - - private var pushStream: SPXPushAudioInputStream? - private var recognizer: SPXSpeechRecognizer? - - private let subscriptionKey = "rLPdyGgD1v7z7khzpdrd4YNWUinjw6GzAbA1pPXBRmUjitfjDxdsJQQJ99BGAC3pKaRXJ3w3AAAYACOGAHZZ" - private let region = "eastasia" - - private let workQueue = DispatchQueue(label: "asr.volcengine.work", qos: .userInitiated) - private let autoDetectLanguage: Bool - private let languages: [String] - - init(languages: [String] = ["zh-CN", "en-US"], autoDetectLanguage: Bool = false) { - self.autoDetectLanguage = autoDetectLanguage - self.languages = languages - setup(languages: languages) - } - - private func setup(languages: [String]) { - do { - let speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - guard let audioFormat = SPXAudioStreamFormat(usingPCMWithSampleRate: 16000, bitsPerSample: 16, channels: 1) else { return } - guard let stream = try SPXPushAudioInputStream(audioFormat: audioFormat) else { return } - guard let audioConfig = try SPXAudioConfiguration(streamInput: stream) else { return } - - let rec: SPXSpeechRecognizer - if autoDetectLanguage { - try speechConfig.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) - let autoDetect = try SPXAutoDetectSourceLanguageConfiguration(languages) - rec = try SPXSpeechRecognizer( - speechConfiguration: speechConfig, - autoDetectSourceLanguageConfiguration: autoDetect, - audioConfiguration: audioConfig - ) - } else { - speechConfig.speechRecognitionLanguage = languages.first ?? "zh-CN" - rec = try SPXSpeechRecognizer( - speechConfiguration: speechConfig, - audioConfiguration: audioConfig - ) - } - - rec.addRecognizingEventHandler { [weak self] (_: SPXSpeechRecognizer, event: SPXSpeechRecognitionEventArgs) in - guard let self = self else { return } - let text = event.result.text?.trimmingCharacters(in: CharacterSet.whitespaces) ?? "" - guard !text.isEmpty else { return } - let language: String - if self.autoDetectLanguage { - language = SPXAutoDetectSourceLanguageResult(event.result).language ?? "" - } else { - language = self.languages.first ?? "" - } - self.onRecognizing?(AsrResult(text: text, language: language)) - } - - rec.addRecognizedEventHandler { [weak self] (_: SPXSpeechRecognizer, event: SPXSpeechRecognitionEventArgs) in - guard let self = self else { return } - guard event.result.reason == SPXResultReason.recognizedSpeech else { return } - let text = event.result.text?.trimmingCharacters(in: CharacterSet.whitespaces) ?? "" - let language: String - if self.autoDetectLanguage { - language = SPXAutoDetectSourceLanguageResult(event.result).language ?? "" - } else { - language = self.languages.first ?? "" - } - self.onRecognized?(AsrResult(text: text, language: language)) - } - - self.pushStream = stream - self.recognizer = rec - } catch { - } - } - - func startRecognition() { - workQueue.async { [weak self] in - try? self?.recognizer?.startContinuousRecognition() - } - } - - func stopRecognition() { - workQueue.async { [weak self] in - try? self?.recognizer?.stopContinuousRecognition() - } - } - - func pushStreamWrite(_ bytes: Data) { - pushStream?.write(bytes) - } - - func pushStreamClose() { - pushStream?.close() - } - - func dispose() { - workQueue.async { [weak self] in - try? self?.recognizer?.stopContinuousRecognition() - self?.pushStream?.close() - self?.recognizer = nil - self?.pushStream = nil - } - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/recorder/AstStereoRecorder.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/recorder/AstStereoRecorder.swift deleted file mode 100644 index 08975b88..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/recorder/AstStereoRecorder.swift +++ /dev/null @@ -1,171 +0,0 @@ -import Foundation - -/// 通话翻译模式立体声录音器 -/// -/// 由本端麦克风 PCM(来自 BLE 输入的左声道)作为时钟,按 16k/16bit 的节奏 -/// 持续写入录音文件;对方端解码出的 PCM(远端/对方声音)异步排入右声道 FIFO, -/// 与左声道按相同样本数交错合成立体声后写出。 -/// -/// 输出格式:16 kHz / 16-bit PCM / 2 channels WAV。 -/// 从 azure_speech 插件移植,未依赖任何外部模块。 -public final class AstStereoRecorder { - /// 通话翻译录音中麦克风原音偏小、对方 TTS 偏响,对两路分别做线性增益再 tanh 软削波。 - /// 左(麦克风) +12dB ≈ 3.98x,右(TTS) -4dB ≈ 0.63x,与 Android 端 AstStereoRecorder 保持一致。 - private static let leftGain: Float = 3.98 - private static let rightGain: Float = 0.63 - /// 右声道 FIFO 上限(约 5 秒 @16kHz/16bit 单声道);左声道(时钟)长时间缺席时防止无限增长 - private static let maxRightFifoBytes = 16000 * 2 * 5 - - /// 受写入队列保护的状态 - private let stateQueue = DispatchQueue(label: "ast.stereo.recorder.state") - private var recordFile: RecordFile? - private var rightFifo = Data() - private var isRunningFlag = false - private var isPausedFlag = false - - /// 调用点用的轻量级开关,避免每帧都进 stateQueue.sync。 - /// 在 start/pause/resume/stop 内同步更新,读时直接走原子 Bool(接受 start/stop 瞬间的一两帧延迟)。 - private var _active: Bool = false - public var isActive: Bool { _active } - - public init() {} - - /// 开始录音 - /// - Parameter filePath: 输出文件路径(.wav) - @discardableResult - public func start(filePath: String) -> Bool { - return stateQueue.sync { - // 已在录音,先停掉旧的 - if recordFile != nil { - _ = recordFile?.closeFile(isSave: false) - recordFile = nil - } - rightFifo.removeAll(keepingCapacity: false) - isPausedFlag = false - - let file = RecordFile() - file.setAudioConfig(sampleRate: 16000, channels: 2) - file.creatingFiles(atPath: filePath) - recordFile = file - isRunningFlag = true - _active = true - return true - } - } - - @discardableResult - public func pause() -> Bool { - return stateQueue.sync { - isPausedFlag = true - _active = false - return true - } - } - - @discardableResult - public func resume() -> Bool { - return stateQueue.sync { - isPausedFlag = false - _active = isRunningFlag - return true - } - } - - @discardableResult - public func stop(isSave: Bool) -> Bool { - return stateQueue.sync { - guard let file = recordFile else { - _active = false - return false - } - isRunningFlag = false - isPausedFlag = false - _active = false - let ok = file.closeFile(isSave: isSave) - recordFile = nil - rightFifo.removeAll(keepingCapacity: false) - return ok - } - } - - /// 排入右声道(对方端解码出的远端 PCM) - /// - Parameter pcm: 16 kHz / 16-bit PCM mono - public func enqueueRightChannel(_ pcm: Data) { - guard !pcm.isEmpty else { return } - stateQueue.sync { - guard isRunningFlag, !isPausedFlag else { return } - rightFifo.append(pcm) - // 超出上限则丢弃最旧字节,避免 OOM - if rightFifo.count > AstStereoRecorder.maxRightFifoBytes { - rightFifo.removeFirst(rightFifo.count - AstStereoRecorder.maxRightFifoBytes) - } - } - } - - /// 写入一帧左声道(本端麦克风 PCM)并消费等量右声道字节,交错后落盘 - /// - Parameter mono: 16 kHz / 16-bit PCM mono(来自 BLE 输入解出的 leftBuffer) - public func writeLeftChannel(_ mono: Data) { - guard !mono.isEmpty else { return } - // 左声道字节数必须是偶数(16-bit 对齐) - let leftBytes = mono.count - (mono.count % 2) - if leftBytes == 0 { return } - - var stereo = Data() - var fileToWrite: RecordFile? - stateQueue.sync { - guard isRunningFlag, !isPausedFlag, let file = recordFile else { return } - - // 从右声道 FIFO 中取出与左声道等长的字节,不足部分补零 - let popCount = min(rightFifo.count, leftBytes) - var right = Data(capacity: leftBytes) - if popCount > 0 { - right.append(rightFifo.prefix(popCount)) - rightFifo.removeFirst(popCount) - } - if popCount < leftBytes { - right.append(Data(count: leftBytes - popCount)) - } - - stereo = Data(capacity: leftBytes * 2) - let leftPrefix = mono.prefix(leftBytes) - leftPrefix.withUnsafeBytes { (lPtr: UnsafeRawBufferPointer) in - right.withUnsafeBytes { (rPtr: UnsafeRawBufferPointer) in - let lBase = lPtr.bindMemory(to: UInt8.self).baseAddress! - let rBase = rPtr.bindMemory(to: UInt8.self).baseAddress! - let sampleCount = leftBytes / 2 - for i in 0.. (UInt8, UInt8) { - if gain == 1.0 { return (lo, hi) } - let unsigned = UInt16(lo) | (UInt16(hi) << 8) - let s = Int16(bitPattern: unsigned) - let x = (Float(s) / 32767.0) * gain - let y = tanh(x) - var v = y * 32767.0 - if v > 32767.0 { v = 32767.0 } - if v < -32768.0 { v = -32768.0 } - let outU = UInt16(bitPattern: Int16(v)) - return (UInt8(outU & 0xFF), UInt8((outU >> 8) & 0xFF)) - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/recorder/RecordFile.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/recorder/RecordFile.swift deleted file mode 100644 index ed0782ab..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/recorder/RecordFile.swift +++ /dev/null @@ -1,185 +0,0 @@ -import Foundation -import os.log - -/// WAV 录音写入器(16-bit PCM,单/双声道可配)。 -/// -/// creatingFiles() 先写占位 WAV 头;saveAudioDataToWav() 在后台串行队列异步追加 -/// PCM;closeFile() 等写入完成后回填真实 RIFF/data 长度,得到合法 WAV。 -/// 若 isSave==false 或无数据则删除文件。 -/// -/// 单声道:同声/设备辅助翻译直接使用。双声道:通话翻译由 AstStereoRecorder -/// 先把左右声道交织成立体声帧后再调本类写入。 -public class RecordFile { - private let log = OSLog(subsystem: "com.asrtrtts.manager", category: "RecordFile") - private var currentAudioFile: URL? - private var fileHandle: FileHandle? - private let writeQueue = DispatchQueue(label: "audio.write.queue") - private let writeGroup = DispatchGroup() - private var isWriting = false - private var dataBuffer = Data() - private var totalBytesWritten = 0 - - private var sampleRate: UInt32 = 16000 - private var channels: UInt32 = 1 // 1=单声道, 2=立体声 - - public init() {} - - public func setAudioConfig(sampleRate: UInt32, channels: UInt32) { - self.sampleRate = sampleRate - self.channels = channels - } - - // 创建文件并写入占位 WAV 头 - public func creatingFiles(atPath path: String) { - guard fileHandle == nil else { return } - - let fileURL = URL(fileURLWithPath: path) - let directoryURL = fileURL.deletingLastPathComponent() - do { - try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) - } catch { - os_log("创建目录失败: %{public}@", log: log, type: .error, error.localizedDescription) - return - } - - FileManager.default.createFile(atPath: path, contents: nil) - do { - fileHandle = try FileHandle(forWritingTo: fileURL) - currentAudioFile = fileURL - let header = generateWavHeader(dataLength: 0) - try fileHandle?.write(contentsOf: header) - totalBytesWritten = 0 - } catch { - os_log("文件初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) - fileHandle = nil - currentAudioFile = nil - } - } - - // 异步追加 PCM(避免在音频实时回调线程上做文件 I/O) - public func saveAudioDataToWav(_ data: Data) { - guard fileHandle != nil, currentAudioFile != nil else { return } - - writeQueue.async { [weak self] in - guard let self = self else { return } - self.dataBuffer.append(data) - self.totalBytesWritten += data.count - - if !self.isWriting { - self.isWriting = true - self.writeGroup.enter() - defer { - self.isWriting = false - self.writeGroup.leave() - } - - while !self.dataBuffer.isEmpty { - let chunk: Data - if self.dataBuffer.count > 8192 { - chunk = self.dataBuffer.prefix(8192) - self.dataBuffer.removeFirst(8192) - } else { - chunk = self.dataBuffer - self.dataBuffer.removeAll() - } - do { - try self.fileHandle?.seekToEnd() - self.fileHandle?.write(chunk) - } catch { - os_log("写入数据失败: %{public}@", log: log, type: .error, error.localizedDescription) - } - } - } - } - } - - // 关闭并回填 WAV 头;isSave=false 或无数据则删除文件。 - // 整个过程在串行写队列上执行:排在所有已入队的异步写入之后、且与写入互斥, - // 彻底避免"关闭与写入并发"(写已关闭句柄)。 - public func closeFile(isSave: Bool) -> Bool { - // 全过程在串行写队列上执行:与所有异步写入互斥并排在其后;关闭后再来的写入块 - // 会因 fileHandle 已在队列内被置 nil 而自动跳过,无需额外的跨线程标志 - return writeQueue.sync { - defer { - fileHandle = nil - currentAudioFile = nil - dataBuffer.removeAll() - totalBytesWritten = 0 - } - - guard let fileURL = currentAudioFile, let handle = fileHandle else { - return false - } - - do { - try handle.close() - - if !isSave || totalBytesWritten == 0 { - try FileManager.default.removeItem(at: fileURL) - return false - } - - let header = generateWavHeader(dataLength: totalBytesWritten) - let updateHandle = try FileHandle(forUpdating: fileURL) - try updateHandle.seek(toOffset: 0) - updateHandle.write(header) - try updateHandle.close() - return true - } catch { - os_log("关闭文件失败: %{public}@", log: log, type: .error, error.localizedDescription) - return false - } - } - } - - // 标准 16-bit PCM WAV 头(44 字节,little-endian) - private func generateWavHeader(dataLength: Int) -> Data { - let totalLength = 36 + dataLength - let bytesPerSample: UInt32 = 2 - let byteRate = sampleRate * channels * bytesPerSample - let blockAlign = channels * bytesPerSample - - var header = Data() - header.append("RIFF".data(using: .ascii)!) - header.append(Data(bytes: [ - UInt8(totalLength & 0xFF), - UInt8((totalLength >> 8) & 0xFF), - UInt8((totalLength >> 16) & 0xFF), - UInt8((totalLength >> 24) & 0xFF) - ])) - header.append("WAVE".data(using: .ascii)!) - header.append("fmt ".data(using: .ascii)!) - header.append(Data(bytes: [16, 0, 0, 0])) // PCM 头长度 - header.append(Data(bytes: [1, 0])) // PCM 格式 - header.append(Data(bytes: [ - UInt8(channels & 0xFF), - UInt8((channels >> 8) & 0xFF) - ])) // 声道数 - header.append(Data(bytes: [ - UInt8(sampleRate & 0xFF), - UInt8((sampleRate >> 8) & 0xFF), - UInt8((sampleRate >> 16) & 0xFF), - UInt8((sampleRate >> 24) & 0xFF) - ])) // 采样率 - header.append(Data(bytes: [ - UInt8(byteRate & 0xFF), - UInt8((byteRate >> 8) & 0xFF), - UInt8((byteRate >> 16) & 0xFF), - UInt8((byteRate >> 24) & 0xFF) - ])) // 字节率 - header.append(Data(bytes: [ - UInt8(blockAlign & 0xFF), - UInt8((blockAlign >> 8) & 0xFF) - ])) // 块对齐 - header.append(Data(bytes: [16, 0])) // 样本位数 - header.append("data".data(using: .ascii)!) - header.append(Data(bytes: [ - UInt8(dataLength & 0xFF), - UInt8((dataLength >> 8) & 0xFF), - UInt8((dataLength >> 16) & 0xFF), - UInt8((dataLength >> 24) & 0xFF) - ])) - - return header - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/AzureTranslation.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/AzureTranslation.swift deleted file mode 100644 index c40107bb..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/AzureTranslation.swift +++ /dev/null @@ -1,184 +0,0 @@ -import Foundation -import CommonCrypto - -class AzureTranslation: TranslationProtocol { - private let baseURL = "https://translate.volcengineapi.com" - private let endpoint = "/" - private let service = "translate" - private let version = "2020-06-01" - private let action = "TranslateText" - private let algorithm = "HMAC-SHA256" - - private let accessKey = "AKLTZjJmODdkODQwN2RjNDcyMWI4NWM2ZWRmNDkxY2RiYzI" - private let secretKey = "T0Roa09EZGlNalZpTjJObE5HVmlZMkV3WVRkbVpUTTBORGMzTW1FNFlqYw==" - private let region = "cn-north-1" - private let maxRetry = 3 - - func translateText(text: String, sourceLanguage: String, targetLanguage: String) -> TranslationResult { - var lastError = "未知错误" - for attempt in 0.. TranslationResult { - let body: [String: Any] = [ - "SourceLanguage": sourceLanguage, - "TargetLanguage": targetLanguage, - "TextList": [text], - ] - guard let bodyData = try? JSONSerialization.data(withJSONObject: body), - let bodyString = String(data: bodyData, encoding: .utf8) else { - return TranslationResult(success: false, error: "请求序列化失败") - } - - let queryParams: [(String, String)] = [ - ("Action", action), - ("Region", region), - ("Service", service), - ("Version", version), - ] - - let headers: [String: String] - do { - headers = try generateSignature(method: "POST", bodyString: bodyString, queryParams: queryParams) - } catch { - return TranslationResult(success: false, error: "签名生成失败: \(error)") - } - - var components = URLComponents(string: baseURL)! - components.queryItems = queryParams.map { URLQueryItem(name: $0.0, value: $0.1) } - guard let url = components.url else { - return TranslationResult(success: false, error: "URL 构建失败") - } - - var request = URLRequest(url: url, timeoutInterval: 10) - request.httpMethod = "POST" - request.httpBody = bodyData - headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } - - let semaphore = DispatchSemaphore(value: 0) - var responseData: Data? - var responseError: Error? - var statusCode = 0 - - URLSession.shared.dataTask(with: request) { data, response, error in - responseData = data - responseError = error - statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - semaphore.signal() - }.resume() - semaphore.wait() - - if let error = responseError { - return TranslationResult(success: false, error: "请求异常: \(error.localizedDescription)") - } - guard (200..<300).contains(statusCode), let data = responseData else { - return TranslationResult(success: false, error: "HTTP 错误: \(statusCode)") - } - - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return TranslationResult(success: false, error: "响应解析失败") - } - - if let translation = extractTranslation(json) { - return TranslationResult(success: true, translatedText: translation, confidence: 0.9) - } - let err = extractError(json) ?? "翻译结果为空" - return TranslationResult(success: false, error: err) - } - - private func extractTranslation(_ json: [String: Any]) -> String? { - if let list = json["TranslationList"] as? [[String: Any]], - let first = list.first, - let t = first["Translation"] as? String { return t } - if let t = json["Translation"] as? String { return t } - if let result = json["Result"] as? [String: Any], - let t = result["Translation"] as? String { return t } - if let data = json["Data"] as? [String: Any] { - if let t = data["Translation"] as? String { return t } - if let list = data["TranslationList"] as? [[String: Any]], - let first = list.first, - let t = first["Translation"] as? String { return t } - } - return nil - } - - private func extractError(_ json: [String: Any]) -> String? { - if let meta = json["ResponseMetadata"] as? [String: Any], - let err = meta["Error"] as? [String: Any] { - return err["Message"] as? String ?? err["Code"] as? String - } - if let err = json["Error"] as? [String: Any] { - return err["Message"] as? String ?? err["Code"] as? String - } - return nil - } - - private func generateSignature(method: String, bodyString: String, - queryParams: [(String, String)]) throws -> [String: String] { - let now = Date() - let dateFmt = DateFormatter(); dateFmt.dateFormat = "yyyyMMdd"; dateFmt.timeZone = TimeZone(identifier: "UTC") - let tsFmt = DateFormatter(); tsFmt.dateFormat = "yyyyMMdd'T'HHmmss'Z'"; tsFmt.timeZone = TimeZone(identifier: "UTC") - let date = dateFmt.string(from: now) - let timestamp = tsFmt.string(from: now) - - let sortedParams = queryParams.sorted { $0.0 < $1.0 } - let canonicalQuery = sortedParams.map { "\(urlEncode($0.0))=\(urlEncode($0.1))" }.joined(separator: "&") - - let host = "translate.volcengineapi.com" - let payloadHash = sha256Hex(bodyString) - let canonicalHeaders = "host:\(host)\nx-date:\(timestamp)\n" - let signedHeaders = "host;x-date" - let canonicalRequest = "\(method)\n\(endpoint)\n\(canonicalQuery)\n\(canonicalHeaders)\n\(signedHeaders)\n\(payloadHash)" - - let credentialScope = "\(date)/\(region)/\(service)/request" - let stringToSign = "\(algorithm)\n\(timestamp)\n\(credentialScope)\n\(sha256Hex(canonicalRequest))" - - let kSecret = secretKey.data(using: .utf8)! - let kDate = hmacSha256(key: kSecret, data: date) - let kRegion = hmacSha256(key: kDate, data: region) - let kService = hmacSha256(key: kRegion, data: service) - let kSigning = hmacSha256(key: kService, data: "request") - let signature = hmacSha256(key: kSigning, data: stringToSign).map { String(format: "%02x", $0) }.joined() - - let authorization = "\(algorithm) Credential=\(accessKey)/\(credentialScope), SignedHeaders=\(signedHeaders), Signature=\(signature)" - return [ - "Content-Type": "application/json", - "X-Date": timestamp, - "Authorization": authorization, - "Host": host, - ] - } - - private func sha256Hex(_ input: String) -> String { - let data = Data(input.utf8) - var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - data.withUnsafeBytes { CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) } - return hash.map { String(format: "%02x", $0) }.joined() - } - - private func hmacSha256(key: Data, data: String) -> Data { - let dataBytes = Data(data.utf8) - var mac = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - key.withUnsafeBytes { keyPtr in - dataBytes.withUnsafeBytes { dataPtr in - CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA256), - keyPtr.baseAddress, key.count, - dataPtr.baseAddress, dataBytes.count, - &mac) - } - } - return Data(mac) - } - - private func urlEncode(_ s: String) -> String { - s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/TranslationManager.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/TranslationManager.swift deleted file mode 100644 index 72a84a45..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/TranslationManager.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -class TranslationManager: TranslationProtocol { - private var currentProvider: TranslationProvider - private var delegate: TranslationProtocol? - - init(provider: TranslationProvider = .azure) { - self.currentProvider = provider - createDelegate() - } - - func switchProvider(provider: TranslationProvider? = nil) { - if let p = provider { currentProvider = p } - createDelegate() - } - - private func createDelegate() { - switch currentProvider { - case .azure: - delegate = AzureTranslation() - case .volcengine: - delegate = VolcengineTranslation() - } - } - - func getProvider() -> TranslationProvider { currentProvider } - - func translateText(text: String, sourceLanguage: String, targetLanguage: String) -> TranslationResult { - return delegate?.translateText(text: text, sourceLanguage: sourceLanguage, targetLanguage: targetLanguage) - ?? TranslationResult(success: false, error: "TranslationManager not initialized") - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/TranslationProtocol.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/TranslationProtocol.swift deleted file mode 100644 index 128a16ae..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/TranslationProtocol.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -enum TranslationProvider { - case azure - case volcengine -} - -struct TranslationResult { - let success: Bool - let translatedText: String? - let error: String? - let confidence: Float - - init(success: Bool, translatedText: String? = nil, error: String? = nil, confidence: Float = 0) { - self.success = success - self.translatedText = translatedText - self.error = error - self.confidence = confidence - } -} - -protocol TranslationProtocol { - func translateText(text: String, sourceLanguage: String, targetLanguage: String) -> TranslationResult -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/VolcengineTranslation.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/VolcengineTranslation.swift deleted file mode 100644 index 7bf6f993..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation/VolcengineTranslation.swift +++ /dev/null @@ -1,184 +0,0 @@ -import Foundation -import CommonCrypto - -class VolcengineTranslation: TranslationProtocol { - private let baseURL = "https://translate.volcengineapi.com" - private let endpoint = "/" - private let service = "translate" - private let version = "2020-06-01" - private let action = "TranslateText" - private let algorithm = "HMAC-SHA256" - - private let accessKey = "AKLTZjJmODdkODQwN2RjNDcyMWI4NWM2ZWRmNDkxY2RiYzI" - private let secretKey = "T0Roa09EZGlNalZpTjJObE5HVmlZMkV3WVRkbVpUTTBORGMzTW1FNFlqYw==" - private let region = "cn-north-1" - private let maxRetry = 3 - - func translateText(text: String, sourceLanguage: String, targetLanguage: String) -> TranslationResult { - var lastError = "未知错误" - for attempt in 0.. TranslationResult { - let body: [String: Any] = [ - "SourceLanguage": sourceLanguage, - "TargetLanguage": targetLanguage, - "TextList": [text], - ] - guard let bodyData = try? JSONSerialization.data(withJSONObject: body), - let bodyString = String(data: bodyData, encoding: .utf8) else { - return TranslationResult(success: false, error: "请求序列化失败") - } - - let queryParams: [(String, String)] = [ - ("Action", action), - ("Region", region), - ("Service", service), - ("Version", version), - ] - - let headers: [String: String] - do { - headers = try generateSignature(method: "POST", bodyString: bodyString, queryParams: queryParams) - } catch { - return TranslationResult(success: false, error: "签名生成失败: \(error)") - } - - var components = URLComponents(string: baseURL)! - components.queryItems = queryParams.map { URLQueryItem(name: $0.0, value: $0.1) } - guard let url = components.url else { - return TranslationResult(success: false, error: "URL 构建失败") - } - - var request = URLRequest(url: url, timeoutInterval: 10) - request.httpMethod = "POST" - request.httpBody = bodyData - headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } - - let semaphore = DispatchSemaphore(value: 0) - var responseData: Data? - var responseError: Error? - var statusCode = 0 - - URLSession.shared.dataTask(with: request) { data, response, error in - responseData = data - responseError = error - statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - semaphore.signal() - }.resume() - semaphore.wait() - - if let error = responseError { - return TranslationResult(success: false, error: "请求异常: \(error.localizedDescription)") - } - guard (200..<300).contains(statusCode), let data = responseData else { - return TranslationResult(success: false, error: "HTTP 错误: \(statusCode)") - } - - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return TranslationResult(success: false, error: "响应解析失败") - } - - if let translation = extractTranslation(json) { - return TranslationResult(success: true, translatedText: translation, confidence: 0.9) - } - let err = extractError(json) ?? "翻译结果为空" - return TranslationResult(success: false, error: err) - } - - private func extractTranslation(_ json: [String: Any]) -> String? { - if let list = json["TranslationList"] as? [[String: Any]], - let first = list.first, - let t = first["Translation"] as? String { return t } - if let t = json["Translation"] as? String { return t } - if let result = json["Result"] as? [String: Any], - let t = result["Translation"] as? String { return t } - if let data = json["Data"] as? [String: Any] { - if let t = data["Translation"] as? String { return t } - if let list = data["TranslationList"] as? [[String: Any]], - let first = list.first, - let t = first["Translation"] as? String { return t } - } - return nil - } - - private func extractError(_ json: [String: Any]) -> String? { - if let meta = json["ResponseMetadata"] as? [String: Any], - let err = meta["Error"] as? [String: Any] { - return err["Message"] as? String ?? err["Code"] as? String - } - if let err = json["Error"] as? [String: Any] { - return err["Message"] as? String ?? err["Code"] as? String - } - return nil - } - - private func generateSignature(method: String, bodyString: String, - queryParams: [(String, String)]) throws -> [String: String] { - let now = Date() - let dateFmt = DateFormatter(); dateFmt.dateFormat = "yyyyMMdd"; dateFmt.timeZone = TimeZone(identifier: "UTC") - let tsFmt = DateFormatter(); tsFmt.dateFormat = "yyyyMMdd'T'HHmmss'Z'"; tsFmt.timeZone = TimeZone(identifier: "UTC") - let date = dateFmt.string(from: now) - let timestamp = tsFmt.string(from: now) - - let sortedParams = queryParams.sorted { $0.0 < $1.0 } - let canonicalQuery = sortedParams.map { "\(urlEncode($0.0))=\(urlEncode($0.1))" }.joined(separator: "&") - - let host = "translate.volcengineapi.com" - let payloadHash = sha256Hex(bodyString) - let canonicalHeaders = "host:\(host)\nx-date:\(timestamp)\n" - let signedHeaders = "host;x-date" - let canonicalRequest = "\(method)\n\(endpoint)\n\(canonicalQuery)\n\(canonicalHeaders)\n\(signedHeaders)\n\(payloadHash)" - - let credentialScope = "\(date)/\(region)/\(service)/request" - let stringToSign = "\(algorithm)\n\(timestamp)\n\(credentialScope)\n\(sha256Hex(canonicalRequest))" - - let kSecret = secretKey.data(using: .utf8)! - let kDate = hmacSha256(key: kSecret, data: date) - let kRegion = hmacSha256(key: kDate, data: region) - let kService = hmacSha256(key: kRegion, data: service) - let kSigning = hmacSha256(key: kService, data: "request") - let signature = hmacSha256(key: kSigning, data: stringToSign).map { String(format: "%02x", $0) }.joined() - - let authorization = "\(algorithm) Credential=\(accessKey)/\(credentialScope), SignedHeaders=\(signedHeaders), Signature=\(signature)" - return [ - "Content-Type": "application/json", - "X-Date": timestamp, - "Authorization": authorization, - "Host": host, - ] - } - - private func sha256Hex(_ input: String) -> String { - let data = Data(input.utf8) - var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - data.withUnsafeBytes { CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) } - return hash.map { String(format: "%02x", $0) }.joined() - } - - private func hmacSha256(key: Data, data: String) -> Data { - let dataBytes = Data(data.utf8) - var mac = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - key.withUnsafeBytes { keyPtr in - dataBytes.withUnsafeBytes { dataPtr in - CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA256), - keyPtr.baseAddress, key.count, - dataPtr.baseAddress, dataBytes.count, - &mac) - } - } - return Data(mac) - } - - private func urlEncode(_ s: String) -> String { - s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/BoundedQueue.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/BoundedQueue.swift deleted file mode 100644 index c2cd11d9..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/BoundedQueue.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -class BoundedQueue { - private var buffer: [T?] - private let capacity: Int - private let lock = NSLock() - private var head = 0 - private var tail = 0 - private var count = 0 - - init(capacity: Int) { - self.capacity = capacity - self.buffer = [T?](repeating: nil, count: capacity) - } - - func offer(_ item: T) { - lock.lock(); defer { lock.unlock() } - if count >= capacity { - // 丢弃最旧的元素 - head = (head + 1) % capacity - count -= 1 - } - buffer[tail] = item - tail = (tail + 1) % capacity - count += 1 - } - - func poll() -> T? { - lock.lock(); defer { lock.unlock() } - guard count > 0 else { return nil } - let item = buffer[head] - buffer[head] = nil - head = (head + 1) % capacity - count -= 1 - return item - } - - func clear() { - lock.lock(); defer { lock.unlock() } - buffer = [T?](repeating: nil, count: capacity) - head = 0 - tail = 0 - count = 0 - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/CallTranslationHelper.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/CallTranslationHelper.swift deleted file mode 100644 index c31f5026..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/CallTranslationHelper.swift +++ /dev/null @@ -1,258 +0,0 @@ -import Foundation -import AVFoundation -import bluetooth_manager - -class CallTranslationHelper: TranslationHelperProtocol { - private let asrTrTtsHelperSpk = VolcengineHelper() - private let asrTrTtsHelperMic = VolcengineHelper() - - var callback: ((Dictionary) -> Void)? - - // 音频发送队列(容量上限 4096,超限丢弃最旧) - private let spkQueue = BoundedQueue(capacity: 4096) - private let micQueue = BoundedQueue(capacity: 4096) - - private var previousCategory: AVAudioSession.Category? - private var previousMode: AVAudioSession.Mode? - private var previousOptions: AVAudioSession.CategoryOptions? - private var interruptionObserver: NSObjectProtocol? - - private var isSending = false - private var sendInterval: TimeInterval = 0.060 // 60ms - private var sendTimer: DispatchSourceTimer? - private let timerQueue = DispatchQueue(label: "call.sendtimer", qos: .userInteractive) - private var isRecognitionStopped = true - - /// 通话翻译立体声录音器(左=本端麦克风,右=对方/远端)。 - /// 两路均为 BluetoothManager 已 G722 解码的 16k/16bit 单声道 PCM。 - private var stereoRecorder: AstStereoRecorder? - - /// 静音填充数据:与 Android 一致的全零 PCM G722 编码帧 - private let silenceData = Data([ - 0x0A, 0x8E, 0x30, 0xBD, 0x3F, 0x83, 0xFF, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF - ]) - - init() { - // 注册蓝牙电话翻译回调 - BluetoothManager.translationCallback = { [weak self] data in - guard let self = self else { return } - switch data["type"] as? String { - case "startCall": self.startRecognition() - case "stopCall": self.stopRecognition() - case "spkData": if let d = data["data"] as? Data { self.spkData(d) } - case "micData": if let d = data["data"] as? Data { self.micData(d) } - case "speedAdjustment": if let d = data["data"] as? Data { self.handleSpeedAdjustment(d) } - default: break - } - } - - asrTrTtsHelperSpk.callback = { [weak self] data in - self?.publishData(data.merging(["side": "right"]) { $1 }) - } - asrTrTtsHelperMic.callback = { [weak self] data in - self?.publishData(data.merging(["side": "left"]) { $1 }) - } - asrTrTtsHelperSpk.onSynthesisEncoded = { [weak self] bytes in - self?.spkQueue.offer(bytes) - } - asrTrTtsHelperMic.onSynthesisEncoded = { [weak self] bytes in - self?.micQueue.offer(bytes) - } - } - - // MARK: - 公共接口 - - func start(sourceLanguage: String, targetLanguage: String) { - // 设置音频会话,确保后台保活 - let session = AVAudioSession.sharedInstance() - previousCategory = session.category - previousMode = session.mode - previousOptions = session.categoryOptions - do { - try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth]) - try session.setActive(true) - } catch {} - - // 注册音频中断处理 - interruptionObserver = NotificationCenter.default.addObserver( - forName: AVAudioSession.interruptionNotification, - object: nil, queue: nil - ) { notification in - guard let info = notification.userInfo, - let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, - let type = AVAudioSession.InterruptionType(rawValue: typeValue), - type == .ended else { return } - try? AVAudioSession.sharedInstance().setActive(true) - } - - asrTrTtsHelperSpk.switchProvider(languages: [targetLanguage, sourceLanguage]) - asrTrTtsHelperMic.switchProvider(languages: [sourceLanguage, targetLanguage]) - BluetoothManager.writeBytes(Data([0xaa, 0x51])) - } - - func stop() { - BluetoothManager.writeBytes(Data([0xaa, 0x52])) - - // 移除中断监听 - if let observer = interruptionObserver { - NotificationCenter.default.removeObserver(observer) - interruptionObserver = nil - } - - // 恢复音频会话 - if let cat = previousCategory, let mode = previousMode, let opts = previousOptions { - try? AVAudioSession.sharedInstance().setCategory(cat, mode: mode, options: opts) - } - } - - func dispose() { - stop() - stopRecognition() - asrTrTtsHelperSpk.dispose() - asrTrTtsHelperMic.dispose() - // 兜底关闭录音文件(dispose 时保存已写入内容) - _ = stereoRecorder?.stop(isSave: true) - stereoRecorder = nil - callback = nil - BluetoothManager.translationCallback = nil - stopSendTimer() - } - - // MARK: - 录音(立体声:左=本端麦克风,右=对方/远端) - - func enableRecord(filePath: String) { - // 防御:未 stopRecord 就重复 enableRecord 时先停旧的 - _ = stereoRecorder?.stop(isSave: false) - let recorder = AstStereoRecorder() - recorder.start(filePath: filePath) - stereoRecorder = recorder - } - - func stopRecord(isSave: Bool) { - _ = stereoRecorder?.stop(isSave: isSave) - stereoRecorder = nil - } - - // MARK: - 私有:识别控制 - - private func startRecognition() { - guard isRecognitionStopped else { return } - isRecognitionStopped = false - BluetoothManager.writeBytes(Data([0xaa, 0x57])) - asrTrTtsHelperSpk.startRecognition() - asrTrTtsHelperMic.startRecognition() - spkQueue.clear() - micQueue.clear() - isSending = true - startSendTimer() - publishData(["type": "startCall"]) - } - - private func stopRecognition() { - guard !isRecognitionStopped else { return } - isRecognitionStopped = true - BluetoothManager.writeBytes(Data([0xaa, 0x58])) - asrTrTtsHelperSpk.stopRecognition() - asrTrTtsHelperMic.stopRecognition() - isSending = false - stopSendTimer() - publishData(["type": "stopCall"]) - } - - private func handleSpeedAdjustment(_ data: Data) { - guard data.count > 6 else { return } - let newInterval: TimeInterval - switch data[6] { - case 0x01: newInterval = 0.055 - case 0x02: newInterval = 0.060 - case 0x03: newInterval = 0.065 - default: newInterval = 0.060 - } - if sendInterval != newInterval { - sendInterval = newInterval - if isSending { - stopSendTimer() - startSendTimer() - } - } - } - - // MARK: - 私有:音频数据路由 - - private func spkData(_ data: Data) { - asrTrTtsHelperSpk.pushStreamWrite(data) - // 录音点(右声道):data 为 BluetoothManager 已 G722 解码的对方/远端 16k/16bit 单声道 PCM - stereoRecorder?.enqueueRightChannel(data) - } - private func micData(_ data: Data) { - asrTrTtsHelperMic.pushStreamWrite(data) - // 录音点(左声道时钟):data 为 BluetoothManager 已 G722 解码的本端麦克风 16k/16bit 单声道 PCM - stereoRecorder?.writeLeftChannel(data) - } - - // MARK: - 私有:定时发送 - - private func startSendTimer() { - let timer = DispatchSource.makeTimerSource(queue: timerQueue) - let interval = sendInterval - timer.schedule(deadline: .now(), repeating: interval, leeway: .milliseconds(2)) - timer.setEventHandler { [weak self] in self?.sendAudioData() } - timer.resume() - sendTimer = timer - } - - private func stopSendTimer() { - sendTimer?.cancel() - sendTimer = nil - } - - private func sendAudioData() { - guard isSending else { return } - let count = 3 - let payloadSize = 80 * count // 每包 SPK(40) + MIC(40) - let totalSize = 4 + payloadSize - var packet = Data(repeating: 0, count: totalSize) - - // Header - packet[0] = count > 4 ? 0xDD : 0xAA - packet[1] = 0x56 - packet[2] = UInt8(clamping: totalSize) - packet[3] = 0x03 - - var hasData = false - for i in 0..) { - let cb = callback - DispatchQueue.main.async { cb?(data) } - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/FaceToFaceTranslationHelper.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/FaceToFaceTranslationHelper.swift deleted file mode 100644 index ddafdc4b..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/FaceToFaceTranslationHelper.swift +++ /dev/null @@ -1,248 +0,0 @@ -import Foundation -import AVFoundation -import bluetooth_manager - -class FaceToFaceTranslationHelper: TranslationHelperProtocol { - private var asrTrTtsHelperSpk: AsrTrTtsHelper? - private var asrTrTtsHelperMic: AsrTrTtsHelper? - private var microphoneRecorder: MicrophoneRecorder? - - /// 面对面翻译录音器(单声道)。录的是本端手机麦克风的 16k/16bit 单声道 PCM。 - private var stereoRecorder: AstStereoRecorder? - - var callback: ((Dictionary) -> Void)? - - private var isSending = false - private let micQueue = BoundedQueue(capacity: 4096) - - private var sendTimer: DispatchSourceTimer? - private let timerQueue = DispatchQueue(label: "f2f.sendtimer", qos: .userInteractive) - private var sendInterval: TimeInterval = 0.060 - private var isRecognitionStopped = true - private var previousCategory: AVAudioSession.Category? - private var previousMode: AVAudioSession.Mode? - private var previousOptions: AVAudioSession.CategoryOptions? - private var interruptionObserver: NSObjectProtocol? - - private let silenceData = Data([ - 0x0A, 0x8E, 0x30, 0xBD, 0x3F, 0x83, 0xFF, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF - ]) - - init() { - asrTrTtsHelperSpk = AsrTrTtsHelper() - asrTrTtsHelperMic = AsrTrTtsHelper(autoPlay: true, forceBuiltInSpeaker: true) - - // 注册蓝牙面对面翻译回调 - BluetoothManager.translationCallback = { [weak self] data in - guard let self = self else { return } - switch data["type"] as? String { - case "startFaceToFace": self.startRecognition() - case "stopFaceToFace": self.stopRecognition() - case "spkData": if let d = data["data"] as? Data { self.micData(d) } - case "speedAdjustment": if let d = data["data"] as? Data { self.handleSpeedAdjustment(d) } - default: break - } - } - - microphoneRecorder = MicrophoneRecorder { [weak self] data in - self?.spkData(data) - } - - asrTrTtsHelperSpk?.callback = { [weak self] data in - var newData = data - newData["side"] = "right" - self?.publishData(newData) - } - asrTrTtsHelperMic?.callback = { [weak self] data in - var newData = data - newData["side"] = "left" - self?.publishData(newData) - } - asrTrTtsHelperSpk?.onSynthesisEncoded = { [weak self] bytes in - self?.micQueue.offer(bytes) - } - } - - func start(sourceLanguage: String, targetLanguage: String) { - // 设置通信模式,让平台级AEC生效 - let session = AVAudioSession.sharedInstance() - previousCategory = session.category - previousMode = session.mode - previousOptions = session.categoryOptions - - do { - try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) - try session.setActive(true) - } catch {} - - // 注册音频中断处理,中断结束后重新激活音频会话 - interruptionObserver = NotificationCenter.default.addObserver( - forName: AVAudioSession.interruptionNotification, - object: nil, queue: nil - ) { notification in - guard let info = notification.userInfo, - let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, - let type = AVAudioSession.InterruptionType(rawValue: typeValue), - type == .ended else { return } - try? AVAudioSession.sharedInstance().setActive(true) - } - - asrTrTtsHelperSpk?.switchProvider(languages: [targetLanguage, sourceLanguage]) - asrTrTtsHelperMic?.switchProvider(languages: [sourceLanguage, targetLanguage]) - BluetoothManager.writeBytes(Data([0xaa, 0x66])) - // 在麦克风 VPIO 启用前预热手机扬声器 TTS 播放引擎(让其尽早跑起来、整段复用), - // 消除冷启动首句偏小、停止重启后才变大的问题。 - asrTrTtsHelperMic?.prepareForPlayback() - microphoneRecorder?.start() - } - - func stop() { - BluetoothManager.writeBytes(Data([0xaa, 0x67])) - microphoneRecorder?.stop() - - // 移除中断监听 - if let observer = interruptionObserver { - NotificationCenter.default.removeObserver(observer) - interruptionObserver = nil - } - - // 恢复音频设置 - if let cat = previousCategory, let mode = previousMode, let opts = previousOptions { - try? AVAudioSession.sharedInstance().setCategory(cat, mode: mode, options: opts) - } - } - - private func startRecognition() { - guard isRecognitionStopped else { return } - isRecognitionStopped = false - asrTrTtsHelperSpk?.startRecognition() - asrTrTtsHelperMic?.startRecognition() - micQueue.clear() - isSending = true - - let timer = DispatchSource.makeTimerSource(queue: timerQueue) - timer.schedule(deadline: .now(), repeating: sendInterval, leeway: .milliseconds(2)) - timer.setEventHandler { [weak self] in self?.sendAudioData() } - timer.resume() - sendTimer = timer - - publishData(["type": "startCall"]) - } - - private func stopRecognition() { - guard !isRecognitionStopped else { return } - isRecognitionStopped = true - asrTrTtsHelperSpk?.stopRecognition() - asrTrTtsHelperMic?.stopRecognition() - isSending = false - sendTimer?.cancel() - sendTimer = nil - publishData(["type": "stopCall"]) - } - - private func sendAudioData() { - guard isSending else { return } - let count = 3 - let payloadSize = 40 * count // 每包 MIC(40) - let totalSize = 4 + payloadSize - var packet = Data(repeating: 0, count: totalSize) - - // Header - packet[0] = 0xAA - packet[1] = 0x69 - packet[2] = UInt8(clamping: totalSize) - packet[3] = 0x01 - - var hasData = false - for i in 0.. 6 else { return } - let newInterval: TimeInterval - switch data[6] { - case 0x01: newInterval = 0.055 - case 0x02: newInterval = 0.060 - case 0x03: newInterval = 0.065 - default: newInterval = 0.060 - } - - guard sendInterval != newInterval else { return } - sendInterval = newInterval - if isSending { - sendTimer?.cancel() - let timer = DispatchSource.makeTimerSource(queue: timerQueue) - timer.schedule(deadline: .now(), repeating: sendInterval, leeway: .milliseconds(2)) - timer.setEventHandler { [weak self] in self?.sendAudioData() } - timer.resume() - sendTimer = timer - } - } - - private func spkData(_ data: Data) { - asrTrTtsHelperSpk?.pushStreamWrite(data) - // 录音点(左声道时钟):data 来自 MicrophoneRecorder(PCMReader), - // 是本端手机麦克风的 16k/16bit 单声道 PCM - stereoRecorder?.writeLeftChannel(data) - } - - private func micData(_ data: Data) { - asrTrTtsHelperMic?.pushStreamWrite(data) - // 录音点(右声道):data 为 BluetoothManager 已解码的对方(BLE 设备) - // 16k/16bit 单声道 PCM - stereoRecorder?.enqueueRightChannel(data) - } - - private func publishData(_ data: Dictionary) { - let cb = callback - DispatchQueue.main.async { cb?(data) } - } - - // MARK: - 录音 - - func enableRecord(filePath: String) { - // 防御:未 stopRecord 就重复 enableRecord 时先停旧的 - _ = stereoRecorder?.stop(isSave: false) - // 立体声录音:左=本端手机麦克风,右=对方(BLE 设备) - let recorder = AstStereoRecorder() - recorder.start(filePath: filePath) - stereoRecorder = recorder - } - - func stopRecord(isSave: Bool) { - _ = stereoRecorder?.stop(isSave: isSave) - stereoRecorder = nil - } - - func dispose() { - stop() - stopRecognition() - microphoneRecorder?.dispose() - microphoneRecorder = nil - asrTrTtsHelperSpk?.dispose() - asrTrTtsHelperMic?.dispose() - // 兜底关闭录音文件(dispose 时保存已写入内容) - _ = stereoRecorder?.stop(isSave: true) - stereoRecorder = nil - callback = nil - BluetoothManager.translationCallback = nil - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/MicrophoneRecorder.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/MicrophoneRecorder.swift deleted file mode 100644 index 03252673..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/MicrophoneRecorder.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -import audio_recorder - -class MicrophoneRecorder { - private var pcmReader: PCMReader? - private var isRecording = false - private let sampleRate: Int - private let onAudioData: (Data) -> Void - - /// 是否启用回声消除,每次 start() 时生效 - var echoCancel: Bool = true - - init(sampleRate: Int = 16000, onAudioData: @escaping (Data) -> Void) { - self.sampleRate = sampleRate - self.onAudioData = onAudioData - } - - func start() { - guard !isRecording else { return } - - let reader = PCMReader( - sampleRate: sampleRate, - numChannels: 1, - echoCancel: echoCancel, - autoGain: echoCancel, - bufferSize: 1024, - onData: onAudioData - ) - - do { - try reader.start() - pcmReader = reader - isRecording = true - } catch { - print("[MicrophoneRecorder] start failed: \(error)") - } - } - - func stop() { - guard isRecording else { return } - isRecording = false - pcmReader?.stop() - pcmReader?.release() - pcmReader = nil - } - - func dispose() { - stop() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/SimultaneousTranslationHelper.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/SimultaneousTranslationHelper.swift deleted file mode 100644 index 588528c9..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/SimultaneousTranslationHelper.swift +++ /dev/null @@ -1,205 +0,0 @@ -import Foundation -import AVFoundation -import bluetooth_manager - -class SimultaneousTranslationHelper: NSObject, TranslationHelperProtocol { - private var asrTrTtsHelper: AsrTrTtsHelper? - private var microphoneRecorder: MicrophoneRecorder? - - /// 同声传译录音器(单声道)。录的就是喂给 ASR 的解码后 16k/16bit 单声道 PCM。 - private var recordFile: RecordFile? - - var callback: ((Dictionary) -> Void)? - - private var isRecognitionStopped = true - private var sourceLanguageCode: String = "" - private var targetLanguageCode: String = "" - private var previousCategory: AVAudioSession.Category? - private var previousMode: AVAudioSession.Mode? - private var previousOptions: AVAudioSession.CategoryOptions? - private var interruptionObserver: NSObjectProtocol? - /// 本次会话是否走蓝牙 BLE 指令路径(连上耳机时启用,从耳机主麦经 mSBC 拉 PCM)。 - /// 由 start/stop(主线程)写入、translationCallback(BluetoothManager 处理队列)读取, - /// 用 NSLock 串行化避免数据竞争。 - private let stateLock = NSLock() - private var _usingBtPath = false - private var usingBtPath: Bool { - get { - stateLock.lock() - defer { stateLock.unlock() } - return _usingBtPath - } - set { - stateLock.lock() - defer { stateLock.unlock() } - _usingBtPath = newValue - } - } - - override init() { - super.init() - asrTrTtsHelper = AsrTrTtsHelper(autoPlay: true, autoDetectLanguage: true) - microphoneRecorder = MicrophoneRecorder { [weak self] data in - self?.asrTrTtsHelper?.pushStreamWrite(data) - // 录音点(非 BT 路径):data 为 PCMReader 输出的 16k/16bit 单声道 PCM - self?.recordFile?.saveAudioDataToWav(data) - } - asrTrTtsHelper?.callback = { [weak self] data in - guard let self = self else { return } - let language = data["language"] as? String ?? "" - let side = language.caseInsensitiveCompare(self.sourceLanguageCode) == .orderedSame ? "left" : "right" - var newData = data - newData["side"] = side - self.publishData(newData) - } - // BT 路径事件分发:0xD9/0xDA 触发 ASR 生命周期,mainMicData 喂 ASR(ffMicData 是辅助麦,丢弃) - BluetoothManager.translationCallback = { [weak self] data in - guard let self = self, self.usingBtPath else { return } - switch data["type"] as? String { - case "startMicRecording": - self.startRecognition() - case "stopMicRecording": - self.stopRecognition() - case "mainMicData": - if let pcm = data["data"] as? Data { - self.asrTrTtsHelper?.pushStreamWrite(pcm) - // 录音点(BT 路径):pcm 为 BluetoothManager 已 mSBC 解码的 16k/16bit 单声道 PCM - self.recordFile?.saveAudioDataToWav(pcm) - } - default: - break - } - } - } - - func start(sourceLanguage: String, targetLanguage: String) { - sourceLanguageCode = sourceLanguage - targetLanguageCode = targetLanguage - usingBtPath = BluetoothManager.sharedInstance?.isConnected == true - - let session = AVAudioSession.sharedInstance() - previousCategory = session.category - previousMode = session.mode - previousOptions = session.categoryOptions - - do { - if usingBtPath { - // BT 路径:录音走 BLE 指令通道,iOS 端只播 TTS → .playback + A2DP 走耳机高音质 - try session.setCategory(.playback, mode: .default, options: [.allowBluetoothA2DP]) - } else { - // .allowBluetooth: 有蓝牙耳机时 iOS 自动走 HFP(麦克风+扬声器都走蓝牙) - // .defaultToSpeaker: 没有蓝牙耳机时回退到手机扬声器(不走听筒) - // iOS 会自动处理蓝牙连接/断开的路由切换 - try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .defaultToSpeaker]) - } - try session.setActive(true) - } catch {} - - // 注册音频中断处理,中断结束后重新激活音频会话 - interruptionObserver = NotificationCenter.default.addObserver( - forName: AVAudioSession.interruptionNotification, - object: nil, queue: nil - ) { notification in - guard let info = notification.userInfo, - let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, - let type = AVAudioSession.InterruptionType(rawValue: typeValue), - type == .ended else { return } - try? AVAudioSession.sharedInstance().setActive(true) - } - - asrTrTtsHelper?.switchProvider(languages: [targetLanguage, sourceLanguage]) - - if usingBtPath { - // 复用会议助手「现场录音」指令:通知耳机启动主麦 mSBC 数据流 - // ASR 开启 + startCall 等设备回 0xD9 后在 startRecognition 里发起 - BluetoothManager.writeBytes(Data([0xaa, 0x59])) - } else { - // 在麦克风 VPIO 启用前预热 TTS 播放引擎(让其尽早跑起来、整段复用), - // 消除冷启动首句偏小、停止重启后才变大的问题。 - asrTrTtsHelper?.prepareForPlayback() - // MicrophoneRecorder.echoCancel 默认开启(VoiceProcessing 防 TTS 回灌), - // PCMReader 内部通过 AVAudioEngineConfigurationChange 处理蓝牙切换 - microphoneRecorder?.start() - startRecognition() - } - } - - func stop() { - // 缓存一次:避免在 stop() 执行过程中被 translationCallback 的 stopRecognition 改写后 - // 出现「前半段当 BT 路径处理、后半段当非 BT 处理」的不一致 - let bt = usingBtPath - if bt { - // ASR 关闭 + stopCall 等设备回 0xDA 后在 stopRecognition 里发起 - BluetoothManager.writeBytes(Data([0xaa, 0x5a])) - } else { - microphoneRecorder?.stop() - } - - // 音频会话同步回滚(与 FaceToFaceTranslationHelper 一致) - if let observer = interruptionObserver { - NotificationCenter.default.removeObserver(observer) - interruptionObserver = nil - } - if let cat = previousCategory, let mode = previousMode, let opts = previousOptions { - try? AVAudioSession.sharedInstance().setCategory(cat, mode: mode, options: opts) - } - - // 非 BT 路径在所有同步清理结束后再触发 stopRecognition + publishStopCall, - // 让 UI 看到 stopCall 时资源已全部释放,与 BT 路径下 0xDA 触发的时机一致 - if !bt { - stopRecognition() - } - } - - private func startRecognition() { - guard isRecognitionStopped else { return } - isRecognitionStopped = false - asrTrTtsHelper?.startRecognition() - publishData(["type": "startCall"]) - } - - private func stopRecognition() { - guard !isRecognitionStopped else { return } - isRecognitionStopped = true - asrTrTtsHelper?.stopRecognition() - publishData(["type": "stopCall"]) - usingBtPath = false - } - - private func publishData(_ data: Dictionary) { - let cb = callback - DispatchQueue.main.async { cb?(data) } - } - - // MARK: - 录音 - - func enableRecord(filePath: String) { - // 防御:未 stopRecord 就重复 enableRecord 时先关旧的,避免泄漏挂起写入与文件句柄 - _ = recordFile?.closeFile(isSave: false) - // 单声道录音:16k/16bit/1ch - let file = RecordFile() - file.setAudioConfig(sampleRate: 16000, channels: 1) - file.creatingFiles(atPath: filePath) - recordFile = file - } - - func stopRecord(isSave: Bool) { - _ = recordFile?.closeFile(isSave: isSave) - recordFile = nil - } - - func dispose() { - stop() - // 若 BT 路径下设备没回 0xDA,stopRecognition 还没跑过;这里兜底强制收尾 - stopRecognition() - microphoneRecorder?.dispose() - microphoneRecorder = nil - asrTrTtsHelper?.dispose() - asrTrTtsHelper = nil - // 兜底关闭录音文件(dispose 时保存已写入内容) - _ = recordFile?.closeFile(isSave: true) - recordFile = nil - callback = nil - BluetoothManager.translationCallback = nil - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/TranslationHelperManager.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/TranslationHelperManager.swift deleted file mode 100644 index aa095d3c..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/TranslationHelperManager.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -class TranslationHelperManager: TranslationHelperProtocol { - private var currentProvider: TranslationHelperProvider - private var delegate: TranslationHelperProtocol? - - var callback: ((Dictionary) -> Void)? { - didSet { delegate?.callback = callback } - } - - init(provider: TranslationHelperProvider = .call) { - self.currentProvider = provider - switchProvider(provider: provider) - } - - func switchProvider(provider: TranslationHelperProvider) { - try? delegate?.dispose() - currentProvider = provider - - switch provider { - case .simultaneous: - delegate = SimultaneousTranslationHelper() - case .call: - delegate = CallTranslationHelper() - case .faceToFace: - delegate = FaceToFaceTranslationHelper() - } - delegate?.callback = callback - } - - func getProvider() -> TranslationHelperProvider { currentProvider } - - func start(sourceLanguage: String, targetLanguage: String) { - delegate?.start(sourceLanguage: sourceLanguage, targetLanguage: targetLanguage) - } - - func stop() { - delegate?.stop() - } - - func enableRecord(filePath: String) { - delegate?.enableRecord(filePath: filePath) - } - - func stopRecord(isSave: Bool) { - delegate?.stopRecord(isSave: isSave) - } - - func dispose() { - callback = nil - let d = delegate - delegate = nil - d?.dispose() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/TranslationHelperProtocol.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/TranslationHelperProtocol.swift deleted file mode 100644 index 911ccad4..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/translation_option/TranslationHelperProtocol.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -enum TranslationHelperProvider { - case simultaneous - case call - case faceToFace -} - -protocol TranslationHelperProtocol: AnyObject { - var callback: ((Dictionary) -> Void)? { get set } - - func start(sourceLanguage: String, targetLanguage: String) - func stop() - func dispose() - - /// 开启录音:将 ASR 正在处理的解码后 PCM 写入 WAV 文件。 - /// 同声传译/面对面为单声道;通话翻译为立体声(左=本端麦克风,右=对方)。 - func enableRecord(filePath: String) - /// 停止录音。isSave=false 时丢弃文件。 - func stopRecord(isSave: Bool) -} - -extension TranslationHelperProtocol { - func start() { - start(sourceLanguage: "zh-CN", targetLanguage: "en-US") - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/AzureTts.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/AzureTts.swift deleted file mode 100644 index 70814990..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/AzureTts.swift +++ /dev/null @@ -1,250 +0,0 @@ -import Foundation -import AVFoundation -import MicrosoftCognitiveServicesSpeech -import bluetooth_manager - -class AzureTts: TtsProtocol { - var onSynthesisEncoded: ((Data) -> Void)? - var autoPlay: Bool = false - var forceBuiltInSpeaker: Bool = false - - private var synthesizer: SPXSpeechSynthesizer? - private var g722Codec: G722Codec? - - private let subscriptionKey = "rLPdyGgD1v7z7khzpdrd4YNWUinjw6GzAbA1pPXBRmUjitfjDxdsJQQJ99BGAC3pKaRXJ3w3AAAYACOGAHZZ" - private let region = "eastasia" - - /// 每帧 PCM 字节数:16kHz × 20ms × 2 bytes = 640 bytes(与 Android 一致) - private let framePcmBytes = 640 - - /// 流式缓冲区(与 Android MemoryPushAudioOutputStreamCallback 对齐) - private var stashBuffer = Data(capacity: 8192) - private var stashSize: Int = 0 - - /// 自动播放用的 AVAudioEngine(整个会话期间复用,避免每句重建引擎触发崩溃) - private var audioEngine: AVAudioEngine? - private var playerNode: AVAudioPlayerNode? - /// 引擎图使用标准 Float32 单声道格式(mixer 的规范处理格式),比 Int16 交错格式更兼容,降低 connect 抛异常概率 - private let playbackFormat = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1)! - /// 保护 audioEngine / playerNode 的并发访问(合成回调线程与工作队列可能并发) - private let engineLock = NSLock() - /// 建引擎时的输出硬件格式,用于检测音频路由变化后重建引擎(适配新路由) - private var builtOutputFormat: AVAudioFormat? - - init() { - setup() - } - - private func setup() { - do { - let speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - try speechConfig.setSpeechSynthesisOutputFormat(.raw16Khz16BitMonoPcm) - synthesizer = try SPXSpeechSynthesizer(speechConfiguration: speechConfig, audioConfiguration: nil) - g722Codec = G722Codec(sampleRate: 16000) - - synthesizer?.addSynthesizingEventHandler { [weak self] (_: SPXSpeechSynthesizer, event: SPXSpeechSynthesisEventArgs) in - guard let self = self, let data = event.result.audioData, !data.isEmpty else { return } - self.processStreamingAudio(data) - } - } catch {} - } - - func speakText(text: String, targetLanguage: String, voiceName: String) { - speakSsml(buildSsml(text: text, targetLanguage: targetLanguage, voiceName: voiceName)) - } - - /// 在麦克风 VPIO 启用之前预先构建并启动自动播放引擎(长生命周期,整段会话复用)。 - /// 冷启动若等到第一句才懒构建,扬声器 TTS 会偏小,停止→重启后才变大; - /// 会话起始就把引擎跑起来可消除该差异(真机验证),让第一句即保持正常音量。 - /// 注:响度与 VPIO 把会话切到 .voiceChat(通话音量域,比 .default 媒体音量域更响)有关, - /// 这里只负责让引擎尽早就绪,不改音频路由。非自动播放时为空操作。 - func prepareForPlayback() { - guard autoPlay else { return } - if forceBuiltInSpeaker { - try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) - } - _ = ensurePlaybackEngineReady() - } - - func speakSsml(_ ssml: String) { - guard let synthesizer = synthesizer else { return } - stashSize = 0 - - if autoPlay { - if forceBuiltInSpeaker { - try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) - } - // 准备(必要时创建)可复用的播放引擎;失败时本句不播放,但不会崩溃 - _ = ensurePlaybackEngineReady() - } - - // 阻塞直到合成完成 - try? synthesizer.speakSsml(ssml) - - if autoPlay { - drainPlayback() - } - } - - /// 准备可复用的播放引擎,返回是否就绪。 - /// 关键:连接前确保音频会话已激活且输出节点硬件格式有效, - /// 否则 AVAudioEngine.connect 会抛出未捕获的 ObjC 异常(SetFormat 失败)导致 SIGABRT 崩溃。 - private func ensurePlaybackEngineReady() -> Bool { - engineLock.lock() - defer { engineLock.unlock() } - - // 确保会话已激活(面对面模式下通常已由外层激活,这里再确认一次) - try? AVAudioSession.sharedInstance().setActive(true) - - // 复用已有引擎:仅当仍在运行且音频路由(输出硬件格式)未变化时 - if let engine = audioEngine, let player = playerNode, let built = builtOutputFormat { - let live = engine.outputNode.outputFormat(forBus: 0) - let routeUnchanged = live.sampleRate == built.sampleRate && live.channelCount == built.channelCount - // 路由(输出格式)未变时优先“原地重启”同一引擎:若系统在 VPIO 启停时停掉了播放引擎, - // 重启同一实例即可,避免走下面的销毁+重建路径(历史上重建易触发 SIGABRT)。 - if routeUnchanged { - if !engine.isRunning { try? engine.start() } - if engine.isRunning { - if !player.isPlaying { player.play() } - return true - } - } - // 引擎被系统停止或音频路由已变化:销毁后按当前路由重建 - player.stop() - engine.stop() - audioEngine = nil - playerNode = nil - builtOutputFormat = nil - } - - // 重建引擎 - let engine = AVAudioEngine() - // 校验输出硬件格式有效(采样率/声道数 > 0),无效说明会话未就绪,放弃本次播放以避免 connect 崩溃 - let hwFormat = engine.outputNode.outputFormat(forBus: 0) - guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else { return false } - - let player = AVAudioPlayerNode() - engine.attach(player) - engine.connect(player, to: engine.mainMixerNode, format: playbackFormat) - do { - try engine.start() - player.play() - audioEngine = engine - playerNode = player - builtOutputFormat = hwFormat - return true - } catch { - audioEngine = nil - playerNode = nil - builtOutputFormat = nil - return false - } - } - - /// 等待当前已排入的音频播放完毕(带超时保护),但保留引擎以便复用 - private func drainPlayback() { - engineLock.lock() - guard let engine = audioEngine, engine.isRunning, let player = playerNode, - let silence = AVAudioPCMBuffer(pcmFormat: playbackFormat, frameCapacity: 1) else { - engineLock.unlock() - return - } - silence.frameLength = 1 - silence.floatChannelData?[0][0] = 0 - let semaphore = DispatchSemaphore(value: 0) - player.scheduleBuffer(silence) { semaphore.signal() } - engineLock.unlock() - _ = semaphore.wait(timeout: .now() + 30) - } - - /// 处理流式 PCM 数据 - private func processStreamingAudio(_ data: Data) { - if autoPlay { - engineLock.lock() - defer { engineLock.unlock() } - guard let engine = audioEngine, engine.isRunning, - let player = playerNode, let buffer = pcmBuffer(from: data) else { return } - player.scheduleBuffer(buffer) - return - } - - guard let codec = g722Codec else { return } - - // 扩容并追加数据 - let required = stashSize + data.count - if required > stashBuffer.count { - var newCap = max(stashBuffer.count, 8192) - while newCap < required { newCap <<= 1 } - var newBuf = Data(capacity: newCap) - newBuf.append(stashBuffer.prefix(stashSize)) - newBuf.append(data) - stashBuffer = newBuf - } else { - if stashSize < stashBuffer.count { - let end = min(stashSize + data.count, stashBuffer.count) - stashBuffer.replaceSubrange(stashSize..= framePcmBytes { - let frame = Data(stashBuffer[cursor.. 0 { - let remaining = stashSize - cursor - if remaining > 0 { - let leftover = Data(stashBuffer[cursor.. AVAudioPCMBuffer? { - // Azure 输出为小端 Int16 单声道(每帧 2 字节),转换为引擎所用的 Float32 - let frameCount = AVAudioFrameCount(data.count / 2) - guard frameCount > 0, - let buffer = AVAudioPCMBuffer(pcmFormat: playbackFormat, frameCapacity: frameCount), - let dst = buffer.floatChannelData else { return nil } - buffer.frameLength = frameCount - data.withUnsafeBytes { raw in - guard let src = raw.bindMemory(to: Int16.self).baseAddress else { return } - for i in 0.. String { - return """ - - - - \(text) - - - - """ - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/TtsManager.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/TtsManager.swift deleted file mode 100644 index 61d393fb..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/TtsManager.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -class TtsManager: TtsProtocol { - private var currentProvider: TtsProvider - private var delegate: TtsProtocol? - - var onSynthesisEncoded: ((Data) -> Void)? { - didSet { delegate?.onSynthesisEncoded = onSynthesisEncoded } - } - - var autoPlay: Bool = false { - didSet { delegate?.autoPlay = autoPlay } - } - - var forceBuiltInSpeaker: Bool = false { - didSet { delegate?.forceBuiltInSpeaker = forceBuiltInSpeaker } - } - - init(provider: TtsProvider = .azure, autoPlay: Bool = false, forceBuiltInSpeaker: Bool = false) { - self.currentProvider = provider - self.autoPlay = autoPlay - self.forceBuiltInSpeaker = forceBuiltInSpeaker - createDelegate() - } - - func switchProvider(provider: TtsProvider? = nil) { - if let p = provider { currentProvider = p } - delegate?.dispose() - createDelegate() - } - - private func createDelegate() { - switch currentProvider { - case .azure: - delegate = AzureTts() - case .volcengine: - delegate = VolcengineTts() - } - delegate?.onSynthesisEncoded = onSynthesisEncoded - delegate?.autoPlay = autoPlay - delegate?.forceBuiltInSpeaker = forceBuiltInSpeaker - } - - func getProvider() -> TtsProvider { currentProvider } - - func speakText(text: String, targetLanguage: String, voiceName: String) { - delegate?.speakText(text: text, targetLanguage: targetLanguage, voiceName: voiceName) - } - - func prepareForPlayback() { - delegate?.prepareForPlayback() - } - - func dispose() { - onSynthesisEncoded = nil - let d = delegate - delegate = nil - d?.dispose() - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/TtsProtocol.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/TtsProtocol.swift deleted file mode 100644 index 5a4a0502..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/TtsProtocol.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -enum TtsProvider { - case azure - case volcengine -} - -protocol TtsProtocol: AnyObject { - var onSynthesisEncoded: ((Data) -> Void)? { get set } - var autoPlay: Bool { get set } - var forceBuiltInSpeaker: Bool { get set } - - func speakText(text: String, targetLanguage: String, voiceName: String) - /// 预热自动播放引擎:在麦克风 VPIO 启用之前、会话刚激活时调用, - /// 让长生命周期的播放引擎尽早跑起来并整段复用,消除冷启动首句偏小、停止重启后才变大的问题。 - /// 非自动播放时为空操作。 - func prepareForPlayback() - func dispose() -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/VolcengineTts.swift b/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/VolcengineTts.swift deleted file mode 100644 index ee6473b8..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/ios/asr_tr_tts_manager/Sources/asr_tr_tts_manager/tts/VolcengineTts.swift +++ /dev/null @@ -1,249 +0,0 @@ -import Foundation -import AVFoundation -import MicrosoftCognitiveServicesSpeech -import bluetooth_manager - -class VolcengineTts: TtsProtocol { - var onSynthesisEncoded: ((Data) -> Void)? - var autoPlay: Bool = false - var forceBuiltInSpeaker: Bool = false - - private var synthesizer: SPXSpeechSynthesizer? - private var g722Codec: G722Codec? - - private let subscriptionKey = "rLPdyGgD1v7z7khzpdrd4YNWUinjw6GzAbA1pPXBRmUjitfjDxdsJQQJ99BGAC3pKaRXJ3w3AAAYACOGAHZZ" - private let region = "eastasia" - - /// 每帧 PCM 字节数:16kHz × 20ms × 2 bytes = 640 bytes(与 Android 一致) - private let framePcmBytes = 640 - - /// 流式缓冲区(与 Android MemoryPushAudioOutputStreamCallback 对齐) - private var stashBuffer = Data(capacity: 8192) - private var stashSize: Int = 0 - - /// 自动播放用的 AVAudioEngine(整个会话期间复用,避免每句重建引擎触发崩溃) - private var audioEngine: AVAudioEngine? - private var playerNode: AVAudioPlayerNode? - /// 引擎图使用标准 Float32 单声道格式(mixer 的规范处理格式),比 Int16 交错格式更兼容,降低 connect 抛异常概率 - private let playbackFormat = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1)! - /// 保护 audioEngine / playerNode 的并发访问(合成回调线程与工作队列可能并发) - private let engineLock = NSLock() - /// 建引擎时的输出硬件格式,用于检测音频路由变化后重建引擎(适配新路由) - private var builtOutputFormat: AVAudioFormat? - - init() { - setup() - } - - private func setup() { - do { - let speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - try speechConfig.setSpeechSynthesisOutputFormat(.raw16Khz16BitMonoPcm) - synthesizer = try SPXSpeechSynthesizer(speechConfiguration: speechConfig, audioConfiguration: nil) - g722Codec = G722Codec(sampleRate: 16000) - - synthesizer?.addSynthesizingEventHandler { [weak self] (_: SPXSpeechSynthesizer, event: SPXSpeechSynthesisEventArgs) in - guard let self = self, let data = event.result.audioData, !data.isEmpty else { return } - self.processStreamingAudio(data) - } - } catch {} - } - - func speakText(text: String, targetLanguage: String, voiceName: String) { - speakSsml(buildSsml(text: text, targetLanguage: targetLanguage, voiceName: voiceName)) - } - - /// 在麦克风 VPIO 启用之前预先构建并启动自动播放引擎(长生命周期,整段会话复用)。 - /// 冷启动若等到第一句才懒构建,扬声器 TTS 会偏小,停止→重启后才变大; - /// 会话起始就把引擎跑起来可消除该差异(真机验证),让第一句即保持正常音量。 - /// 注:响度与 VPIO 把会话切到 .voiceChat(通话音量域,比 .default 媒体音量域更响)有关, - /// 这里只负责让引擎尽早就绪,不改音频路由。非自动播放时为空操作。 - func prepareForPlayback() { - guard autoPlay else { return } - if forceBuiltInSpeaker { - try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) - } - _ = ensurePlaybackEngineReady() - } - - func speakSsml(_ ssml: String) { - guard let synthesizer = synthesizer else { return } - stashSize = 0 - - if autoPlay { - if forceBuiltInSpeaker { - try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) - } - // 准备(必要时创建)可复用的播放引擎;失败时本句不播放,但不会崩溃 - _ = ensurePlaybackEngineReady() - } - - try? synthesizer.speakSsml(ssml) - - if autoPlay { - drainPlayback() - } - } - - /// 准备可复用的播放引擎,返回是否就绪。 - /// 关键:连接前确保音频会话已激活且输出节点硬件格式有效, - /// 否则 AVAudioEngine.connect 会抛出未捕获的 ObjC 异常(SetFormat 失败)导致 SIGABRT 崩溃。 - private func ensurePlaybackEngineReady() -> Bool { - engineLock.lock() - defer { engineLock.unlock() } - - // 确保会话已激活(面对面模式下通常已由外层激活,这里再确认一次) - try? AVAudioSession.sharedInstance().setActive(true) - - // 复用已有引擎:仅当仍在运行且音频路由(输出硬件格式)未变化时 - if let engine = audioEngine, let player = playerNode, let built = builtOutputFormat { - let live = engine.outputNode.outputFormat(forBus: 0) - let routeUnchanged = live.sampleRate == built.sampleRate && live.channelCount == built.channelCount - // 路由(输出格式)未变时优先“原地重启”同一引擎:若系统在 VPIO 启停时停掉了播放引擎, - // 重启同一实例即可,避免走下面的销毁+重建路径(历史上重建易触发 SIGABRT)。 - if routeUnchanged { - if !engine.isRunning { try? engine.start() } - if engine.isRunning { - if !player.isPlaying { player.play() } - return true - } - } - // 引擎被系统停止或音频路由已变化:销毁后按当前路由重建 - player.stop() - engine.stop() - audioEngine = nil - playerNode = nil - builtOutputFormat = nil - } - - // 重建引擎 - let engine = AVAudioEngine() - // 校验输出硬件格式有效(采样率/声道数 > 0),无效说明会话未就绪,放弃本次播放以避免 connect 崩溃 - let hwFormat = engine.outputNode.outputFormat(forBus: 0) - guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else { return false } - - let player = AVAudioPlayerNode() - engine.attach(player) - engine.connect(player, to: engine.mainMixerNode, format: playbackFormat) - do { - try engine.start() - player.play() - audioEngine = engine - playerNode = player - builtOutputFormat = hwFormat - return true - } catch { - audioEngine = nil - playerNode = nil - builtOutputFormat = nil - return false - } - } - - /// 等待当前已排入的音频播放完毕(带超时保护),但保留引擎以便复用 - private func drainPlayback() { - engineLock.lock() - guard let engine = audioEngine, engine.isRunning, let player = playerNode, - let silence = AVAudioPCMBuffer(pcmFormat: playbackFormat, frameCapacity: 1) else { - engineLock.unlock() - return - } - silence.frameLength = 1 - silence.floatChannelData?[0][0] = 0 - let semaphore = DispatchSemaphore(value: 0) - player.scheduleBuffer(silence) { semaphore.signal() } - engineLock.unlock() - _ = semaphore.wait(timeout: .now() + 30) - } - - /// 处理流式 PCM 数据 - private func processStreamingAudio(_ data: Data) { - if autoPlay { - engineLock.lock() - defer { engineLock.unlock() } - guard let engine = audioEngine, engine.isRunning, - let player = playerNode, let buffer = pcmBuffer(from: data) else { return } - player.scheduleBuffer(buffer) - return - } - - guard let codec = g722Codec else { return } - - // 扩容并追加数据 - let required = stashSize + data.count - if required > stashBuffer.count { - var newCap = max(stashBuffer.count, 8192) - while newCap < required { newCap <<= 1 } - var newBuf = Data(capacity: newCap) - newBuf.append(stashBuffer.prefix(stashSize)) - newBuf.append(data) - stashBuffer = newBuf - } else { - if stashSize < stashBuffer.count { - let end = min(stashSize + data.count, stashBuffer.count) - stashBuffer.replaceSubrange(stashSize..= framePcmBytes { - let frame = Data(stashBuffer[cursor.. 0 { - let remaining = stashSize - cursor - if remaining > 0 { - let leftover = Data(stashBuffer[cursor.. AVAudioPCMBuffer? { - // 输出为小端 Int16 单声道(每帧 2 字节),转换为引擎所用的 Float32 - let frameCount = AVAudioFrameCount(data.count / 2) - guard frameCount > 0, - let buffer = AVAudioPCMBuffer(pcmFormat: playbackFormat, frameCapacity: frameCount), - let dst = buffer.floatChannelData else { return nil } - buffer.frameLength = frameCount - data.withUnsafeBytes { raw in - guard let src = raw.bindMemory(to: Int16.self).baseAddress else { return } - for i in 0.. String { - return """ - - - - \(text) - - - - """ - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/lib/asr_tr_tts_manager.dart b/apps/client/local_plugins/asr_tr_tts_manager/lib/asr_tr_tts_manager.dart deleted file mode 100644 index 6f21313a..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/lib/asr_tr_tts_manager.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'asr_tr_tts_manager_platform_interface.dart'; - -class AsrTrTtsManager { - Future translationInit(Map data) { - return AsrTrTtsManagerPlatform.instance.translationInit(data); - } - - Future translationStart(Map data) { - return AsrTrTtsManagerPlatform.instance.translationStart(data); - } - - Future translationStop() { - return AsrTrTtsManagerPlatform.instance.translationStop(); - } - - Future translationDispose() { - return AsrTrTtsManagerPlatform.instance.translationDispose(); - } - - /// 开始录音:将 ASR 正在处理的 PCM 帧写成 WAV。 - /// [data]: {'filePath': String}。声道由原生按当前翻译模式决定 - /// (同声/设备辅助=单声道,通话=双声道)。 - Future enableRecord(Map data) { - return AsrTrTtsManagerPlatform.instance.enableRecord(data); - } - - /// 停止录音。[data]: {'isSave': bool},false 丢弃文件。 - Future stopRecord(Map data) { - return AsrTrTtsManagerPlatform.instance.stopRecord(data); - } - - Stream onTranslationEvent() { - return AsrTrTtsManagerPlatform.instance.onTranslationEvent(); - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/lib/asr_tr_tts_manager_method_channel.dart b/apps/client/local_plugins/asr_tr_tts_manager/lib/asr_tr_tts_manager_method_channel.dart deleted file mode 100644 index 12d61ec6..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/lib/asr_tr_tts_manager_method_channel.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -import 'asr_tr_tts_manager_platform_interface.dart'; - -/// An implementation of [AsrTrTtsManagerPlatform] that uses method channels. -class MethodChannelAsrTrTtsManager extends AsrTrTtsManagerPlatform { - /// The method channel used to interact with the native platform. - @visibleForTesting - final methodChannel = const MethodChannel('asr_tr_tts_manager'); - final translationChannel = const EventChannel( - "asr_tr_tts_manager/translation", - ); - - Stream? _translationEventStream; - StreamSubscription? _translationEventSubscription; - - final StreamController translationEvent = - StreamController.broadcast(); - - void _onTranslationEvent(Map event) { - translationEvent.add(event); - } - - @override - Future translationInit(Map data) async { - final result = await methodChannel.invokeMethod( - 'translationInit', - data, - ); - return result ?? false; - } - - @override - Future translationStart(Map data) async { - final result = await methodChannel.invokeMethod( - 'translationStart', - data, - ); - return result ?? false; - } - - @override - Future translationStop() async { - final result = await methodChannel.invokeMethod( - 'translationStop', - ); - return result ?? false; - } - - @override - Future translationDispose() async { - await _translationEventSubscription?.cancel(); - _translationEventSubscription = null; - _translationEventStream = null; - - final result = await methodChannel.invokeMethod( - 'translationDispose', - ); - return result ?? false; - } - - @override - Future enableRecord(Map data) async { - final result = await methodChannel.invokeMethod( - 'enableRecord', - data, - ); - return result ?? false; - } - - @override - Future stopRecord(Map data) async { - final result = await methodChannel.invokeMethod( - 'stopRecord', - data, - ); - return result ?? false; - } - - @override - Stream onTranslationEvent() { - if (_translationEventStream == null) { - _translationEventStream = translationChannel.receiveBroadcastStream(); - _translationEventSubscription = _translationEventStream!.listen((event) { - if (event is Map) { - _onTranslationEvent(event); - } - }); - } - - return translationEvent.stream; - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/lib/asr_tr_tts_manager_platform_interface.dart b/apps/client/local_plugins/asr_tr_tts_manager/lib/asr_tr_tts_manager_platform_interface.dart deleted file mode 100644 index 82648d11..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/lib/asr_tr_tts_manager_platform_interface.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; - -import 'asr_tr_tts_manager_method_channel.dart'; - -abstract class AsrTrTtsManagerPlatform extends PlatformInterface { - /// Constructs a AsrTrTtsManagerPlatform. - AsrTrTtsManagerPlatform() : super(token: _token); - - static final Object _token = Object(); - - static AsrTrTtsManagerPlatform _instance = MethodChannelAsrTrTtsManager(); - - /// The default instance of [AsrTrTtsManagerPlatform] to use. - /// - /// Defaults to [MethodChannelAsrTrTtsManager]. - static AsrTrTtsManagerPlatform get instance => _instance; - - /// Platform-specific implementations should set this with their own - /// platform-specific class that extends [AsrTrTtsManagerPlatform] when - /// they register themselves. - static set instance(AsrTrTtsManagerPlatform instance) { - PlatformInterface.verifyToken(instance, _token); - _instance = instance; - } - - Future translationInit(Map data) { - throw UnimplementedError('translationInit() has not been implemented.'); - } - - Future translationStart(Map data) { - throw UnimplementedError('translationStart() has not been implemented.'); - } - - Future translationStop() { - throw UnimplementedError('translationStop() has not been implemented.'); - } - - Future translationDispose() { - throw UnimplementedError('translationDispose() has not been implemented.'); - } - - Future enableRecord(Map data) { - throw UnimplementedError('enableRecord() has not been implemented.'); - } - - Future stopRecord(Map data) { - throw UnimplementedError('stopRecord() has not been implemented.'); - } - - Stream onTranslationEvent() { - throw UnimplementedError('onTranslationEvent() has not been implemented.'); - } -} diff --git a/apps/client/local_plugins/asr_tr_tts_manager/pubspec.yaml b/apps/client/local_plugins/asr_tr_tts_manager/pubspec.yaml deleted file mode 100644 index c21a4eb9..00000000 --- a/apps/client/local_plugins/asr_tr_tts_manager/pubspec.yaml +++ /dev/null @@ -1,72 +0,0 @@ -name: asr_tr_tts_manager -description: "A new Flutter project." -version: 0.0.1 -homepage: - -environment: - sdk: ^3.10.4 - flutter: '>=3.3.0' - -dependencies: - flutter: - sdk: flutter - plugin_platform_interface: ^2.0.2 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - # This section identifies this Flutter project as a plugin project. - # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) - # which should be registered in the plugin registry. This is required for - # using method channels. - # The Android 'package' specifies package in which the registered class is. - # This is required for using method channels on Android. - # The 'ffiPlugin' specifies that native code should be built and bundled. - # This is required for using `dart:ffi`. - # All these are used by the tooling to maintain consistency when - # adding or updating assets for this project. - plugin: - platforms: - android: - package: com.example.asr_tr_tts_manager - pluginClass: AsrTrTtsManagerPlugin - ios: - pluginClass: AsrTrTtsManagerPlugin - - # To add assets to your plugin package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/to/asset-from-package - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # To add custom fonts to your plugin package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/to/font-from-package diff --git a/apps/client/local_plugins/jl_opus/README.md b/apps/client/local_plugins/jl_opus/README.md deleted file mode 100644 index 124a1c56..00000000 --- a/apps/client/local_plugins/jl_opus/README.md +++ /dev/null @@ -1,126 +0,0 @@ -# JL OPUS Plugin - -基于杰理音频编解码库的Flutter插件,提供OPUS音频格式的解码功能。 - -## 功能 - -- 支持OPUS文件解码为PCM格式 -- 支持OPUS数据流解码 -- 支持多种采样率和通道配置 -- 支持带协议头/不带协议头的OPUS数据 - -## 引入方式 - -在pubspec.yaml中添加依赖: - -```yaml -dependencies: - jl_opus: - path: ./local_plugins/jl_opus -``` - -## 使用方法 - -### 初始化插件 - -```dart -import 'package:jl_opus/jl_opus.dart'; - -final jlOpus = JlOpus(); -``` - -### 解码OPUS文件 - -```dart -Future decodeOpusFile() async { - final inPath = '/path/to/input.opus'; - final outPath = '/path/to/output.pcm'; - - // 创建解码选项 - final option = OpusOption( - hasHeader: false, // 是否有协议头 - channel: 1, // 通道数:1=单声道,2=立体声 - sampleRate: 16000, // 采样率 - packetSize: 40, // 数据包大小(仅当hasHeader=false时生效) - ); - - try { - // 开始解码文件 - final outFilePath = await jlOpus.decodeOpusFile(inPath, outPath, option); - print('解码成功: $outFilePath'); - } catch (e) { - print('解码失败: $e'); - } -} -``` - -### 解码OPUS数据流 - -```dart -Future decodeOpusStream() async { - // 监听解码后的PCM数据 - jlOpus.decodedDataStream.listen((pcmData) { - print('收到解码后的PCM数据: ${pcmData.length} 字节'); - // 处理解码后的PCM数据 - }); - - // 创建解码选项 - final option = OpusOption( - hasHeader: false, - channel: 1, - sampleRate: 16000, - packetSize: 40, - ); - - // 开始解码数据流 - final success = await jlOpus.startDecodeStream(option); - if (success) { - print('开始解码数据流'); - - // 写入OPUS数据进行解码 - final opusData = Uint8List.fromList([/* OPUS数据 */]); - await jlOpus.writeAudioStream(opusData); - - // 数据处理完成后停止解码 - await jlOpus.stopDecodeStream(); - } -} -``` - -### 监听事件 - -```dart -void listenToEvents() { - jlOpus.eventStream.listen((event) { - switch (event.event) { - case 'onStart': - print('开始${event.type == "file" ? "文件" : "流"}解码'); - break; - case 'onComplete': - print('完成${event.type == "file" ? "文件" : "流"}解码'); - if (event.filePath != null) { - print('输出文件: ${event.filePath}'); - } - break; - case 'onError': - print('解码错误: [${event.errorCode}] ${event.errorMessage}'); - break; - } - }); -} -``` - -### 资源释放 - -```dart -Future disposePlugin() async { - await jlOpus.dispose(); - print('插件资源已释放'); -} -``` - -## 注意事项 - -1. OPUS解码需要正确设置参数,特别是采样率和通道数必须与原始音频匹配 -2. 使用前请确保jl_audio_decode_V2.0.0_20005.aar包已添加到libs目录 -3. 本插件仅支持Android平台 \ No newline at end of file diff --git a/apps/client/local_plugins/jl_opus/android/build.gradle.kts b/apps/client/local_plugins/jl_opus/android/build.gradle.kts deleted file mode 100644 index cb74f462..00000000 --- a/apps/client/local_plugins/jl_opus/android/build.gradle.kts +++ /dev/null @@ -1,62 +0,0 @@ -plugins { - // Android Library 插件 - id("com.android.library") - // Kotlin Android 插件 - id("org.jetbrains.kotlin.android") - id("com.github.johnrengelman.shadow") version "8.1.1" -} - -// 添加mavenLocal()仓库 -repositories { - mavenLocal() - google() - mavenCentral() -} - -android { - // 命名空间,对应你插件的包名(需与代码内包名保持一致) - namespace = "com.yunqiinnovation.jl_opus" - - // 目标 SDK 版本 - compileSdk = 35 - - defaultConfig { - // 最低 SDK 版本 - minSdk = 21 - targetSdk = 35 - } - - // Java 语言级别兼容配置 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - // Kotlin 语言级别 - kotlinOptions { - jvmTarget = "11" - } - - // 添加packaging配置,排除冲突文件 - packaging { - resources { - excludes.add("META-INF/DEPENDENCIES") - excludes.add("META-INF/LICENSE") - excludes.add("META-INF/LICENSE.txt") - excludes.add("META-INF/license.txt") - excludes.add("META-INF/NOTICE") - excludes.add("META-INF/NOTICE.txt") - excludes.add("META-INF/notice.txt") - excludes.add("META-INF/*.kotlin_module") - } - } -} - -dependencies { - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10") - implementation("androidx.annotation:annotation:1.5.0") - implementation("androidx.core:core-ktx:1.9.0") - // 使用从本地Maven仓库引用的AAR依赖 - implementation("com.jieli.audio:jl_audio_decode:2.1.0") -} - diff --git a/apps/client/local_plugins/jl_opus/android/settings.gradle.kts b/apps/client/local_plugins/jl_opus/android/settings.gradle.kts deleted file mode 100644 index 453a0b3f..00000000 --- a/apps/client/local_plugins/jl_opus/android/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "jl_opus" \ No newline at end of file diff --git a/apps/client/local_plugins/jl_opus/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/jl_opus/android/src/main/AndroidManifest.xml deleted file mode 100644 index 331a4778..00000000 --- a/apps/client/local_plugins/jl_opus/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/apps/client/local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt b/apps/client/local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt deleted file mode 100644 index a2c9ad16..00000000 --- a/apps/client/local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt +++ /dev/null @@ -1,285 +0,0 @@ -package com.yunqiinnovation.jl_opus - -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.util.Log -import androidx.annotation.NonNull -import com.jieli.jl_audio_decode.opus.model.OpusOption -import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback -import com.jieli.jl_audio_decode.callback.OnStateCallback -import com.jieli.jl_audio_decode.opus.OpusManager -import com.jieli.jl_audio_decode.exceptions.OpusException -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import java.io.File - -/** JlOpusPlugin */ -class JlOpusPlugin: FlutterPlugin, MethodCallHandler { - private lateinit var channel: MethodChannel - private lateinit var eventChannel: EventChannel - private lateinit var context: Context - private var opusManager: OpusManager? = null - private var streamSink: EventChannel.EventSink? = null - - // 添加主线程Handler用于确保在主线程回调 - private val mainHandler = Handler(Looper.getMainLooper()) - - companion object { - private const val TAG = "JlOpusPlugin" - private const val CHANNEL_NAME = "com.yunqiinnovation.jl_opus" - private const val EVENT_CHANNEL_NAME = "com.yunqiinnovation.jl_opus/events" - } - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) - channel.setMethodCallHandler(this) - - eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, EVENT_CHANNEL_NAME) - eventChannel.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - streamSink = events - } - - override fun onCancel(arguments: Any?) { - streamSink = null - } - }) - - - } - - // 在主线程上安全发送事件 - private fun sendEventOnMainThread(event: Map) { - if (streamSink == null) return - - if (Looper.myLooper() == Looper.getMainLooper()) { - // 如果已经在主线程,直接发送 - streamSink?.success(event) - } else { - // 否则切换到主线程 - mainHandler.post { - streamSink?.success(event) - } - } - } - - // 在主线程上安全返回结果 - private fun sendResultOnMainThread(result: Result, value: Any?) { - if (Looper.myLooper() == Looper.getMainLooper()) { - // 如果已经在主线程,直接返回 - result.success(value) - } else { - // 否则切换到主线程 - mainHandler.post { - result.success(value) - } - } - } - - // 在主线程上安全返回错误 - private fun sendErrorOnMainThread(result: Result, errorCode: String, errorMessage: String, errorDetails: Any?) { - if (Looper.myLooper() == Looper.getMainLooper()) { - // 如果已经在主线程,直接返回 - result.error(errorCode, errorMessage, errorDetails) - } else { - // 否则切换到主线程 - mainHandler.post { - result.error(errorCode, errorMessage, errorDetails) - } - } - } - - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - // if (opusManager == null) { - // result.error("OPUS_MANAGER_NULL", "OpusManager未初始化", null) - // return - // } - - when (call.method) { - "initOpusDecoder" -> { - // 初始化OpusManager - - opusManager = OpusManager() - Log.i("TAG", "OpusManager初始化成功") - result.success(true) - - - } - "decodeOpusFile" -> { - val inPath = call.argument("inPath") - val outPath = call.argument("outPath") - val hasHeader = call.argument("hasHeader") ?: false - val channel = call.argument("channel") ?: 1 - val sampleRate = call.argument("sampleRate") ?: 16000 - val packetSize = call.argument("packetSize") ?: 40 - - if (inPath == null || outPath == null) { - result.error("INVALID_ARGS", "输入或输出路径不能为空", null) - return - } - - // 确保输入文件存在 - val inFile = File(inPath) - if (!inFile.exists()) { - result.error("FILE_NOT_FOUND", "输入文件不存在: $inPath", null) - return - } - - // 创建解码参数 - val option = OpusOption() - .setHasHead(hasHeader) - .setChannel(channel) - .setSampleRate(sampleRate) - .setPacketSize(packetSize) - - Log.d(TAG, "开始解码文件, 参数: $option, 输入: $inPath, 输出: $outPath") - - opusManager?.decodeFile(inPath, outPath, option, object : OnStateCallback { - override fun onStart() { - Log.d(TAG, "文件解码开始") - sendEventOnMainThread(mapOf( - "event" to "onStart", - "type" to "file" - )) - } - - override fun onComplete(outFilePath: String?) { - Log.d(TAG, "文件解码完成: $outFilePath") - sendEventOnMainThread(mapOf( - "event" to "onComplete", - "type" to "file", - "filePath" to outFilePath - )) - sendResultOnMainThread(result, outFilePath) - } - - override fun onError(code: Int, message: String?) { - Log.e(TAG, "文件解码错误: [$code] $message") - sendEventOnMainThread(mapOf( - "event" to "onError", - "type" to "file", - "code" to code, - "message" to message - )) - sendErrorOnMainThread(result, "DECODE_ERROR", "解码出错: [$code] $message", null) - } - }) - } - "startDecodeStream" -> { - val hasHeader = call.argument("hasHeader") ?: false - val channel = call.argument("channel") ?: 1 - val sampleRate = call.argument("sampleRate") ?: 16000 - val packetSize = call.argument("packetSize") ?: 40 - - // 如果已经在解码流,先停止 - if (opusManager?.isDecodeStream == true) { - opusManager?.stopDecodeStream() - } - - // 创建解码参数 - val option = OpusOption() - .setHasHead(hasHeader) - .setChannel(channel) - .setSampleRate(sampleRate) - .setPacketSize(packetSize) - - Log.d(TAG, "开始解码数据流, 参数: $option") - - opusManager?.startDecodeStream(option, object : OnDecodeStreamCallback { - override fun onDecodeStream(data: ByteArray?) { - if (data != null) { - sendEventOnMainThread(mapOf( - "event" to "onDecodeStream", - "data" to data - )) - } - } - - override fun onStart() { - Log.d(TAG, "数据流解码开始") - sendEventOnMainThread(mapOf( - "event" to "onStart", - "type" to "stream" - )) - sendResultOnMainThread(result, true) - } - - override fun onComplete(outPath: String?) { - Log.d(TAG, "数据流解码完成: $outPath") - sendEventOnMainThread(mapOf( - "event" to "onComplete", - "type" to "stream" - )) - } - - override fun onError(code: Int, message: String?) { - Log.e(TAG, "数据流解码错误: [$code] $message") - sendEventOnMainThread(mapOf( - "event" to "onError", - "type" to "stream", - "code" to code, - "message" to message - )) - sendErrorOnMainThread(result, "DECODE_STREAM_ERROR", "数据流解码出错: [$code] $message", null) - } - }) - } - "stopDecodeStream" -> { - if (opusManager?.isDecodeStream == true) { - opusManager?.stopDecodeStream() - result.success(true) - } else { - result.success(false) - } - } - "writeAudioStream" -> { - val data = call.argument("data") - if (data == null) { - result.error("INVALID_ARGS", "音频数据不能为空", null) - return - } - - if (opusManager?.isDecodeStream == true) { - opusManager?.writeAudioStream(data) - result.success(true) - } else { - result.error("NOT_DECODING", "当前没有处于解码状态", null) - } - } - "isDecoding" -> { - result.success(opusManager?.isDecodeStream == true) - } - "dispose" -> { - opusManager?.let { - if (it.isDecodeStream) { - it.stopDecodeStream() - } - it.release() - } - opusManager = null - result.success(true) - } - else -> { - result.notImplemented() - } - } - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - eventChannel.setStreamHandler(null) - opusManager?.let { - if (it.isDecodeStream) { - it.stopDecodeStream() - } - it.release() - } - opusManager = null - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.1.0_20012-release.aar b/apps/client/local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.1.0_20012-release.aar deleted file mode 100644 index 1a609561..00000000 Binary files a/apps/client/local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.1.0_20012-release.aar and /dev/null differ diff --git a/apps/client/local_plugins/jl_opus/ios/jl_opus/Package.swift b/apps/client/local_plugins/jl_opus/ios/jl_opus/Package.swift deleted file mode 100644 index ab0c6c8e..00000000 --- a/apps/client/local_plugins/jl_opus/ios/jl_opus/Package.swift +++ /dev/null @@ -1,22 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "jl_opus", - platforms: [.iOS("16.0")], - products: [ - .library(name: "jl-opus", targets: ["jl_opus"]) - ], - dependencies: [ - .package(path: "../../../ble_service/ios/ble_service") - ], - targets: [ - .target( - name: "jl_opus", - dependencies: [ - .product(name: "ble-service", package: "ble_service") - ], - path: "Sources/jl_opus" - ) - ] -) diff --git a/apps/client/local_plugins/jl_opus/ios/jl_opus/Sources/jl_opus/JlOpusPlugin.swift b/apps/client/local_plugins/jl_opus/ios/jl_opus/Sources/jl_opus/JlOpusPlugin.swift deleted file mode 100644 index 09734dab..00000000 --- a/apps/client/local_plugins/jl_opus/ios/jl_opus/Sources/jl_opus/JlOpusPlugin.swift +++ /dev/null @@ -1,450 +0,0 @@ -import Flutter -import Foundation -import opus - -@objc(JlOpusPlugin) -public class JlOpusPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { - private var eventSink: FlutterEventSink? - private var isInitialized: Bool = false - - private var isDecodingStream: Bool = false - private var streamHasHeader: Bool = false - private var streamChannels: Int32 = 1 - private var streamSampleRate: Int32 = 16000 - private var streamDecoder: OpaquePointer? - - private let decodeQueue = DispatchQueue(label: "com.yunqiinnovation.jl_opus.decode", qos: .userInitiated) - - /// Flutter 插件注册入口 - /// - Parameter registrar: FlutterPluginRegistrar - /// - Returns: 无 - /// - Throws: 不抛出 - public static func register(with registrar: FlutterPluginRegistrar) { - let methodChannel = FlutterMethodChannel( - name: "com.yunqiinnovation.jl_opus", - binaryMessenger: registrar.messenger() - ) - - let eventChannel = FlutterEventChannel( - name: "com.yunqiinnovation.jl_opus/events", - binaryMessenger: registrar.messenger() - ) - - let instance = JlOpusPlugin() - registrar.addMethodCallDelegate(instance, channel: methodChannel) - eventChannel.setStreamHandler(instance) - } - - /// 处理 Flutter 侧方法调用 - /// - Parameters: - /// - call: FlutterMethodCall - /// - result: FlutterResult - /// - Returns: 无 - /// - Throws: 不抛出(错误通过 FlutterError 返回) - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "initOpusDecoder": - isInitialized = true - result(true) - - case "decodeOpusFile": - decodeOpusFile(arguments: call.arguments, result: result) - - case "startDecodeStream": - startDecodeStream(arguments: call.arguments, result: result) - - case "stopDecodeStream": - stopDecodeStream(result: result) - - case "writeAudioStream": - writeAudioStream(arguments: call.arguments, result: result) - - case "isDecoding": - result(isDecodingStream) - - case "dispose": - dispose(result: result) - - default: - result(FlutterMethodNotImplemented) - } - } - - /// 事件通道开始监听 - /// - Parameters: - /// - arguments: Flutter 传入参数 - /// - eventSink: 事件回调 - /// - Returns: FlutterError?(通常为 nil) - /// - Throws: 不抛出 - public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - eventSink = events - return nil - } - - /// 事件通道取消监听 - /// - Parameter arguments: Flutter 传入参数 - /// - Returns: FlutterError?(通常为 nil) - /// - Throws: 不抛出 - public func onCancel(withArguments arguments: Any?) -> FlutterError? { - eventSink = nil - return nil - } - - /// 解码 Opus 文件为 PCM 文件(16-bit little-endian 原始 PCM) - /// - Parameters: - /// - arguments: Flutter 传入参数(需包含 inPath/outPath/hasHeader/channel/sampleRate/packetSize) - /// - result: FlutterResult,成功返回 outPath,失败返回 FlutterError - /// - Returns: 无 - /// - Throws: 不直接抛出;内部错误会通过 FlutterError 与事件通道 onError 返回 - private func decodeOpusFile(arguments: Any?, result: @escaping FlutterResult) { - guard isInitialized else { - result(FlutterError(code: "NOT_INITIALIZED", message: "Opus解码器未初始化", details: nil)) - return - } - - guard let args = arguments as? [String: Any], - let inPath = args["inPath"] as? String, - let outPath = args["outPath"] as? String else { - result(FlutterError(code: "INVALID_ARGS", message: "输入或输出路径不能为空", details: nil)) - return - } - - let hasHeader = (args["hasHeader"] as? Bool) ?? false - let channel = Int32((args["channel"] as? Int) ?? 1) - let sampleRate = Int32((args["sampleRate"] as? Int) ?? 16000) - let packetSize = (args["packetSize"] as? Int) ?? 40 - - sendEvent([ - "event": "onStart", - "type": "file" - ]) - - decodeQueue.async { [weak self] in - guard let self = self else { return } - - do { - let inputUrl = URL(fileURLWithPath: inPath) - let outputUrl = URL(fileURLWithPath: outPath) - - let inputData = try Data(contentsOf: inputUrl) - if inputData.isEmpty { - throw NSError(domain: "jl_opus", code: -2, userInfo: [NSLocalizedDescriptionKey: "输入文件为空"]) - } - - let packets = try self.splitPackets(inputData: inputData, hasHeader: hasHeader, packetSize: packetSize) - let pcmData = try self.decodePacketsToPcm(packets: packets, sampleRate: sampleRate, channels: channel) - - try self.ensureParentDirectoryExists(for: outputUrl) - try pcmData.write(to: outputUrl, options: .atomic) - - self.sendEvent([ - "event": "onComplete", - "type": "file", - "filePath": outPath - ]) - - DispatchQueue.main.async { - result(outPath) - } - } catch { - let message = error.localizedDescription - self.sendEvent([ - "event": "onError", - "type": "file", - "code": -1, - "message": message - ]) - - DispatchQueue.main.async { - result(FlutterError(code: "DECODE_ERROR", message: "解码出错: \(message)", details: nil)) - } - } - } - } - - /// 开始 Opus 数据流解码 - /// - Parameters: - /// - arguments: Flutter 传入参数(需包含 hasHeader/channel/sampleRate) - /// - result: FlutterResult,成功返回 true,失败返回 FlutterError - /// - Returns: 无 - /// - Throws: 不抛出;失败通过 FlutterError/事件 onError 返回 - private func startDecodeStream(arguments: Any?, result: @escaping FlutterResult) { - guard isInitialized else { - result(FlutterError(code: "NOT_INITIALIZED", message: "Opus解码器未初始化", details: nil)) - return - } - - guard let args = arguments as? [String: Any] else { - result(FlutterError(code: "INVALID_ARGS", message: "缺少解码参数", details: nil)) - return - } - - let hasHeader = (args["hasHeader"] as? Bool) ?? false - let channel = Int32((args["channel"] as? Int) ?? 1) - let sampleRate = Int32((args["sampleRate"] as? Int) ?? 16000) - - stopDecodeStreamInternal() - - var error: Int32 = 0 - guard let decoder = opus_decoder_create(sampleRate, channel, &error), error == OPUS_OK else { - sendEvent([ - "event": "onError", - "type": "stream", - "code": Int(error), - "message": "Opus解码器创建失败: \(error)" - ]) - result(FlutterError(code: "DECODE_STREAM_ERROR", message: "数据流解码器创建失败", details: nil)) - return - } - - streamHasHeader = hasHeader - streamChannels = channel - streamSampleRate = sampleRate - streamDecoder = decoder - isDecodingStream = true - - sendEvent([ - "event": "onStart", - "type": "stream" - ]) - result(true) - } - - /// 停止 Opus 数据流解码 - /// - Parameter result: FlutterResult,已停止返回 true,未处于解码返回 false - /// - Returns: 无 - /// - Throws: 不抛出 - private func stopDecodeStream(result: @escaping FlutterResult) { - if isDecodingStream { - stopDecodeStreamInternal() - result(true) - } else { - result(false) - } - } - - /// 停止并释放当前数据流解码器(内部方法) - /// - Returns: 无 - /// - Throws: 不抛出 - private func stopDecodeStreamInternal() { - if let decoder = streamDecoder { - opus_decoder_destroy(decoder) - } - streamDecoder = nil - isDecodingStream = false - } - - /// 写入 Opus 数据到解码流,并通过事件通道返回 PCM 数据 - /// - Parameters: - /// - arguments: Flutter 传入参数(需包含 data) - /// - result: FlutterResult,成功返回 true;不在解码状态返回 FlutterError - /// - Returns: 无 - /// - Throws: 不直接抛出;解码错误通过事件 onError 返回 - private func writeAudioStream(arguments: Any?, result: @escaping FlutterResult) { - guard isDecodingStream, let decoder = streamDecoder else { - result(FlutterError(code: "NOT_DECODING", message: "当前没有处于解码状态", details: nil)) - return - } - - guard let args = arguments as? [String: Any], - let data = args["data"] as? FlutterStandardTypedData else { - result(FlutterError(code: "INVALID_ARGS", message: "音频数据不能为空", details: nil)) - return - } - - let opusData = data.data - if opusData.isEmpty { - result(true) - return - } - - let hasHeader = streamHasHeader - let channels = streamChannels - let sampleRate = streamSampleRate - - decodeQueue.async { [weak self] in - guard let self = self else { return } - - do { - let packets: [Data] - if hasHeader { - packets = try self.splitPacketsByLengthPrefix(inputData: opusData) - } else { - packets = [opusData] - } - - for packet in packets { - let pcm = try self.decodeSinglePacketToPcm(decoder: decoder, packet: packet, sampleRate: sampleRate, channels: channels) - if !pcm.isEmpty { - self.sendEvent([ - "event": "onDecodeStream", - "data": FlutterStandardTypedData(bytes: pcm) - ]) - } - } - } catch { - let message = error.localizedDescription - self.sendEvent([ - "event": "onError", - "type": "stream", - "code": -1, - "message": message - ]) - } - } - - result(true) - } - - /// 释放插件资源 - /// - Parameter result: FlutterResult - /// - Returns: 无 - /// - Throws: 不抛出 - private func dispose(result: @escaping FlutterResult) { - stopDecodeStreamInternal() - isInitialized = false - result(true) - } - - /// 拆分输入文件数据为 Opus 包列表 - /// - Parameters: - /// - inputData: 输入文件二进制数据 - /// - hasHeader: 是否包含 2 字节小端长度前缀 - /// - packetSize: 无协议头时按固定长度切包 - /// - Returns: Opus 包数组 - /// - Throws: 参数错误或解析失败时抛出 NSError - private func splitPackets(inputData: Data, hasHeader: Bool, packetSize: Int) throws -> [Data] { - if hasHeader { - return try splitPacketsByLengthPrefix(inputData: inputData) - } - - guard packetSize > 0 else { - throw NSError(domain: "jl_opus", code: -5, userInfo: [NSLocalizedDescriptionKey: "packetSize 必须大于 0"]) - } - - var packets: [Data] = [] - var offset = 0 - while offset < inputData.count { - let end = min(offset + packetSize, inputData.count) - packets.append(inputData.subdata(in: offset.. [Data] { - var packets: [Data] = [] - var cursor = 0 - while cursor + 2 <= inputData.count { - let lo = Int(inputData[cursor]) - let hi = Int(inputData[cursor + 1]) << 8 - let length = lo | hi - cursor += 2 - - if length <= 0 || cursor + length > inputData.count { - throw NSError(domain: "jl_opus", code: -3, userInfo: [NSLocalizedDescriptionKey: "协议头解析失败"]) - } - - packets.append(inputData.subdata(in: cursor..<(cursor + length))) - cursor += length - } - - if packets.isEmpty { - throw NSError(domain: "jl_opus", code: -4, userInfo: [NSLocalizedDescriptionKey: "未解析到任何Opus数据包"]) - } - - return packets - } - - /// 将 Opus 包列表解码为 PCM 数据 - /// - Parameters: - /// - packets: Opus 包数组 - /// - sampleRate: 采样率 - /// - channels: 通道数 - /// - Returns: PCM 数据(16-bit little-endian) - /// - Throws: 解码器创建失败或任意包解码失败时抛出 NSError - private func decodePacketsToPcm(packets: [Data], sampleRate: Int32, channels: Int32) throws -> Data { - var error: Int32 = 0 - guard let decoder = opus_decoder_create(sampleRate, channels, &error), error == OPUS_OK else { - throw NSError(domain: "jl_opus", code: Int(error), userInfo: [NSLocalizedDescriptionKey: "Opus解码器创建失败: \(error)"]) - } - defer { opus_decoder_destroy(decoder) } - - var pcmOutput = Data() - for packet in packets { - let pcmChunk = try decodeSinglePacketToPcm(decoder: decoder, packet: packet, sampleRate: sampleRate, channels: channels) - pcmOutput.append(pcmChunk) - } - return pcmOutput - } - - /// 解码单个 Opus 包为 PCM - /// - Parameters: - /// - decoder: Opus 解码器指针 - /// - packet: 单个 Opus 包 - /// - sampleRate: 采样率 - /// - channels: 通道数 - /// - Returns: PCM 数据(16-bit little-endian) - /// - Throws: 解码失败时抛出 NSError - private func decodeSinglePacketToPcm(decoder: OpaquePointer, packet: Data, sampleRate: Int32, channels: Int32) throws -> Data { - let maxSamplesPerChannel = Int(sampleRate * 120 / 1000) - var pcmBuffer = [opus_int16](repeating: 0, count: maxSamplesPerChannel * Int(channels)) - - let decodedSamples: Int32 = packet.withUnsafeBytes { packetPtr in - guard let inPtr = packetPtr.bindMemory(to: UInt8.self).baseAddress else { - return OPUS_BAD_ARG - } - - return opus_decode( - decoder, - inPtr, - Int32(packet.count), - &pcmBuffer, - Int32(maxSamplesPerChannel), - 0 - ) - } - - if decodedSamples < 0 { - throw NSError(domain: "jl_opus", code: Int(decodedSamples), userInfo: [NSLocalizedDescriptionKey: "Opus解码失败: \(decodedSamples)"]) - } - - let sampleCount = Int(decodedSamples) * Int(channels) - return pcmBuffer.withUnsafeBytes { rawPtr in - Data(rawPtr.bindMemory(to: UInt8.self).prefix(sampleCount * MemoryLayout.size)) - } - } - - /// 确保输出路径的父目录存在 - /// - Parameter url: 输出文件 URL - /// - Returns: 无 - /// - Throws: 创建目录失败时抛出错误 - private func ensureParentDirectoryExists(for url: URL) throws { - let dir = url.deletingLastPathComponent() - var isDirectory: ObjCBool = false - if FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDirectory), isDirectory.boolValue { - return - } - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) - } - - /// 通过事件通道向 Flutter 发送事件 - /// - Parameter event: 事件字典 - /// - Returns: 无 - /// - Throws: 不抛出 - private func sendEvent(_ event: [String: Any]) { - guard let sink = eventSink else { return } - DispatchQueue.main.async { - sink(event) - } - } -} diff --git a/apps/client/local_plugins/jl_opus/lib/jl_opus.dart b/apps/client/local_plugins/jl_opus/lib/jl_opus.dart deleted file mode 100644 index dc63a0cd..00000000 --- a/apps/client/local_plugins/jl_opus/lib/jl_opus.dart +++ /dev/null @@ -1,214 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:flutter/services.dart'; - -/// OPUS解码参数选项 -class OpusOption { - /// 是否具有协议头 - final bool hasHeader; - - /// 通道数。取值范围:[1, 2] - final int channel; - - /// 采样率。参考值:{8000, 16000, 24000, 32000, 44100, 48000} - final int sampleRate; - - /// 数据包长度。仅hasHeader = false时生效。 - final int packetSize; - - /// 构造函数 - OpusOption({ - this.hasHeader = false, - this.channel = 1, - this.sampleRate = 16000, - this.packetSize = 40, - }); - - /// 转换为参数Map - Map toMap() { - return { - 'hasHeader': hasHeader, - 'channel': channel, - 'sampleRate': sampleRate, - 'packetSize': packetSize, - }; - } -} - -/// 杰理OPUS编解码器事件 -class OpusEvent { - /// 事件类型 - final String event; - - /// 数据类型 (file或stream) - final String? type; - - /// 解码后的数据 (仅在流解码时有效) - final Uint8List? data; - - /// 输出文件路径 (仅在文件解码完成时有效) - final String? filePath; - - /// 错误码 (仅在发生错误时有效) - final int? errorCode; - - /// 错误信息 (仅在发生错误时有效) - final String? errorMessage; - - /// 构造函数 - OpusEvent({ - required this.event, - this.type, - this.data, - this.filePath, - this.errorCode, - this.errorMessage, - }); - - /// 从Map构造对象 - factory OpusEvent.fromMap(Map map) { - return OpusEvent( - event: map['event'], - type: map['type'], - data: map['data'], - filePath: map['filePath'], - errorCode: map['code'], - errorMessage: map['message'], - ); - } -} - -/// 杰理OPUS编解码插件 -class JlOpus { - static const MethodChannel _methodChannel = MethodChannel('com.yunqiinnovation.jl_opus'); - static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.jl_opus/events'); - - /// 事件流 - Stream? _eventStream; - - /// 获取事件流 - Stream get eventStream { - _eventStream ??= _eventChannel.receiveBroadcastStream().map((event) { - if (event is Map) { - return OpusEvent.fromMap(event); - } - throw PlatformException( - code: 'INVALID_EVENT', - message: '无效的事件数据', - ); - }); - return _eventStream!; - } - - /// 解码后的数据流 - final StreamController _decodedDataController = StreamController.broadcast(); - - /// 获取解码数据流 - Stream get decodedDataStream => _decodedDataController.stream; - - /// 构造函数,设置事件监听 - JlOpus() { - _setupEventListener(); - } - - /// 设置事件监听 - void _setupEventListener() { - eventStream.listen((event) { - if (event.event == 'onDecodeStream' && event.data != null) { - _decodedDataController.add(event.data!); - } - }); - } - - /// 初始化OPUS解码器 - Future initOpusDecoder() async { - try { - final result = await _methodChannel.invokeMethod('initOpusDecoder'); - return result ?? false; - } catch (e) { - return false; - } - } - - /// 解码OPUS文件 - /// - /// [inPath] 输入文件路径 - /// [outPath] 输出文件路径 - /// [option] 解码参数 - Future decodeOpusFile(String inPath, String outPath, OpusOption option) async { - try { - final result = await _methodChannel.invokeMethod( - 'decodeOpusFile', - { - 'inPath': inPath, - 'outPath': outPath, - ...option.toMap(), - }, - ); - return result; - } catch (e) { - rethrow; - } - } - - /// 开始解码OPUS数据流 - /// - /// [option] 解码参数 - Future startDecodeStream(OpusOption option) async { - try { - final result = await _methodChannel.invokeMethod( - 'startDecodeStream', - option.toMap(), - ); - return result ?? false; - } catch (e) { - rethrow; - } - } - - /// 停止解码OPUS数据流 - Future stopDecodeStream() async { - try { - final result = await _methodChannel.invokeMethod('stopDecodeStream'); - return result ?? false; - } catch (e) { - return false; - } - } - - /// 写入OPUS音频数据进行解码 - /// - /// [data] OPUS音频数据 - Future writeAudioStream(Uint8List data) async { - try { - final result = await _methodChannel.invokeMethod( - 'writeAudioStream', - {'data': data}, - ); - return result ?? false; - } catch (e) { - rethrow; - } - } - - /// 判断是否正在解码 - Future isDecoding() async { - try { - final result = await _methodChannel.invokeMethod('isDecoding'); - return result ?? false; - } catch (e) { - return false; - } - } - - /// 释放资源 - Future dispose() async { - try { - _decodedDataController.close(); - final result = await _methodChannel.invokeMethod('dispose'); - return result ?? false; - } catch (e) { - return false; - } - } -} diff --git a/apps/client/local_plugins/jl_opus/pubspec.yaml b/apps/client/local_plugins/jl_opus/pubspec.yaml deleted file mode 100644 index b28aaeb0..00000000 --- a/apps/client/local_plugins/jl_opus/pubspec.yaml +++ /dev/null @@ -1,25 +0,0 @@ -name: jl_opus -description: 杰理OPUS编解码库Flutter插件 -version: 0.0.1 -homepage: - -environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" - -dependencies: - flutter: - sdk: flutter - path_provider: ^2.0.0 - permission_handler: ^12.0.1 - -dev_dependencies: - flutter_test: - sdk: flutter - -flutter: - plugin: - platforms: - android: - package: com.yunqiinnovation.jl_opus - pluginClass: JlOpusPlugin diff --git a/apps/client/local_plugins/open_ai_service/LICENSE b/apps/client/local_plugins/open_ai_service/LICENSE deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/client/local_plugins/open_ai_service/android/AndroidManifest.xml b/apps/client/local_plugins/open_ai_service/android/AndroidManifest.xml deleted file mode 100644 index e71b124e..00000000 --- a/apps/client/local_plugins/open_ai_service/android/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/apps/client/local_plugins/open_ai_service/android/build.gradle.kts b/apps/client/local_plugins/open_ai_service/android/build.gradle.kts deleted file mode 100644 index a93b5d5a..00000000 --- a/apps/client/local_plugins/open_ai_service/android/build.gradle.kts +++ /dev/null @@ -1,64 +0,0 @@ -plugins { - // Android Library 插件 - id("com.android.library") - // Kotlin Android 插件 - id("org.jetbrains.kotlin.android") - id("com.github.johnrengelman.shadow") version "8.1.1" - kotlin("plugin.serialization") version "1.9.24" -} - -android { - // 命名空间,对应你插件的包名(需与代码内包名保持一致) - namespace = "com.yunqiinnovation.open_ai_service" - - // 目标 SDK 版本 - compileSdk = 35 - - defaultConfig { - // 最低 SDK 版本 - minSdk = 21 - targetSdk = 35 - } - - // Java 语言级别兼容配置 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - // Kotlin 语言级别 - kotlinOptions { - jvmTarget = "11" - } - - // 添加packaging配置,排除冲突文件 - packaging { - resources { - excludes.add("META-INF/DEPENDENCIES") - excludes.add("META-INF/LICENSE") - excludes.add("META-INF/LICENSE.txt") - excludes.add("META-INF/license.txt") - excludes.add("META-INF/NOTICE") - excludes.add("META-INF/NOTICE.txt") - excludes.add("META-INF/notice.txt") - excludes.add("META-INF/*.kotlin_module") - } - } -} - -dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") - implementation("com.squareup.okhttp3:okhttp:4.9.3") - implementation("com.squareup.okhttp3:logging-interceptor:4.9.3") - implementation("com.anthropic:anthropic-java:0.8.0") - implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") - implementation("io.ktor:ktor-client-core:3.1.2") - implementation("io.ktor:ktor-client-cio:3.1.2") - implementation("io.ktor:ktor-client-okhttp:3.1.2") - implementation("io.ktor:ktor-client-logging:3.1.2") - implementation("com.google.code.gson:gson:2.10.1") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") - implementation("androidx.media:media:1.6.0") -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/android/settings.gradle.kts b/apps/client/local_plugins/open_ai_service/android/settings.gradle.kts deleted file mode 100644 index 1c33f497..00000000 --- a/apps/client/local_plugins/open_ai_service/android/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "open_ai_service" \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/open_ai_service/android/src/main/AndroidManifest.xml deleted file mode 100644 index 69c9202a..00000000 --- a/apps/client/local_plugins/open_ai_service/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - diff --git a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt b/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt deleted file mode 100644 index 370e16a5..00000000 --- a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt +++ /dev/null @@ -1,1070 +0,0 @@ -package com.yunqiinnovation.open_ai_service - -import android.content.Context -import android.util.Log -import com.anthropic.core.JsonValue -import com.anthropic.models.messages.Tool -import com.anthropic.models.messages.ToolUnion -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import io.ktor.client.HttpClient -import io.ktor.client.plugins.sse.* -import io.modelcontextprotocol.kotlin.sdk.Implementation -import io.modelcontextprotocol.kotlin.sdk.ListToolsResult -import io.modelcontextprotocol.kotlin.sdk.TextContent -import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.JsonObject -import org.json.JSONObject -import kotlin.jvm.optionals.getOrNull -import java.io.File -import io.ktor.client.engine.okhttp.* -import okhttp3.OkHttpClient -import java.security.cert.X509Certificate -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManager -import javax.net.ssl.X509TrustManager -import java.security.SecureRandom -import java.util.concurrent.TimeUnit -import okhttp3.logging.HttpLoggingInterceptor -import okhttp3.Interceptor -import io.ktor.client.request.* -import io.ktor.http.* -import com.yunqiinnovation.open_ai_service.mcp.CustomSseClientTransport -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import okio.Buffer - -/** - * 工具类型枚举 - */ -enum class ToolType { - LOCAL_FUNCTION, // 本地函数 - MCP_TOOL // MCP工具 -} - -/** - * 函数处理器接口 - */ -interface FunctionHandler { - /** - * 处理函数调用 - * @param arguments 函数参数,Map格式 - * @return 函数执行结果,字符串 - */ - suspend fun handle(arguments: Map): String -} - -/** - * 单个MCP子客户端 - * 负责连接到一个具体的MCP服务器并管理其工具 - */ -class MCPSubClient( - private val serverId: String, - serverUrl: String, - private val customClient: HttpClient? = null -) : AutoCloseable { - private val TAG = "MCPSubClient[$serverId]" - - // 保存服务器URL - private val serverUrl: String = serverUrl - - // 重连配置 - private val maxRetryAttempts = 3 - private val initialReconnectDelay = 1000L // 初始重连延迟1秒 - private val maxReconnectDelay = 30000L // 最大重连延迟30秒 - private var currentReconnectDelay = initialReconnectDelay - private var retryCount = 0 - - // 连接状态锁 - private val connectionLock = Mutex() - - // 初始化MCP客户端 - private val mcp: Client = Client( - clientInfo = Implementation( - name = "deep-voice-mcp-client-$serverId", - version = "1.0.0" - ) - ) - - // 服务器提供的工具列表 - private var tools: List = emptyList() - - // 工具Map列表,用于OpenAI请求 - private val toolMaps = mutableListOf>() - - // 是否已连接 - private var isConnected = false - - // SSE传输层,使用自定义的传输层 - private var sseTransport: CustomSseClientTransport? = null - - // 工具名称到工具的映射,用于快速查找 - private val toolMapping = mutableMapOf() - - // 心跳检测 - private var heartbeatJob: Job? = null - private val heartbeatInterval = 30000L // 30秒 - - // 网络状态监听 - private var networkStateListener: NetworkStateListener? = null - - init { - Log.d(TAG, "MCPSubClient初始化,URL: $serverUrl") - } - - /** - * 将JsonObject转换为JsonValue - */ - private fun JsonObject.toJsonValue(): JsonValue { - val mapper = ObjectMapper() - val node = mapper.readTree(this.toString()) - return JsonValue.fromJsonNode(node) - } - - /** - * 连接到MCP服务器(通过SSE) - * @return 是否连接成功 - */ - suspend fun connect(): Boolean { - if (isConnected) { - Log.d(TAG, "已经连接到服务器,跳过重复连接") - return true - } - try { - Log.d(TAG, "开始连接服务器: $serverUrl") - - // 使用提供的客户端或创建带SSL配置的客户端 - val client = customClient ?: createSslTrustAllClient() - - // 使用自定义传输层 - sseTransport = CustomSseClientTransport( - client = client, - urlString = serverUrl - ) - - // 连接MCP客户端到服务器 - mcp.connect(sseTransport!!) - - // 获取工具列表并处理 - val toolsResult = mcp.listTools() - processToolsList(toolsResult) - - isConnected = true - retryCount = 0 - currentReconnectDelay = initialReconnectDelay - // 启动心跳检测 - startHeartbeat() - - Log.d(TAG, "连接成功,可用工具: ${tools.size}个") - return true - } catch (e: Exception) { - Log.e(TAG, "连接失败: ${e.message}") - return false - } - } - - /** - * 处理工具列表 - */ - private fun processToolsList(toolsResult: ListToolsResult?) { - try { - // 将MCP工具列表转换为ToolUnion列表 - tools = toolsResult?.tools?.map { tool -> - // 转换为ToolUnion格式 - ToolUnion.ofTool( - Tool.builder() - .name(tool.name) - .description(tool.description ?: "") - .inputSchema( - Tool.InputSchema.builder() - .type(JsonValue.from(tool.inputSchema?.type ?: "object")) - .properties(if (tool.inputSchema?.properties is JsonObject) - (tool.inputSchema.properties as JsonObject).toJsonValue() - else JsonValue.from(emptyMap())) - .putAdditionalProperty("required", JsonValue.from(tool.inputSchema?.required ?: emptyList())) - .build() - ) - .build() - ) - } ?: emptyList() - - // 更新工具映射 - toolMapping.clear() - tools.forEach { toolUnion -> - val tool = toolUnion.tool().get() - toolMapping[tool.name()] = toolUnion - } - - // 更新工具Map列表 - updateToolMaps() - - Log.d(TAG, "成功处理 ${tools.size} 个工具") - } catch (e: Exception) { - Log.e(TAG, "处理工具列表失败: ${e.message}", e) - } - } - - /** - * 更新工具Map列表,用于OpenAI请求 - * 将MCP工具转换为OpenAI函数调用格式 - */ - private fun updateToolMaps() { - toolMaps.clear() - - try { - tools.forEach { toolUnion -> - val tool = toolUnion.tool().getOrNull() ?: return@forEach - - try { - val inputSchema = tool.inputSchema() ?: return@forEach - val schemaStr = inputSchema.toString() - Log.d(TAG, "工具 ${tool.name()} 的Schema: $schemaStr") - - val propertiesMap = HashMap() - val requiredList = ArrayList() - - try { - // ✅ 替换原来的正则方式,使用括号匹配方式提取 properties 内容 - val propertiesContent = extractPropertiesContent(schemaStr) - if (propertiesContent != null) { - val fieldPattern = "([a-zA-Z0-9_]+)=\\{([^{}]+?)\\}".toRegex() - val fieldMatches = fieldPattern.findAll(propertiesContent) - - fieldMatches.forEach { fieldMatch -> - val fieldName = fieldMatch.groupValues[1] - val fieldContent = fieldMatch.groupValues[2] - - val fieldMap = HashMap() - val keyValuePattern = "([a-zA-Z0-9_]+)=(\"[^\"]*\"|'[^']*'|[^,}\\s]+)".toRegex() - val keyValueMatches = keyValuePattern.findAll(fieldContent) - - keyValueMatches.forEach { kvMatch -> - val key = kvMatch.groupValues[1] - var value = kvMatch.groupValues[2].trim() - - if ((value.startsWith("\"") && value.endsWith("\"")) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.substring(1, value.length - 1) - } - - val processedValue: Any = when { - value.equals("true", ignoreCase = true) -> true - value.equals("false", ignoreCase = true) -> false - value.matches("\\d+".toRegex()) -> value.toIntOrNull() ?: value - value.matches("\\d+\\.\\d+".toRegex()) -> value.toDoubleOrNull() ?: value - else -> value - } - - fieldMap[key] = processedValue - } - - propertiesMap[fieldName] = fieldMap - } - - Log.d(TAG, "成功解析属性: $propertiesMap") - } - - // ✅ required 部分保持不变 - val requiredPattern = "required=\\[(.*?)\\]".toRegex() - val requiredMatch = requiredPattern.find(schemaStr) - if (requiredMatch != null) { - val requiredStr = requiredMatch.groupValues[1] - if (requiredStr.isNotEmpty()) { - val fieldNames = requiredStr.split(",").map { - val trimmed = it.trim() - if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || - (trimmed.startsWith("'") && trimmed.endsWith("'"))) { - trimmed.substring(1, trimmed.length - 1) - } else { - trimmed - } - } - requiredList.addAll(fieldNames) - Log.d(TAG, "解析到required字段: $requiredList") - } - } - } catch (e: Exception) { - Log.e(TAG, "解析Schema字符串失败: ${e.message}", e) - Log.e(TAG, "原始Schema: $schemaStr") - } - - val functionMap = mapOf( - "name" to tool.name(), - "description" to (tool.description() ?: ""), - "parameters" to mapOf( - "type" to "object", - "properties" to propertiesMap, - "required" to requiredList - ) - ) - - toolMaps.add(mapOf( - "type" to "function", - "function" to functionMap - )) - - Log.d(TAG, "成功转换工具 ${tool.name()} 到OpenAI函数格式 $functionMap") - } catch (e: Exception) { - Log.e(TAG, "转换工具 ${tool.name()} 失败: ${e.message}") - } - } - } catch (e: Exception) { - Log.e(TAG, "更新工具映射失败: ${e.message}") - } - - Log.d(TAG, "更新工具映射完成,共 ${toolMaps.size} 个工具") - } - - private fun extractPropertiesContent(schemaStr: String): String? { - val startKey = "properties={" - val startIndex = schemaStr.indexOf(startKey) - if (startIndex == -1) return null - - var braceCount = 0 - var endIndex = -1 - val contentStart = startIndex + startKey.length - - for (i in contentStart until schemaStr.length) { - when (schemaStr[i]) { - '{' -> braceCount++ - '}' -> { - if (braceCount == 0) { - endIndex = i - break - } else { - braceCount-- - } - } - } - } - - return if (endIndex != -1) schemaStr.substring(contentStart, endIndex) else null - } - - /** - * 将JsonNode转换为Map - */ - private fun convertJsonNodeToMap(node: JsonNode): Any { - return when { - node.isTextual() -> node.asText() - node.isNumber() -> node.asDouble() - node.isBoolean() -> node.asBoolean() - node.isObject() -> { - val result = mutableMapOf() - node.fieldNames().forEach { fieldName -> - result[fieldName] = convertJsonNodeToMap(node.get(fieldName)) - } - result - } - node.isArray() -> { - val result = mutableListOf() - for (i in 0 until node.size()) { - result.add(convertJsonNodeToMap(node.get(i))) - } - result - } - node.isNull() -> "null" - else -> node.toString() - } - } - - /** - * 获取工具Map列表,用于OpenAI请求 - */ - fun getToolMaps(): List> { - return toolMaps - } - - /** - * 获取可用的工具列表 - */ - fun getTools(): List { - return tools - } - - /** - * 检查是否包含指定工具 - */ - fun containsTool(name: String): Boolean { - return toolMapping.containsKey(name) - } - - /** - * 调用工具 - */ - suspend fun callTool(name: String, arguments: Map): JSONObject? { - - // 先检查连接状态 - if (!checkConnection()) { - Log.e(TAG, "无法连接到服务器,工具调用失败") - return JSONObject().apply { put("context", "无法连接到MCP服务器") } - } - - if (!containsTool(name)) { - Log.w(TAG, "此客户端不包含工具: $name") - return null - } - - return try { - val result = mcp.callTool(name = name, arguments = arguments) - - // 处理工具调用结果 - val context = result?.content?.joinToString("\n") { content -> - when (content) { - is TextContent -> content.text ?: "" - else -> "" - } - } - JSONObject().apply { - put("name", name) - put("meta", result?._meta) - put("context", context) - } - } catch (e: Exception) { - Log.e(TAG, "调用工具失败: ${e.message}") - isConnected = false - JSONObject().apply { put("context", "调用工具失败: ${e.message}") } - } - } - - /** - * 设置网络状态监听器 - */ - fun setNetworkStateListener(listener: NetworkStateListener) { - this.networkStateListener = listener - } - - /** - * 检查连接状态并自动重连 - */ - suspend fun checkConnection(): Boolean { - if (!isConnected) { - Log.d(TAG, "当前未连接,尝试重新连接...") - return connect() - } - - try { - // 简单ping测试连接状态 - mcp.ping() - return true - } catch (e: Exception) { - Log.e(TAG, "连接检查失败: ${e.message}") - isConnected = false - return false - } - } - /** - * 检查是否已连接 - */ - fun isConnected(): Boolean { - return isConnected - } - /** - * 处理连接错误并尝试重连 - */ - private suspend fun handleConnectionError(e: Exception) { - isConnected = false - stopHeartbeat() - - if (retryCount < maxRetryAttempts) { - retryCount++ - currentReconnectDelay = minOf(currentReconnectDelay * 2, maxReconnectDelay) - - // Log.w(TAG, "连接失败,将在 ${currentReconnectDelay}ms 后尝试重连 (尝试 $retryCount/$maxRetryAttempts)") - - delay(currentReconnectDelay) - connect() - } else { - Log.e(TAG, "已达到最大重试次数($maxRetryAttempts),停止重连") - // 可以在这里添加通知或回调,告知上层连接彻底失败 - } - } - - /** - * 启动心跳检测 - */ - private fun startHeartbeat() { - heartbeatJob?.cancel() - heartbeatJob = CoroutineScope(Dispatchers.IO).launch { - while (isActive && isConnected) { - try { - delay(heartbeatInterval) - - // 发送心跳请求 - val startTime = System.currentTimeMillis() - val response = mcp.ping() - val latency = System.currentTimeMillis() - startTime - - Log.d(TAG, "心跳检测成功,延迟: ${latency}ms") - } catch (e: Exception) { - Log.e(TAG, "心跳检测失败: ${e.message}") - handleConnectionError(e) - break - } - } - } - } - - /** - * 停止心跳检测 - */ - private fun stopHeartbeat() { - heartbeatJob?.cancel() - heartbeatJob = null - } - - /** - * 关闭连接 - */ - override fun close() { - runBlocking { - connectionLock.withLock { - try { - isConnected = false - stopHeartbeat() - mcp.close() - sseTransport?.close() - Log.d(TAG, "已关闭MCP服务器连接") - } catch (e: Exception) { - Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) - } - } - } - } - - /** - * 网络状态监听接口 - */ - interface NetworkStateListener { - fun onNetworkAvailable() - fun onNetworkLost() - } -} - -/** - * MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 - */ -class MCPClient(private val context: Context? = null) : AutoCloseable { - private val TAG = "MCPClient" - - // 本地函数Map,函数名 -> 处理器 - private val localFunctions = mutableMapOf() - - // 本地函数定义Map,函数名 -> 定义 - private val localFunctionDefs = mutableMapOf() - - // 子客户端列表,每个连接一个MCP服务器 - private val subClients = mutableMapOf() - - // 是否已连接 - private var isConnected = false - - // 系统功能处理器 - private val systemFunctionHandler = SystemFunctionHandler(context!!) - - // 共享的HttpClient,用于所有子客户端 - private val sharedHttpClient by lazy { createSslTrustAllClient() } - - init { - // 如果提供了Context,自动注册语音功能处理器 - context?.let { initializeVoiceFunctions(it) } - } - - /** - * 初始化语音功能处理器 - */ - private fun initializeVoiceFunctions(context: Context) { - try { - // 使用SystemFunctionHandler注册所有功能 - systemFunctionHandler.registerAllFunctions(this) - - Log.d(TAG, "语音功能初始化完成") - } catch (e: Exception) { - Log.e(TAG, "语音功能初始化失败: ${e.message}", e) - } - } - - /** - * 连接到MCP服务器(通过SSE) - * 直接接收完整的JSON配置字符串 - * - * @param mcpConfigJson 包含mcpServers字段的JSON配置字符串 - * @return 是否连接成功 - */ - suspend fun connectToSSE(mcpConfigJson: String): Boolean { - // 清除现有连接 - closeAllConnections() - - val serverMap = mutableMapOf() - var configLoaded = false - - // 尝试解析传入的JSON配置 - try { - if (mcpConfigJson.isNotEmpty()) { - val mapper = ObjectMapper() - try { - val config = mapper.readValue(mcpConfigJson, Map::class.java) - - // 处理mcpServers格式 - @Suppress("UNCHECKED_CAST") - val mcpServers = config["mcpServers"] as? Map> - if (mcpServers != null && mcpServers.isNotEmpty()) { - // 从服务器配置中提取URL - mcpServers.forEach { (serverId, serverConfig) -> - val url = serverConfig["url"] - if (!url.isNullOrEmpty()) { - serverMap[serverId] = url - Log.d(TAG, "从配置中添加服务器 [$serverId]: $url") - } - } - configLoaded = serverMap.isNotEmpty() - Log.d(TAG, "从传入的JSON配置加载了 ${serverMap.size} 个服务器") - } - } catch (e: Exception) { - Log.e(TAG, "解析传入的JSON配置失败: ${e.message}") - } - } - } catch (e: Exception) { - Log.e(TAG, "处理传入的配置失败: ${e.message}") - } - - - // 创建并连接所有子客户端 - val connectionResults = mutableMapOf() - - for ((serverId, serverUrl) in serverMap) { - try { - Log.d(TAG, "为服务器 [$serverId],[$serverUrl] 创建子客户端") - val subClient = MCPSubClient(serverId, serverUrl, sharedHttpClient) - val connected = subClient.connect() - - if (connected) { - subClients[serverId] = subClient - connectionResults[serverId] = true - Log.d(TAG, "子客户端 [$serverId] 连接成功") - } else { - subClient.close() - connectionResults[serverId] = false - Log.w(TAG, "子客户端 [$serverId] 连接失败") - } - } catch (e: Exception) { - Log.e(TAG, "创建子客户端 [$serverId] 失败: ${e.message}", e) - connectionResults[serverId] = false - } - } - - // 检查是否有任何客户端连接成功 - val successCount = connectionResults.count { it.value } - isConnected = successCount > 0 - - if (isConnected) { - Log.d(TAG, "已成功连接 $successCount/${serverMap.size} 个MCP服务器") - - // 记录所有可用工具 - val allToolNames = getAllToolNames() - Log.d(TAG, "所有可用工具: ${allToolNames.joinToString(", ")}") - - return true - } else { - Log.e(TAG, "所有MCP服务器连接均失败") - return false - } - } - - /** - * 关闭所有连接 - */ - private fun closeAllConnections() { - subClients.forEach { (serverId, client) -> - try { - client.close() - Log.d(TAG, "已关闭子客户端 [$serverId]") - } catch (e: Exception) { - Log.e(TAG, "关闭子客户端 [$serverId] 失败: ${e.message}") - } - } - subClients.clear() - } - - /** - * 获取所有工具名称 - */ - private fun getAllToolNames(): List { - val toolNames = mutableSetOf() - - // 添加本地函数 - toolNames.addAll(localFunctions.keys) - - // 添加所有子客户端的工具 - subClients.forEach { (_, client) -> - client.getTools().forEach { toolUnion -> - val tool = toolUnion.tool().get() - toolNames.add(tool.name()) - } - } - - return toolNames.toList() - } - - /** - * 注册本地函数 - * @param name 函数名称 - * @param description 函数描述 - * @param parameters 函数参数定义(Map或JSONObject) - * @param handler 函数处理器 - * @return 是否注册成功 - */ - fun registerLocalFunction( - name: String, - description: String, - parameters: Any, - handler: FunctionHandler - ): Boolean { - try { - // 将参数统一转换为Map格式 - val parametersMap: Map = when (parameters) { - is Map<*, *> -> { - @Suppress("UNCHECKED_CAST") - parameters as Map - } - is JSONObject -> { - val mapper = ObjectMapper() - mapper.readValue( - parameters.toString(), - object : TypeReference>() {} - ) - } - else -> { - Log.e(TAG, "参数类型不支持: ${parameters.javaClass.name}") - return false - } - } - - // 检查参数是否包含必要字段 - if (!parametersMap.containsKey("type") || (parametersMap["type"] != "object")) { - Log.e(TAG, "参数必须是object类型") - return false - } - - // 注册函数处理器 - localFunctions[name] = handler - - // 创建函数定义 - val functionDef = mapOf( - "name" to name, - "description" to description, - "parameters" to parametersMap - ) - - // 保存函数定义 - localFunctionDefs[name] = ObjectMapper().writeValueAsString(functionDef) - - // Log.d(TAG, "注册本地函数成功: $name") - return true - } catch (e: Exception) { - Log.e(TAG, "注册本地函数失败: ${e.message}", e) - return false - } - } - - /** - * 取消注册本地函数 - * @param name 函数名称 - * @return 是否取消成功 - */ - fun unregisterLocalFunction(name: String): Boolean { - val removed = localFunctions.remove(name) != null - if (removed) { - localFunctionDefs.remove(name) - Log.d(TAG, "取消注册本地函数成功: $name") - } - return removed - } - - /** - * 获取工具Map列表,用于OpenAI请求 - */ - fun getToolMaps(): List> { - val allToolMaps = mutableListOf>() - - // 首先添加本地函数 - localFunctionDefs.forEach { (name, functionDefJson) -> - try { - val mapper = ObjectMapper() - val functionDef = mapper.readValue(functionDefJson, object : TypeReference>() {}) - allToolMaps.add(mapOf( - "type" to "function", - "function" to functionDef - )) - } catch (e: Exception) { - Log.e(TAG, "解析本地函数定义失败: $name, ${e.message}") - } - } - - // 然后添加所有子客户端的工具 - subClients.forEach { (serverId, client) -> - val clientTools = client.getToolMaps() - allToolMaps.addAll(clientTools) - // Log.d(TAG, "添加子客户端 [$serverId] 的 ${clientTools.size} 个工具") - } - - return allToolMaps - } - - /** - * 获取工具类型 - * @param name 工具名称 - * @return 工具类型,如果不存在则返回null - */ - fun getToolType(name: String): ToolType? { - if (localFunctions.containsKey(name)) { - return ToolType.LOCAL_FUNCTION - } - - if (subClients.any { it.value.containsTool(name) }) { - return ToolType.MCP_TOOL - } - - return null - } - - /** - * 调用工具(统一入口) - * @param name 工具名称 - * @param arguments 参数 - * @return 调用结果 - */ - suspend fun callTool(name: String, arguments: Map): JSONObject? { - val requestId = "${System.currentTimeMillis()}-${(0..1000).random()}" - Log.d(TAG, "[$requestId] 调用工具: $name 参数:$arguments") - - val toolType = getToolType(name) - - val result = when (toolType) { - ToolType.LOCAL_FUNCTION -> { - callLocalFunction(name, arguments) - } - ToolType.MCP_TOOL -> { - callMcpTool(name, arguments, requestId) - } - null -> { - Log.e(TAG, "未找到工具: $name") - JSONObject().apply { put("context","未找到工具: $name") } - } - } - - return result - } - - /** - * 调用本地函数 - */ - private suspend fun callLocalFunction(name: String, arguments: Map): JSONObject? { - return try { - val handler = localFunctions[name] - if (handler != null) { - val context = handler.handle(arguments) - JSONObject().apply { put("context", context) } - } else { - Log.e(TAG, "未找到本地函数: $name") - JSONObject().apply { put("context","未找到本地函数: $name") } - } - } catch (e: Exception) { - Log.e(TAG, "调用本地函数失败: ${e.message}") - JSONObject().apply { put("context","调用本地函数失败: ${e.message}") } - } - } - - /** - * 调用MCP工具 - */ - private suspend fun callMcpTool(name: String, arguments: Map, requestId: String): JSONObject? { - if (!isConnected) { - Log.e(TAG, "未连接到服务器") - return JSONObject().apply { put("context","未连接到MCP服务器") } - } - - // 查找包含此工具的子客户端 - val clientEntry = subClients.entries.find { it.value.containsTool(name) } - - return if (clientEntry != null) { - val (serverId, client) = clientEntry - try { - val startTime = System.currentTimeMillis() - val result = client.callTool(name, arguments) - val duration = System.currentTimeMillis() - startTime - - Log.d(TAG, "[$requestId] 工具调用成功: $name (${duration}ms)") - result - } catch (e: Exception) { - Log.e(TAG, "[$requestId] 工具调用失败: $name, ${e.message}") - JSONObject().apply { put("context", "工具调用失败: ${e.message}") } - } - } else { - Log.e(TAG, "[$requestId] 无可用服务器提供工具: $name") - JSONObject().apply { put("context", "无可用的服务器提供工具: $name") } - } - } - - /** - * 获取可用的工具列表 - */ - fun getTools(): List { - val allTools = mutableListOf() - - // 收集所有子客户端的工具 - subClients.forEach { (_, client) -> - allTools.addAll(client.getTools()) - } - - return allTools - } - - /** - * 检查工具是否存在 - */ - fun hasToolWithName(name: String): Boolean { - return localFunctions.containsKey(name) || - subClients.any { it.value.containsTool(name) } - } - - /** - * 从字符串解析JSON参数 - */ - fun parseJsonArguments(json: String): Map { - return try { - ObjectMapper().readValue(json, object : TypeReference>() {}) - } catch (e: Exception) { - Log.e(TAG, "解析JSON参数失败: ${e.message}", e) - emptyMap() - } - } - - /** - * 检查是否已连接到MCP服务器 - */ - fun isConnected(): Boolean { - return isConnected - } - - /** - * 关闭连接 - */ - override fun close() { - runBlocking { - try { - closeAllConnections() - isConnected = false - Log.d(TAG, "已关闭所有MCP服务器连接") - } catch (e: Exception) { - Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) - } - } - } -} - -/** - * 创建一个信任所有SSL证书的HttpClient - */ -private fun createSslTrustAllClient(): HttpClient { - // 创建信任所有证书的TrustManager - val trustAllCerts = arrayOf( - object : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) {} - override fun checkServerTrusted(chain: Array?, authType: String?) {} - override fun getAcceptedIssuers(): Array = arrayOf() - } - ) - - // 创建SSL上下文并初始化它 - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(null, trustAllCerts, SecureRandom()) - - // 创建HTTP日志拦截器 - val loggingInterceptor = HttpLoggingInterceptor { message -> - Log.d("OkHttp", message) - }.apply { - level = HttpLoggingInterceptor.Level.HEADERS - } - - // 创建自定义拦截器显示实际请求URL - val urlLoggingInterceptor = Interceptor { chain -> - val request = chain.request() - Log.d("OkHttp-URL", "实际请求URL: ${request.url}") - Log.d("OkHttp-Headers", "请求头: ${request.headers}") - - // 生成curl命令用于调试 - logAsCurlCommand(request) - - val response = chain.proceed(request) - - Log.d("OkHttp-Response", "状态码: ${response.code} - ${response.message}") - Log.d("OkHttp-Response", "响应头: ${response.headers}") - - response - } - - // 创建OkHttpClient并配置信任所有证书 - val okHttpClient = OkHttpClient.Builder() - .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) - .hostnameVerifier { _, _ -> true } - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - // .addInterceptor(loggingInterceptor) // 添加HTTP日志拦截器 - // .addInterceptor(urlLoggingInterceptor) // 添加URL日志拦截器 - .build() - - // 创建使用OkHttp引擎的HttpClient - return HttpClient(OkHttp) { - engine { - preconfigured = okHttpClient - } - - // 安装SSE插件 - install(SSE) - } -} - -/** - * 将OkHttp请求转换为curl命令并输出到日志 - */ -private fun logAsCurlCommand(request: okhttp3.Request) { - try { - val curlCmd = StringBuilder("curl -X ${request.method}") - - // 添加URL - curlCmd.append(" \"${request.url}\"") - - // 添加请求头 - request.headers.forEach { (name, value) -> - curlCmd.append(" \\\n -H \"$name: $value\"") - } - - // 添加请求体 - val requestBody = request.body - if (requestBody != null) { - val buffer = okio.Buffer() - requestBody.writeTo(buffer) - val bodyContent = buffer.readUtf8() - - // 请求体不为空时添加 - if (bodyContent.isNotEmpty()) { - curlCmd.append(" \\\n -d '${bodyContent.replace("'", "\\'")}'") - } - } - - // 输出到日志 - Log.d("CURL-Command", curlCmd.toString()) - } catch (e: Exception) { - Log.e("CURL-Command", "生成curl命令失败: ${e.message}", e) - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt deleted file mode 100644 index b04b9a0a..00000000 --- a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt +++ /dev/null @@ -1,929 +0,0 @@ -package com.yunqiinnovation.open_ai_service - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.os.Handler -import android.os.Looper -import android.util.Base64 -import android.util.Log -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.ObjectMapper -import kotlinx.coroutines.* -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.RequestBody.Companion.toRequestBody -import okio.Buffer -import org.json.JSONArray -import org.json.JSONObject -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.IOException -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlin.math.min -import kotlin.math.sqrt - - -/** - * OpenAI服务的原生实现 - */ -class OpenAIService(private val context: Context? = null) : CoroutineScope { - private val TAG = "OpenAIService" - - /* ---------- 协程与并发 ---------- */ - - // 创建协程Job,用于生命周期管理 - private val job = SupervisorJob() - - // 实现CoroutineScope接口 - override val coroutineContext: CoroutineContext - get() = Dispatchers.IO + job - - // 主线程Handler,用于确保回调在主线程执行 - private val mainHandler = Handler(Looper.getMainLooper()) - - private var baseUrl = "" - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private var apiKey: String = "" - private var isInitialized = false - private var model: String = "" // 默认模型 - private var visionModel: String = "doubao-1-5-vision-pro-32k-250115" // 默认模型 - - // MCP客户端 - private var mcpClient: MCPClient? = null - private var isMcpInitialized = false - - // 是否自动处理MCP工具调用 - private var autoHandleMcpTools = true - - // 添加成员变量以跟踪当前请求 - private var currentStreamCall: Call? = null - @Volatile private var isCanceled = false - - /** - * 构建curl命令用于测试 - */ - private fun buildCurlCommand(request: Request, body: String): String { - val command = StringBuilder("curl -v -X ${request.method}") - - // 添加请求头 - request.headers.forEach { header -> - // 敏感信息处理:不显示真实的API Key - if (header.first == "Authorization") { - command.append(" -H '${header.first}: Bearer $apiKey'") - } else { - command.append(" -H '${header.first}: ${header.second}'") - } - } - - // 添加请求体 - if (request.method == "POST" || request.method == "PUT") { - // 转义JSON中的单引号,确保curl命令正确 - val escapedBody = body.replace("'", "\\'") - command.append(" -d '${escapedBody}'") - } - - // 添加URL - command.append(" '${request.url}'") - - return command.toString() - } - - /** - * 将文件转换为Base64字符串 - */ - fun fileToBase64(filePath: String, maxSizeKB: Int = 20480): String? { - try { - val file = File(filePath) - if (!file.exists() || !file.isFile) { - Log.e(TAG, "文件不存在: $filePath") - return null - } - - // 读取文件 - var bitmap = BitmapFactory.decodeFile(filePath) ?: return null - - // 检查图片尺寸,限制最大为1024*1024 - val maxDimension = 1024 - if (bitmap.width > maxDimension || bitmap.height > maxDimension) { - // 计算缩放比例,保持纵横比 - val ratio = min(maxDimension.toFloat() / bitmap.width, maxDimension.toFloat() / bitmap.height) - - val newWidth = (bitmap.width * ratio).toInt() - val newHeight = (bitmap.height * ratio).toInt() - - bitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) - } - - // 压缩图片 - val outputStream = ByteArrayOutputStream() - var quality = 100 - bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) - - // 如果原始图片太大,继续优化文件大小 - var fileSize = file.length() / 1024 // 转为KB - if (fileSize > maxSizeKB) { - val scale = sqrt(maxSizeKB.toDouble() / fileSize) - val newWidth = (bitmap.width * scale).toInt() - val newHeight = (bitmap.height * scale).toInt() - bitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) - outputStream.reset() - quality = 85 - bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) - } - - // 转为Base64 - return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) - } catch (e: Exception) { - Log.e(TAG, "转换Base64失败: ${e.message}") - return null - } - } - - /** - * 创建带图片的用户消息 - */ - fun createUserMessageWithImage(text: String, imageBase64: String): JSONObject { - return JSONObject().apply { - put("role", "user") - put("content", JSONArray().apply { - // 添加文本部分 - if (text.isNotEmpty()) { - put(JSONObject().apply { - put("type", "text") - put("text", text) - }) - } - - // 添加图片部分 - put(JSONObject().apply { - put("type", "image_url") - put("image_url", JSONObject().apply { - put("url", "data:image/jpeg;base64,$imageBase64") - }) - }) - }) - } - } - - /** - * 创建用户消息 - */ - fun createUserMessage(content: String): JSONObject { - return JSONObject().apply { - put("role", "user") - put("content", content) - } - } - - /** - * 创建系统消息 - */ - fun createSystemMessage(content: String): JSONObject { - return JSONObject().apply { - put("role", "system") - put("content", content) - } - } - - /** - * 创建助手消息 - */ - fun createAssistantMessage(content: String): JSONObject { - return JSONObject().apply { - put("role", "assistant") - put("content", content) - } - } - - /** - * 初始化OpenAI服务 - */ - fun initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String): Boolean { - this.apiKey = apiKey - if (baseUrl.isNotEmpty()) { - this.baseUrl = baseUrl - } - if (model.isNotEmpty()) { - this.model = model - } - - // 初始化MCPClient - if (mcpClient == null) { - mcpClient = MCPClient(context) - } - initializeMcpClient(mcpServer) - isInitialized = apiKey.isNotEmpty() - return isInitialized - } - - /** - * MCP客户端是否已初始化 - */ - fun isMcpInitialized(): Boolean { - return isMcpInitialized && mcpClient?.isConnected() == true - } - - /** - * 关闭MCP客户端 - */ - fun closeMcpClient() { - mcpClient?.close() - mcpClient = null - isMcpInitialized = false - } - - /** - * 初始化MCP客户端 - */ - fun initializeMcpClient(mcpServer: String): Boolean { - if (mcpClient != null) { - mcpClient?.close() - } - - mcpClient = MCPClient(context) - - // 直接使用类的CoroutineScope启动协程 - launch { - try { - val result = mcpClient?.connectToSSE(mcpServer) ?: false - isMcpInitialized = result - Log.d(TAG, "MCP客户端初始化${if (result) "成功" else "失败"}") - } catch (e: Exception) { - Log.e(TAG, "MCP客户端初始化失败: ${e.message}", e) - isMcpInitialized = false - } - } - - return true // 立即返回,实际连接在后台进行 - } - - /** - * 处理MCP工具调用 - * - * @param functionCall 函数调用JSON对象 - * @return 处理结果,如果处理失败则返回null - */ - suspend fun handleMcpToolCall(functionCall: JSONObject): JSONObject? { - if (mcpClient == null || !isMcpInitialized) { - return JSONObject().apply { put("context","MCP客户端未初始化") } - } - - try { - // 获取函数名称 - val name = functionCall.getString("name") - - // 获取参数 - val argumentsJson = functionCall.getString("arguments") - val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf() - - // 调用工具 - return mcpClient?.callTool(name, arguments) - } catch (e: Exception) { - Log.e(TAG, "处理MCP工具调用失败: ${e.message}", e) - return JSONObject().apply { put("context","处理MCP工具调用失败: ${e.message}") } - } - } - - /** - * 注册函数 - */ - fun registerFunction(name: String, description: String, parameters: JSONObject): Boolean { - try { - // 确保MCPClient已初始化 - if (mcpClient == null) { - mcpClient = MCPClient() - } - - // 创建函数处理器 - val handler = object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - // 由于本地函数的实际处理是在Flutter端完成的 - // 这里只需返回一个标记,表示该函数是本地函数 - return "LOCAL_FUNCTION:$name" - } - } - - // 直接委托给MCPClient处理 - return mcpClient?.registerLocalFunction(name, description, parameters, handler) ?: false - } catch (e: Exception) { - Log.e(TAG, "注册函数失败: ${e.message}", e) - return false - } - } - - /** - * 自动处理MCP工具调用 - */ - private suspend fun autoHandleMcpToolCall(functionCall: JSONObject, messages: JSONArray, callback: StreamCallback) { - try { - // 获取函数名称 - val name = functionCall.getString("name") - - // 获取参数 - val argumentsJson = functionCall.getString("arguments") - - Log.d(TAG, "自动处理MCP工具调用: name=${name}, arguments=${argumentsJson}") - val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf() - - // 调用工具(统一通过MCPClient处理) - val result = mcpClient?.callTool(name, arguments) - Log.d(TAG, "自动处理MCP工具调用结果: ${result?.toString()}") - // 检查是否已取消 - if (isCanceled) { - Log.d(TAG, "工具调用已被取消,不处理结果") - return - } - - // 回调结果 - val resultObj = result ?: JSONObject().apply { put("context", "工具调用失败") } - callback.onFunctionCallResult(functionCall, resultObj) - - // 发送结果到OpenAI继续对话 - val resultContent = resultObj.optString("context", "工具调用失败") - sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = resultContent, - callback = callback - ) - } catch (e: Exception) { - if (!isCanceled) { - Log.e(TAG, "自动处理工具调用失败: ${e.message}", e) - // 失败时返回错误给回调函数 - val errorMessage = "工具调用失败: ${e.message}" - sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = errorMessage, - callback = callback - ) - } - } - } - - /** - * 发送消息(非流式输出) - */ - @Throws(OpenAIException::class) - fun sendMessage(messages: JSONArray): String { - if (!isInitialized || apiKey.isEmpty()) { - throw OpenAIException("OpenAI服务未初始化") - } - - val requestBody = JSONObject().apply { - put("model", model) - put("messages", messages) - put("temperature", 0.7) - put("max_tokens", 2000) - put("stream", false) - - // 添加工具列表 - val tools = JSONArray() - - // 使用MCPClient提供的所有工具 - mcpClient?.getToolMaps()?.forEach { toolMap -> - try { - val tool = JSONObject(toolMap) - tools.put(tool) - } catch (e: Exception) { - Log.e(TAG, "转换工具失败: ${e.message}", e) - } - } - - // 如果有工具,则添加到请求中 - if (tools.length() > 0) { - put("tools", tools) - } - } - - val mediaType = "application/json".toMediaTypeOrNull() - val request = Request.Builder() - .url(baseUrl) - .addHeader("Content-Type", "application/json") - .addHeader("Authorization", "Bearer $apiKey") - .post(requestBody.toString().toRequestBody(mediaType)) - .build() - - try { - // 输出用于测试的curl命令 - // val curlCommand = buildCurlCommand(request, requestBody.toString()) - // Log.d(TAG, "curl command: \n$curlCommand") - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw OpenAIException("API调用失败: ${response.code}") - } - - val responseBody = response.body?.string() ?: throw OpenAIException("Empty response") - val jsonResponse = JSONObject(responseBody) - - // 检查是否有函数调用 - if (jsonResponse.has("choices") && - jsonResponse.getJSONArray("choices").length() > 0) { - - val choice = jsonResponse.getJSONArray("choices").getJSONObject(0) - - // 检查是否是函数调用 - if (choice.has("message")) { - val message = choice.getJSONObject("message") - - // 检查是否有工具调用 - if (message.has("tool_calls")) { - val toolCalls = message.getJSONArray("tool_calls") - if (toolCalls.length() > 0) { - val toolCall = toolCalls.getJSONObject(0) - if (toolCall.has("function")) { - val function = toolCall.getJSONObject("function") - val functionCall = JSONObject().apply { - put("name", function.getString("name")) - put("arguments", function.getString("arguments")) - put("id", toolCall.getString("id")) - } - return functionCall.toString() - } - } - } - - // 如果没有工具调用,返回消息内容 - if (message.has("content")) { - return message.getString("content") - } - } - } - - throw OpenAIException("Invalid response format") - } - } catch (e: Exception) { - if (e is OpenAIException) throw e - throw OpenAIException("Failed to communicate with AI service: ${e.message}") - } - } - - /** - * 发送消息(流式输出) - */ - fun sendMessageStream(messages: JSONArray, callback: StreamCallback) { - if (!isInitialized || apiKey.isEmpty()) { - callback.onError(OpenAIException("OpenAI服务未初始化")) - return - } - - // 重置取消状态 - isCanceled = false - - // 检查最后一条消息内容中是否包含 "image_url",决定使用哪个模型 - var currentModel = model - if (messages.length() > 0) { - val lastMessage = messages.getJSONObject(messages.length() - 1) - if (lastMessage.has("content")) { - val content = lastMessage.optString("content", "") - if (content.contains("image_url")) { - currentModel = visionModel - } - } - } - - val requestBody = JSONObject().apply { - put("model", currentModel) - put("messages", messages) - put("temperature", 0.7) - put("max_tokens", 2000) - put("stream", true) - - // 添加工具列表 - val tools = JSONArray() - - // 使用MCPClient提供的所有工具 - mcpClient?.getToolMaps()?.forEach { toolMap -> - try { - val tool = JSONObject(toolMap) - tools.put(tool) - } catch (e: Exception) { - Log.e(TAG, "转换工具失败: ${e.message}", e) - } - } - - // 如果有工具,则添加到请求中 - if (tools.length() > 0) { - put("tools", tools) - } - Log.i(TAG, "liwei------------------- 请求Ai对话: context:${messages.getJSONObject(messages.length() - 1).get("content").toString()} 携带工具数据 ${tools.length()}") - } - - val mediaType = "application/json".toMediaTypeOrNull() - val request = Request.Builder() - .url(baseUrl) - .addHeader("Content-Type", "application/json") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Accept", "text/event-stream") - .post(requestBody.toString().toRequestBody(mediaType)) - .build() - - val call = client.newCall(request) - // 保存当前Call对象以便后续取消 - currentStreamCall = call - // Log.d(TAG, "发送消息流式输出: request=$request") - call.enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.d(TAG, "发送消息流式输出失败: e=$e") - // 如果是由于取消导致的失败,不报告错误 - if (isCanceled) { - return - } - callback.onError(OpenAIException(e.message ?: "请求失败")) - } - - override fun onResponse(call: Call, response: Response) { - // Log.d(TAG, "发送消息流式输出成功: response=$response") - // 检查请求是否被取消 - if (isCanceled) { - response.body?.close() - return - } - - if (!response.isSuccessful) { - callback.onError(OpenAIException("API调用失败: ${response.code}")) - return - } - - val responseBody = response.body ?: return - val source = responseBody.source() - val buffer = Buffer() - - try { - // 用于存储函数调用的各个部分 - val toolCalls = mutableMapOf() - - // 逐块读取数据,而不是一次性预取 - val BUFFER_SIZE = 8192L // 8KB的块大小 - - while (!Thread.currentThread().isInterrupted && !isCanceled) { - // 读取一小块数据 - val bytesRead = source.read(buffer, BUFFER_SIZE) - if (bytesRead == -1L) break // 流结束 - - // 处理接收到的数据 - while (true) { - val line = buffer.readUtf8Line() ?: break - if (line.trim().isEmpty()) continue - - if (line.startsWith("data:")) { - val data = line.substring(5).trim() - - // 处理[DONE]消息 - if (data == "[DONE]" || data == "[\"DONE\"]") { - val hasToolCalls = processToolCalls(toolCalls, callback, messages) - // 如果没有工具调用,这是对话的真正结束 - if (!hasToolCalls) { - callback.onComplete() - } - return - } - - // 验证JSON是否完整(至少检查大括号匹配) - if (isValidJson(data)) { - try { - handleStreamJson(JSONObject(data), toolCalls, callback, messages) - } catch (e: Exception) { - // 忽略解析错误,但记录日志 - Log.e(TAG, "解析JSON出错: ${e.message}", e) - } - } else { - // 记录不完整的JSON,但不尝试解析它 - Log.d(TAG, "收到不完整的JSON数据,跳过处理, data=$data") - } - } - } - } - - // 如果是因为取消导致循环结束,直接返回 - if (isCanceled) { - return - } - - // 如果没有明确的完成信号但数据流结束了,处理最后可能的工具调用 - val hasToolCalls = processToolCalls(toolCalls, callback, messages) - if (!hasToolCalls) { - callback.onComplete() - } - } catch (e: Exception) { - // 如果是由于取消导致的异常,不报告错误 - if (isCanceled) { - return - } - callback.onError(OpenAIException("处理响应流时出错: ${e.message}")) - } finally { - responseBody.close() - } - } - }) - } - - /** - * 处理流式JSON数据 - */ - private fun handleStreamJson( - json: JSONObject, - toolCalls: MutableMap, - callback: StreamCallback, - messages: JSONArray - ) { - if (json.has("choices") && json.getJSONArray("choices").length() > 0) { - val choice = json.getJSONArray("choices").getJSONObject(0) - - // 处理delta内容 - if (choice.has("delta")) { - val delta = choice.getJSONObject("delta") - - // 处理普通文本内容 - if (delta.has("content")) { - val content = delta.getString("content") - // Log.d(TAG, "处理流式JSON数据: content=$content") - if (!isCanceled) { - callback.onToken(content) - } - } - - // 收集工具调用信息 - if (delta.has("tool_calls")) { - val toolCallsArray = delta.getJSONArray("tool_calls") - for (i in 0 until toolCallsArray.length()) { - val toolCall = toolCallsArray.getJSONObject(i) - val index = toolCall.getInt("index") - - // 创建或获取现有的工具调用信息 - val toolCallInfo = toolCalls.getOrPut(index) { ToolCallInfo() } - - // 更新ID - if (toolCall.has("id")) { - toolCallInfo.id = toolCall.getString("id") - } - - // 更新函数信息 - if (toolCall.has("function")) { - val function = toolCall.getJSONObject("function") - - if (function.has("name")) { - toolCallInfo.name = function.getString("name") - } - - if (function.has("arguments")) { - toolCallInfo.arguments += function.getString("arguments") - } - } - } - Log.i(TAG, "liwei---------------------- AI发起工具调用: tool=$toolCallsArray") - } - } - - } - } - - /** - * 处理工具调用 - */ - private fun processToolCalls( - toolCalls: Map, - callback: StreamCallback, - messages: JSONArray? = null - ): Boolean { - if (toolCalls.isEmpty()) return false - - // 只处理第一个工具调用 - val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return false - - if (firstToolCall.isValid()) { - // 创建函数调用JSON对象 - val functionCall = JSONObject().apply { - put("name", firstToolCall.name) - put("arguments", firstToolCall.arguments) - put("id", firstToolCall.id) - } - Log.i(TAG, "liwei---------------------- 工具调用: id=${firstToolCall.id}, name=${firstToolCall.name} arguments=${firstToolCall.arguments}") - - // 通知上层工具调用事件 - callback.onFunctionCall(functionCall) - - // 在协程中处理工具调用 - if (messages != null) { - launch { - try { - if (!isCanceled) { - // 调用工具 - val result = try { - val args = mcpClient?.parseJsonArguments(firstToolCall.arguments) ?: mapOf() - mcpClient?.callTool(firstToolCall.name, args) - } catch (e: Exception) { - Log.e(TAG, "liwei----------------------- 工具调用错误: $e") - JSONObject().put("context", "工具调用失败: ${e.message}") - } - - if (!isCanceled) { - - // 处理结果 - val resultObj = result ?: JSONObject().put("context", "工具调用失败") - callback.onFunctionCallResult(functionCall, resultObj) - - val context = resultObj.optString("context", "工具调用失败").let { - if (it.isNullOrEmpty()) "工具调用失败" else it - } - Log.i(TAG, "liwei----------------------- mcp工具调用完成: name=${firstToolCall.name}, context=$context") - - // 将结果发送回OpenAI继续对话 - sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = context, - callback = callback - ) - } - } - } catch (e: Exception) { - if (!isCanceled) { - Log.e(TAG, "liwei----------------------- 处理工具调用异常: $e") - try { - val errorMessage = "工具调用处理失败: ${e.message}" - sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = errorMessage, - callback = callback - ) - } catch (e2: Exception) { - Log.e(TAG, "发送工具调用结果失败: ${e2.message}") - } - } - } - } - } - return true - } - return false - } - - /** - * 发送函数调用结果 - */ - fun sendFunctionCallResult( - messages: JSONArray, - functionCall: JSONObject, - functionResult: String, - callback: StreamCallback - ) { - if (isCanceled) { - Log.d(TAG, "请求已取消,不发送函数调用结果") - return - } - - try { - val fullMessages = JSONArray() - - // 添加用户消息 - for (i in 0 until messages.length()) { - fullMessages.put(messages.getJSONObject(i)) - } - - // 添加函数调用消息 - val callId = functionCall.optString("id", "call_${System.currentTimeMillis()}") - fullMessages.put(JSONObject().apply { - put("role", "assistant") - put("content", "") - - // 添加工具调用 - val toolCalls = JSONArray().apply { - val toolCall = JSONObject().apply { - put("id", callId) - put("type", "function") - put("function", JSONObject().apply { - put("name", functionCall.getString("name")) - put("arguments", functionCall.getString("arguments")) - }) - } - put(toolCall) - } - put("tool_calls", toolCalls) - }) - - // 添加函数调用结果 - fullMessages.put(JSONObject().apply { - put("role", "tool") - put("content", functionResult) - put("tool_call_id", callId) - }) - - // 发送完整对话 - sendMessageStream(fullMessages, callback) - - } catch (e: Exception) { - if (!isCanceled) { - Log.e(TAG, "发送函数调用结果失败: ${e.message}", e) - callback.onError(OpenAIException("发送函数调用结果失败: ${e.message}")) - } - } - } - - /** - * 取消当前流式请求 - * - * @return 是否成功取消 - */ - fun cancelCurrentStream(): Boolean { - try { - // 标记已取消 - isCanceled = true - - // 取消当前Call对象 - val call = currentStreamCall - if (call != null && !call.isCanceled()) { - call.cancel() - Log.d(TAG, "已取消当前流式请求") - return true - } - return false - } catch (e: Exception) { - Log.e(TAG, "取消流式请求失败: ${e.message}", e) - return false - } finally { - // 清除当前Call引用 - currentStreamCall = null - } - } - - /** - * 取消所有操作并释放资源 - * 供AgentService.dispose()调用 - */ - fun cancelAll() { - cancelCurrentStream() - job.cancel() // 取消所有协程 - } - - /** - * 工具调用信息类 - */ - private class ToolCallInfo { - var id: String = "" - var name: String = "" - var arguments: String = "" - - fun isValid(): Boolean { - return id.isNotEmpty() && name.isNotEmpty() - } - } - - /** - * 流式输出回调接口 - */ - interface StreamCallback { - fun onToken(token: String) - fun onComplete() - fun onError(e: Exception) - fun onFunctionCall(functionCall: JSONObject) - fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) - } - - /** - * 检查字符串是否为有效的JSON - * 使用简单的括号匹配和引号检查来确定JSON是否完整 - */ - private fun isValidJson(jsonStr: String): Boolean { - if (jsonStr.isEmpty()) return false - if (!jsonStr.startsWith("{") || !jsonStr.endsWith("}")) return false - - var braceCount = 0 - var insideQuotes = false - var escapeNext = false - - for (char in jsonStr) { - if (escapeNext) { - escapeNext = false - continue - } - - when (char) { - '\\' -> escapeNext = true - '"' -> if (!escapeNext) insideQuotes = !insideQuotes - '{' -> if (!insideQuotes) braceCount++ - '}' -> if (!insideQuotes) braceCount-- - } - - // 如果括号不匹配(闭合太多),提前返回 - if (braceCount < 0) return false - } - - // 所有引号都应该闭合,所有括号也应该匹配 - return braceCount == 0 && !insideQuotes - } -} - -/** - * OpenAI服务异常 - */ -class OpenAIException(message: String) : Exception(message) \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt b/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt deleted file mode 100644 index 09a0d065..00000000 --- a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt +++ /dev/null @@ -1,272 +0,0 @@ -package com.yunqiinnovation.open_ai_service - -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.util.Log -import kotlinx.serialization.json.Json -import androidx.annotation.NonNull -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.EventChannel.EventSink -import io.flutter.plugin.common.EventChannel.StreamHandler -import org.json.JSONArray -import org.json.JSONObject -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.JsonObject -import kotlin.math.min - -/** OpenAIServicePlugin */ -class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { - /// 方法通道名称 - private val methodChannelName = "com.yunqiinnovation.open_ai_service/methods" - - /// 事件通道名称 - private val eventChannelName = "com.yunqiinnovation.open_ai_service/events" - - /// 方法通道 - private lateinit var methodChannel: MethodChannel - - /// 事件通道 - private lateinit var eventChannel: EventChannel - - /// 应用上下文 - private lateinit var context: Context - - /// OpenAI服务实例 - private lateinit var openAIService: OpenAIService - - /// 事件接收器(用于流式输出) - private var eventSink: EventSink? = null - - /// 执行器(用于真正需要在后台线程处理的任务) - private val executor = Executors.newSingleThreadExecutor() - - /// 主线程Handler - private val mainHandler = Handler(Looper.getMainLooper()) - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - // 保存上下文 - context = flutterPluginBinding.applicationContext - - // 初始化OpenAI服务 - openAIService = OpenAIService(context) - - // 初始化方法通道 - methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, methodChannelName) - methodChannel.setMethodCallHandler(this) - - // 初始化事件通道 - eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, eventChannelName) - eventChannel.setStreamHandler(this) - } - - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - when (call.method) { - "initialize" -> { - val apiKey = call.argument("apiKey") ?: "" - val baseUrl = call.argument("baseUrl") ?: "" - val model = call.argument("model") ?: "" - val mcpServer = call.argument("mcpServer") ?: "" - - val initialized = openAIService.initialize(apiKey, baseUrl, model, mcpServer) - result.success(initialized) - } - - - "sendMessageStream" -> { - val messagesRaw = call.argument>>("messages") ?: emptyList() - // 检查事件接收器 - if (eventSink == null) { - result.error("NO_EVENT_SINK", "没有可用的事件流接收器", null) - return - } - - // 转换消息格式 - val messages = JSONArray() - for (message in messagesRaw) { - messages.put(JSONObject(message)) - } - - // 不再在后台线程包装OkHttp的异步调用 - try { - openAIService.sendMessageStream( - messages = messages, - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - // 发送token事件 - mainHandler.post { - eventSink?.success(mapOf("type" to "token", "content" to token)) - } - } - - override fun onComplete() { - // 发送完成事件 - mainHandler.post { - eventSink?.success(mapOf("type" to "complete")) - } - } - - override fun onError(e: Exception) { - // 发送错误事件 - mainHandler.post { - eventSink?.success(mapOf("type" to "error", "content" to e.message)) - } - } - - override fun onFunctionCall(functionCall: JSONObject) { - mainHandler.post { - eventSink?.success(mapOf("type" to "function_call", "content" to functionCall.toString())) - } - } - - override fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) { - mainHandler.post { - eventSink?.success( - mapOf("type" to "function_call_result" - ,"function_call" to functionCall.toString() - , "result" to functionCallResult.toString())) - } - } - } - ) - - // 请求已开始,直接返回结果 - result.success(true) - } catch (e: Exception) { - // 直接在当前线程处理错误 - result.error("OPENAI_ERROR", e.message, null) - } - } - - - "createUserMessage" -> { - val content = call.argument("content") ?: "" - val message = openAIService.createUserMessage(content) - result.success(message.toMap()) - } - - "createAssistantMessage" -> { - val content = call.argument("content") ?: "" - val message = openAIService.createAssistantMessage(content) - result.success(message.toMap()) - } - - else -> { - result.notImplemented() - } - } - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel.setMethodCallHandler(null) - eventChannel.setStreamHandler(null) - executor.shutdown() - } - - // Stream事件处理 - override fun onListen(arguments: Any?, eventSink: EventSink?) { - this.eventSink = eventSink - } - - override fun onCancel(arguments: Any?) { - this.eventSink = null - } - - // 工具方法:JSONObject转Map - private fun JSONObject.toMap(): Map { - val map = mutableMapOf() - val keys = this.keys() - while (keys.hasNext()) { - val key = keys.next() - var value: Any? = this.opt(key) - - value = when (value) { - JSONObject.NULL -> null - is JSONObject -> value.toMap() - is JSONArray -> value.toList() - else -> value - } - - map[key] = value - } - return map - } - - // 工具方法:JSONArray转List - private fun JSONArray.toList(): List { - val list = mutableListOf() - for (i in 0 until this.length()) { - var value: Any? = this.opt(i) - - value = when (value) { - JSONObject.NULL -> null - is JSONObject -> value.toMap() - is JSONArray -> value.toList() - else -> value - } - - list.add(value) - } - return list - } - - private fun initializeMcpClient(call: MethodCall, result: Result) { - val serverUrl = call.argument("serverUrl") - if (serverUrl == null) { - result.error("INVALID_ARGUMENT", "Missing serverUrl", null) - return - } - - val success = openAIService.initializeMcpClient(serverUrl) - result.success(success) - } - - private fun isMcpInitialized(call: MethodCall, result: Result) { - result.success(openAIService.isMcpInitialized()) - } - - private fun closeMcpClient(call: MethodCall, result: Result) { - openAIService.closeMcpClient() - result.success(true) - } - - private fun handleMcpToolCall(call: MethodCall, result: Result) { - val functionCall = call.argument("functionCall") - if (functionCall == null) { - result.error("INVALID_ARGUMENT", "Missing functionCall", null) - return - } - - try { - val jsonFunctionCall = JSONObject(functionCall) - - // 使用协程处理异步操作 - CoroutineScope(Dispatchers.IO).launch { - try { - val toolResult = openAIService.handleMcpToolCall(jsonFunctionCall) - - // 切换到主线程返回结果 - withContext(Dispatchers.Main) { - result.success(toolResult ?: "无法处理MCP工具调用") - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - result.error("MCP_TOOL_ERROR", "处理MCP工具调用失败: ${e.message}", null) - } - } - } - } catch (e: Exception) { - result.error("INVALID_ARGUMENT", "解析functionCall失败: ${e.message}", null) - } - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt b/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt deleted file mode 100644 index 286fa9cb..00000000 --- a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt +++ /dev/null @@ -1,1168 +0,0 @@ -package com.yunqiinnovation.open_ai_service - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationListener -import android.location.LocationManager -import android.net.Uri -import android.os.Bundle -import android.provider.AlarmClock -import android.provider.CalendarContract -import android.provider.MediaStore -import android.provider.Settings -import android.util.Log -import org.json.JSONObject -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume -import android.view.KeyEvent -import android.os.Handler -import android.os.Looper -import kotlinx.coroutines.withContext -import kotlinx.coroutines.Dispatchers -import android.Manifest -import android.content.ContentValues -import android.content.ContentUris -import androidx.core.content.ContextCompat -import android.app.ActivityManager -import android.app.Application -import android.content.ComponentName -import android.media.AudioManager -import android.os.Process -/** - * 系统功能处理器 - 负责调用系统API执行各种功能以及注册系统功能 - */ -class SystemFunctionHandler(private val context: Context) { - companion object { - private const val TAG = "SystemFunctionHandler" - const val ACTION_EXIT_INTERACTION = "com.yimai.eaimar.action.EXIT_INTERACTION" - } - - /** - * 在MCPClient中注册所有系统功能 - * @param client MCPClient实例 - */ - fun registerAllFunctions(client: MCPClient) { - try { - // 注册退出交互函数 - registerExitInteractionFunction(client) - - // 注册翻译模式函数 - registerTranslationModeFunction(client) - - // 注册设置提醒函数 - registerSetReminderFunction(client) - - // 注册获取当前时间函数 - registerGetCurrentTimeFunction(client) - - // 注册获取当前位置函数 - registerGetCurrentLocationFunction(client) - - // 注册媒体控制函数 - registerMediaPlayFunction(client) - registerMediaPauseFunction(client) - registerMediaPreviousFunction(client) - registerMediaNextFunction(client) - - // 注册打开录音机功能 - registerOpenRecorderFunction(client) - - // 注册关闭导航服务功能 - registerStopNavigationFunction(client) - - //增加音量 - registerAddVolumeFunction(client) - //减少音量 - registerReduceVolumeFunction(client) - //静音 - registerMuteVolumeFunction(client) - Log.d(TAG, "所有系统功能注册完成") - } catch (e: Exception) { - Log.e(TAG, "注册系统功能失败: ${e.message}", e) - } - } - - - /** - * 注册退出交互函数 - */ - private fun registerExitInteractionFunction(client: MCPClient) { - client.registerLocalFunction( - "exit_interaction", - "结束当前交互", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - // 调用发送广播的函数 - // sendExitInteractionBroadcast() - - return "{\"result\": \"已结束当前交互\"}" - } - } - ) - Log.d(TAG, "已注册退出交互功能") - } - - /** - * 注册翻译模式函数 - */ - private fun registerTranslationModeFunction(client: MCPClient) { - client.registerLocalFunction( - "enter_translation_mode", - "用户请求进入实时翻译模式时,启动实时翻译功能", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = enterTranslationMode() - return "{\"result\": \"${result.message}\"}" - } - } - ) - Log.d(TAG, "已注册翻译模式功能") - } - - /** - * 注册设置提醒函数 - */ - private fun registerSetReminderFunction(client: MCPClient) { - client.registerLocalFunction( - "set_reminder", - "设置提醒事项", - JSONObject(""" - { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "提醒标题" - }, - "content": { - "type": "string", - "description": "提醒内容" - }, - "time": { - "type": "string", - "description": "提醒时间,格式为'yyyy-MM-dd HH:mm',如'2023-12-31 14:30'" - } - }, - "required": ["title", "time"] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val title = arguments["title"] as? String ?: "" - val content = arguments["content"] as? String ?: "" - val time = arguments["time"] as? String ?: "" - val result = setReminder(title, content, time) - return "{\"result\": \"${result.message}\"}" - } - } - ) - Log.d(TAG, "已注册设置提醒功能") - } - - /** - * 注册获取当前时间函数 - */ - private fun registerGetCurrentTimeFunction(client: MCPClient) { - client.registerLocalFunction( - "get_current_time", - "获取当前日期和时间,返回值已按当前语言本地化并对TTS友好(不含冒号等易被误读的符号)。播报或回复给用户时必须原样使用 result 字段的文本,不要自行改写为 HH:mm:ss 等格式。", - JSONObject(""" - { - "type": "object", - "properties": { - "format": { - "type": "string", - "enum": ["short", "date", "time", "full", "iso"], - "description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。" - } - }, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val format = arguments["format"] as? String - val result = getCurrentTime(format) - return "{\"result\": \"${result.message}\", \"time\": \"${result.message}\"}" - } - } - ) - Log.d(TAG, "已注册获取当前时间功能") - } - - /** - * 注册获取当前位置函数 - */ - private fun registerGetCurrentLocationFunction(client: MCPClient) { - client.registerLocalFunction( - "get_current_location", - "获取当前地理位置", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = getCurrentLocation() - return "{\"result\": \"${result.message}\", \"success\": ${result.success}}" - } - } - ) - Log.d(TAG, "已注册获取当前位置功能") - } - - /** - * 注册媒体播放功能 - */ - private fun registerMediaPlayFunction(client: MCPClient) { - client.registerLocalFunction( - "media_resume", - "继续媒体播放", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = controlMedia("play") - return "{\"result\": \"${result.message}\"}" - } - } - ) - Log.d(TAG, "已注册媒体播放功能") - } - - /** - * 注册媒体暂停功能 - */ - private fun registerMediaPauseFunction(client: MCPClient) { - client.registerLocalFunction( - "media_pause", - "暂停媒体播放", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = controlMedia("pause") - return "{\"result\": \"${result.message}\"}" - } - } - ) - Log.d(TAG, "已注册媒体暂停功能") - } - - /** - * 注册媒体上一首功能 - */ - private fun registerMediaPreviousFunction(client: MCPClient) { - client.registerLocalFunction( - "media_previous", - "播放上一首", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = controlMedia("previous") - return "{\"result\": \"${result.message}\"}" - } - } - ) - Log.d(TAG, "已注册媒体上一首功能") - } - - /** - * 注册媒体下一首功能 - */ - private fun registerMediaNextFunction(client: MCPClient) { - client.registerLocalFunction( - "media_next", - "播放下一首", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = controlMedia("next") - return "{\"result\": \"${result.message}\"}" - } - } - ) - Log.d(TAG, "已注册媒体下一首功能") - } - - /** - * 注册打开录音机功能 - */ - private fun registerOpenRecorderFunction(client: MCPClient) { - client.registerLocalFunction( - "open_recorder", - "打开系统录音机并开始录音", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = openRecorder() - return "{\"result\": \"${result.message}\"}" - } - } - ) - Log.d(TAG, "已注册打开录音机功能") - } - /** - * 注册获取当前时间函数 - */ - private fun registerStopNavigationFunction(client: MCPClient) { - client.registerLocalFunction( - "stop_navigation", - "结束当前导航服务", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = stopNavigation() - return "{\"result\": \"${result.message}\"}" - } - } - ) - Log.d(TAG, "已注册获取当前时间功能") - } - /** - * 注册添加音量函数 - */ - private fun registerAddVolumeFunction(client: MCPClient) { - client.registerLocalFunction( - "add_volume", - "增大音量", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = adjustVolume(context, AudioManager.ADJUST_RAISE) - return "{\"result\": \"增加音量成功!\"}" - } - } - ) - Log.d(TAG, "已注册音量调节功能") - } - - /** - * 注册减少音量函数 - */ - private fun registerReduceVolumeFunction(client: MCPClient) { - client.registerLocalFunction( - "reduce_volume", - "减少音量", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = adjustVolume(context, AudioManager.ADJUST_LOWER) - return "{\"result\": \"减少音量成功!\"}" - } - } - ) - Log.d(TAG, "已注册音量调节功能") - } - - /** - * 注册静音函数 - */ - private fun registerMuteVolumeFunction(client: MCPClient) { - client.registerLocalFunction( - "mute_volume", - "静音设置", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """), - object : FunctionHandler { - override suspend fun handle(arguments: Map): String { - val result = setVolume(context, AudioManager.STREAM_MUSIC, 0) - return "{\"result\": \"减少音量成功!\"}" - } - } - ) - Log.d(TAG, "已注册音量调节功能") - } - - /** - * 控制媒体播放 - * @param action 操作类型:"play", "pause", "previous", "next" - * @return 操作结果 - */ - private fun controlMedia(action: String): Result { - Log.d(TAG, "媒体控制: $action") - - try { - val intent = Intent() - - when (action) { - "play" -> { - // 尝试发送播放广播 - intent.action = "android.intent.action.MEDIA_PLAY" - context.sendBroadcast(intent) - - // 也尝试使用媒体按键广播 - val keyIntent = Intent(Intent.ACTION_MEDIA_BUTTON).apply { - putExtra( - Intent.EXTRA_KEY_EVENT, - android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_PLAY) - ) - flags = Intent.FLAG_RECEIVER_FOREGROUND - } - keyIntent.setPackage("com.yimai.eaimar") - context.sendBroadcast(keyIntent) - return Result( - success = true, - message = "已开始播放媒体" - ) - } - "pause" -> { - // 发送完整的按键事件序列 - val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE) - val keyIntent = Intent(Intent.ACTION_MEDIA_BUTTON) - keyIntent.putExtra(Intent.EXTRA_KEY_EVENT, downEvent) - keyIntent.setPackage("com.yimai.eaimar") - context.sendBroadcast(keyIntent) - - return Result( - success = true, - message = "已暂停媒体播放" - ) - } - "previous" -> { - // 尝试发送上一首广播 - intent.action = "android.intent.action.MEDIA_PREVIOUS" - context.sendBroadcast(intent) - - // 也尝试使用媒体按键广播 - val keyIntent = Intent(Intent.ACTION_MEDIA_BUTTON).apply { - putExtra( - Intent.EXTRA_KEY_EVENT, - android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS) - ) - flags = Intent.FLAG_RECEIVER_FOREGROUND - } - keyIntent.setPackage("com.yimai.eaimar") - context.sendBroadcast(keyIntent) - return Result( - success = true, - message = "已切换到上一首" - ) - } - "next" -> { - // 尝试发送下一首广播 - intent.action = "android.intent.action.MEDIA_NEXT" - context.sendBroadcast(intent) - - // 也尝试使用媒体按键广播 - val keyIntent = Intent(Intent.ACTION_MEDIA_BUTTON).apply { - putExtra( - Intent.EXTRA_KEY_EVENT, - android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_NEXT) - ) - flags = Intent.FLAG_RECEIVER_FOREGROUND - } - keyIntent.setPackage("com.yimai.eaimar") - context.sendBroadcast(keyIntent) - return Result( - success = true, - message = "已切换到下一首" - ) - } - else -> return Result( - success = false, - message = "未知的媒体控制操作: $action" - ) - } - } catch (e: Exception) { - Log.e(TAG, "媒体控制失败: ${e.message}", e) - return Result( - success = false, - message = "媒体控制失败: ${e.message}" - ) - } - } - - /** - * 发送退出交互广播 - */ - private fun sendExitInteractionBroadcast() { - // 发送广播通知VoiceInteractionHandler停止语音识别 - val intent = Intent(ACTION_EXIT_INTERACTION).apply { - // 明确指定接收广播的包名 - setPackage(context.packageName) - putExtra("timestamp", System.currentTimeMillis()) - // 添加FLAG_RECEIVER_FOREGROUND确保及时处理 - addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - } - Log.d(TAG, "准备发送停止交互广播,包名: ${context.packageName}") - context.sendBroadcast(intent) - Log.d(TAG, "已发送停止交互广播") - } - - - - /** - * 启动翻译模式 - * @return 操作结果 - */ - fun enterTranslationMode(): Result { - Log.d(TAG, "进入翻译模式") - // 此处应添加实际的翻译模式实现 - // 可能需要启动一个专门的翻译服务或活动 - - // 广播通知APP的其他部分进入翻译模式 - val intent = Intent("com.yimai.eaimar.ACTION_ENTER_TRANSLATION_MODE").apply { - setPackage(context.packageName) - - putExtra("timestamp", System.currentTimeMillis()) - // 添加FLAG_RECEIVER_FOREGROUND确保及时处理 - addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - } - - // 发送广播 - context.sendBroadcast(intent) - Log.d(TAG, "已发送广播: ENTER_TRANSLATION_MODE") - - return Result( - success = true, - message = "已进入实时翻译模式" - ) - } - - /** - * 检查权限并根据应用状态采取不同行为 - * @param permission 需要检查的权限 - * @param permissionName 权限名称(用于显示给用户) - * @param functionName 功能名称(用于日志和提示) - * @return 是否拥有权限 - */ - private fun checkPermissionAndGuide(permission: String, permissionName: String, functionName: String): Boolean { - if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { - Log.w(TAG, "缺少${permissionName}权限,无法执行${functionName}") - - // 检查应用是否在前台 - if (isAppInForeground()) { - // 应用在前台,尝试直接弹出权限申请框 - try { - // 首先尝试发送广播通知DeepSound应用弹出权限申请对话框 - val intent = Intent("com.yimai.eaimar.action.REQUEST_PERMISSION").apply { - setPackage(context.packageName) - putExtra("permission", permission) - putExtra("permissionName", permissionName) - putExtra("functionName", functionName) - addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - } - context.sendBroadcast(intent) - - // 由于插件无法直接请求权限(需要Activity),上面的广播会让主应用来处理权限请求 - // 这里设置一个短暂延迟,让用户有时间响应权限请求 - Thread.sleep(500) - - // 再次检查权限是否已授予(以防用户立即授予) - if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) { - return true - } - } catch (e: Exception) { - Log.e(TAG, "请求权限失败: ${e.message}", e) - } - - // 如果权限仍未授予,引导用户到权限管理页面 - try { - // 尝试跳转到应用内的权限管理页面 - val intent = Intent("com.yimai.eaimar.action.PERMISSIONS_SETTINGS").apply { - setPackage(context.packageName) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - if (intent.resolveActivity(context.packageManager) != null) { - context.startActivity(intent) - } else { - // 如果应用内权限页面不可用,则跳转到系统应用设置页面 - val settingsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(settingsIntent) - } - } catch (e: Exception) { - Log.e(TAG, "打开权限设置页面失败: ${e.message}", e) - } - } - // 应用在后台或权限请求失败,只返回文字提示 - return false - } - return true - } - - /** - * 检查应用是否在前台运行 - * @return 应用是否在前台 - */ - private fun isAppInForeground(): Boolean { - try { - val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - val appProcesses = activityManager.runningAppProcesses ?: return false - val packageName = context.packageName - - for (appProcess in appProcesses) { - if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND - && appProcess.processName == packageName) { - return true - } - } - } catch (e: Exception) { - Log.e(TAG, "检查应用状态失败: ${e.message}", e) - } - return false - } - - /** - * 设置提醒 - * @param title 提醒标题 - * @param content 提醒内容(可选) - * @param timeStr 提醒时间,格式为"yyyy-MM-dd HH:mm" - * @return 操作结果 - */ - fun setReminder(title: String, content: String, timeStr: String): Result { - Log.d(TAG, "设置提醒: $title, 时间: $timeStr") - - try { - // 解析时间字符串 - val time = try { - val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault()) - dateFormat.parse(timeStr)?.time ?: System.currentTimeMillis() - } catch (e: Exception) { - Log.e(TAG, "解析时间失败: $timeStr, ${e.message}") - return Result( - success = false, - message = "提醒时间格式无效,请使用'yyyy-MM-dd HH:mm'格式" - ) - } - - // 检查日历权限 - if (!checkPermissionAndGuide(Manifest.permission.WRITE_CALENDAR, "日历写入", "设置提醒")) { - return Result( - success = false, - message = "需要日历权限才能创建提醒,请在权限管理中开启日历权限" - ) - } - - // 直接使用ContentProvider写入日历 - val endTime = time + 3600000 // 默认1小时后结束 - - // 获取默认日历ID - val calendarId = getDefaultCalendarId() - if (calendarId == -1L) { - Log.w(TAG, "未找到默认日历") - return tryCreateAlarm(title, content, timeStr) - } - - // 创建事件 - val eventValues = ContentValues().apply { - put(CalendarContract.Events.CALENDAR_ID, calendarId) - put(CalendarContract.Events.TITLE, title) - put(CalendarContract.Events.DESCRIPTION, content) - put(CalendarContract.Events.DTSTART, time) - put(CalendarContract.Events.DTEND, endTime) - put(CalendarContract.Events.HAS_ALARM, 1) - put(CalendarContract.Events.EVENT_TIMEZONE, java.util.TimeZone.getDefault().id) - } - - val eventUri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, eventValues) - if (eventUri == null) { - Log.e(TAG, "插入日历事件失败") - return tryCreateAlarm(title, content, timeStr) - } - - // 获取事件ID - val eventId = ContentUris.parseId(eventUri) - - // 添加提醒(15分钟前提醒) - val reminderValues = ContentValues().apply { - put(CalendarContract.Reminders.EVENT_ID, eventId) - put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) - put(CalendarContract.Reminders.MINUTES, 15) // 提前15分钟提醒 - } - - val reminderUri = context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, reminderValues) - if (reminderUri != null) { - return Result( - success = true, - message = "已成功创建提醒: $title, 时间: $timeStr" - ) - } else { - // 仍然创建了事件,但没有提醒 - return Result( - success = true, - message = "已创建日历事件,但未设置提醒: $title" - ) - } - } catch (e: Exception) { - Log.e(TAG, "设置提醒失败: ${e.message}", e) - return Result( - success = false, - message = "设置提醒失败: ${e.message}" - ) - } - } - - /** - * 获取默认日历ID - */ - private fun getDefaultCalendarId(): Long { - val projection = arrayOf(CalendarContract.Calendars._ID) - var calendarId = -1L - - try { - val uri = CalendarContract.Calendars.CONTENT_URI - val selection = "(${CalendarContract.Calendars.VISIBLE} = 1) AND (${CalendarContract.Calendars.IS_PRIMARY} = 1)" - context.contentResolver.query(uri, projection, selection, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - calendarId = cursor.getLong(0) - } else { - // 如果没有找到主日历,尝试找任何可见的日历 - context.contentResolver.query(uri, projection, "${CalendarContract.Calendars.VISIBLE} = 1", null, null)?.use { c -> - if (c.moveToFirst()) { - calendarId = c.getLong(0) - } - } - } - } - } catch (e: Exception) { - Log.e(TAG, "获取默认日历ID失败: ${e.message}", e) - } - - return calendarId - } - - /** - * 尝试创建闹钟提醒(备选方案) - */ - private fun tryCreateAlarm(title: String, content: String, timeStr: String): Result { - try { - // 解析小时和分钟 - val timeParts = timeStr.split(" ")[1].split(":") - val hour = timeParts[0].toInt() - val minute = timeParts[1].toInt() - - val alarmIntent = Intent(AlarmClock.ACTION_SET_ALARM) - alarmIntent.putExtra(AlarmClock.EXTRA_MESSAGE, "$title: $content") - alarmIntent.putExtra(AlarmClock.EXTRA_HOUR, hour) - alarmIntent.putExtra(AlarmClock.EXTRA_MINUTES, minute) - alarmIntent.putExtra(AlarmClock.EXTRA_SKIP_UI, true) // 尝试跳过UI - alarmIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - - if (alarmIntent.resolveActivity(context.packageManager) != null) { - context.startActivity(alarmIntent) - return Result( - success = true, - message = "已创建闹钟提醒: $title, 时间: $timeStr" - ) - } - } catch (e: Exception) { - Log.e(TAG, "创建闹钟失败: ${e.message}", e) - } - - return Result( - success = false, - message = "未找到可用的日历或闹钟应用" - ) - } - - /** - * 获取当前时间 - * @param format 时间格式(可选) - * @return 操作结果 - */ - fun getCurrentTime(format: String? = null): Result { - try { - val calendar = java.util.Calendar.getInstance() - val date = calendar.time - val lang = java.util.Locale.getDefault().language - - // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字 - val timeStr = when (format?.lowercase(java.util.Locale.getDefault())) { - "short" -> formatShortTime(date, lang) - "date" -> formatDate(date, lang) - "time" -> formatTime(date, lang) - "full" -> formatFull(date, lang) - "iso" -> java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.getDefault()).format(date) - else -> formatDefaultTime(date, lang) - } - - // 获取星期几(使用系统locale自动适配语言) - val weekDay = java.text.SimpleDateFormat("EEEE", java.util.Locale.getDefault()).format(date) - - // 根据格式决定是否添加星期几 - val resultTime = when { - format == "short" || format == "time" || format == "iso" -> timeStr - format == "full" -> timeStr // full格式已经包含了星期 - else -> "$timeStr $weekDay" - } - - Log.d(TAG, "获取当前时间成功: $resultTime") - return Result( - success = true, - message = resultTime - ) - } catch (e: Exception) { - Log.e(TAG, "获取当前时间失败: ${e.message}", e) - return Result( - success = false, - message = "获取当前时间失败: ${e.message}" - ) - } - } - - // TTS友好的时间格式辅助方法 - // CJK 使用单位数 m/s(去掉前导零),其他语言使用英文单词避免冒号 - private fun formatShortTime(date: java.util.Date, lang: String): String = when (lang) { - "zh" -> java.text.SimpleDateFormat("H时m分", java.util.Locale.CHINESE).format(date) - "ja" -> java.text.SimpleDateFormat("H時m分", java.util.Locale.JAPANESE).format(date) - "ko" -> java.text.SimpleDateFormat("H시 m분", java.util.Locale.KOREAN).format(date) - else -> formatTimeWestern(date, withSeconds = false) - } - - 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时m分s秒", java.util.Locale.CHINESE).format(date) - "ja" -> java.text.SimpleDateFormat("H時m分s秒", java.util.Locale.JAPANESE).format(date) - "ko" -> java.text.SimpleDateFormat("H시 m분 s초", java.util.Locale.KOREAN).format(date) - else -> formatTimeWestern(date, withSeconds = true) - } - - private fun formatFull(date: java.util.Date, lang: String): String = when (lang) { - "zh" -> java.text.SimpleDateFormat("yyyy年M月d日 EEEE H时m分s秒", java.util.Locale.CHINESE).format(date) - "ja" -> java.text.SimpleDateFormat("yyyy年M月d日 EEEE H時m分s秒", java.util.Locale.JAPANESE).format(date) - "ko" -> java.text.SimpleDateFormat("yyyy년 M월 d일 EEEE H시 m분 s초", java.util.Locale.KOREAN).format(date) - else -> "${java.text.SimpleDateFormat("MMMM d, yyyy, EEEE, ", java.util.Locale.getDefault()).format(date)}${formatTimeWestern(date, withSeconds = true)}" - } - - private fun formatDefaultTime(date: java.util.Date, lang: String): String = when (lang) { - "zh" -> java.text.SimpleDateFormat("yyyy年M月d日 H时m分", java.util.Locale.CHINESE).format(date) - "ja" -> java.text.SimpleDateFormat("yyyy年M月d日 H時m分", java.util.Locale.JAPANESE).format(date) - "ko" -> java.text.SimpleDateFormat("yyyy년 M월 d일 H시 m분", java.util.Locale.KOREAN).format(date) - else -> "${java.text.SimpleDateFormat("MMMM d, yyyy, ", java.util.Locale.getDefault()).format(date)}${formatTimeWestern(date, withSeconds = false)}" - } - - /** - * 非 CJK 语言的时间格式:避免冒号,使用英文单词,对 TTS 更稳定 - * 例:2 hours 30 minutes 15 seconds PM - */ - private fun formatTimeWestern(date: java.util.Date, withSeconds: Boolean): String { - val cal = java.util.Calendar.getInstance().apply { time = date } - val h12 = cal.get(java.util.Calendar.HOUR).let { if (it == 0) 12 else it } - val minute = cal.get(java.util.Calendar.MINUTE) - val second = cal.get(java.util.Calendar.SECOND) - val ampm = if (cal.get(java.util.Calendar.AM_PM) == java.util.Calendar.AM) "AM" else "PM" - - val sb = StringBuilder() - sb.append(h12).append(if (h12 == 1) " hour" else " hours") - if (minute > 0 || withSeconds) { - sb.append(' ').append(minute).append(if (minute == 1) " minute" else " minutes") - } - if (withSeconds) { - sb.append(' ').append(second).append(if (second == 1) " second" else " seconds") - } - sb.append(' ').append(ampm) - return sb.toString() - } - - /** - * 获取当前位置(简化版,只获取最后已知位置) - * @return 操作结果 - */ - suspend fun getCurrentLocation(): Result = withContext(Dispatchers.Main) { - Log.d(TAG, "开始获取当前位置") - - try { - val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - - // 检查位置权限 - if (!checkPermissionAndGuide(Manifest.permission.ACCESS_FINE_LOCATION, "位置信息", "获取位置")) { - return@withContext Result( - success = false, - message = "需要位置权限才能获取当前位置,请在权限管理中开启位置权限" - ) - } - - // 检查位置服务是否开启 - if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) && - !locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { - - Log.d(TAG, "位置服务未启用") - // 引导用户打开位置服务 - try { - val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) - } catch (e: Exception) { - Log.e(TAG, "打开位置设置失败: ${e.message}", e) - } - - return@withContext Result( - success = false, - message = "位置服务未启用,请打开GPS或网络定位" - ) - } - - // 获取最后已知位置 - var bestLocation: Location? = null - - // 尝试获取网络位置 - if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { - val networkLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) - if (networkLocation != null) { - bestLocation = networkLocation - Log.d(TAG, "获取到网络位置: ${networkLocation.latitude}, ${networkLocation.longitude}") - } - } - - // 尝试获取GPS位置(如果更精确) - if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { - val gpsLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - if (gpsLocation != null && (bestLocation == null || gpsLocation.accuracy < bestLocation.accuracy)) { - bestLocation = gpsLocation - Log.d(TAG, "获取到GPS位置: ${gpsLocation.latitude}, ${gpsLocation.longitude}") - } - } - - // 返回结果 - return@withContext if (bestLocation != null) { - val message = "当前位置: 经度 ${bestLocation.longitude}, 纬度 ${bestLocation.latitude}" - Log.d(TAG, message) - Result(success = true, message = message) - } else { - Log.d(TAG, "无法获取位置信息") - Result(success = false, message = "无法获取位置信息,请稍后重试") - } - - } catch (e: Exception) { - Log.e(TAG, "获取位置过程中发生异常: ${e.message}") - return@withContext Result( - success = false, - message = "获取位置失败: ${e.message}" - ) - } - } - - /** - * 打开系统录音机并尝试自动开始录音 - * @return 操作结果 - */ - fun openRecorder(): Result { - Log.d(TAG, "打开系统录音机并尝试自动开始录音") - - try { - // 创建用于启动录音机应用的Intent - val recordIntent = Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION) - recordIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - - // 添加额外参数尝试自动开始录音(不是所有应用都支持) - recordIntent.putExtra("android.intent.extra.quickCapture", true) - - // 检查是否有应用能处理此Intent - if (recordIntent.resolveActivity(context.packageManager) != null) { - context.startActivity(recordIntent) - return Result( - success = true, - message = "已打开录音机,尝试自动开始录音" - ) - } else { - // 尝试使用备用方法,针对不同厂商应用添加特定参数 - val commonRecorderIntents = mapOf( - "com.android.soundrecorder" to Intent().setClassName("com.android.soundrecorder", "com.android.soundrecorder.SoundRecorder").setAction("android.intent.action.RECORD_SOUND"), - "com.sec.android.app.voicenote" to Intent().setClassName("com.sec.android.app.voicenote", "com.sec.android.app.voicenote.Main").putExtra("launch_mode", "record"), - "com.miui.soundrecorder" to Intent().setClassName("com.miui.soundrecorder", "com.miui.soundrecorder.SoundRecorderActivity").putExtra("startOnBoot", true), - "com.huawei.recorder" to Intent().setClassName("com.huawei.recorder", "com.huawei.recorder.activity.MainActivity").putExtra("autoRecord", true), - "com.google.android.apps.recorder" to Intent().setClassName("com.google.android.apps.recorder", "com.google.android.apps.recorder.ui.RecorderActivity").putExtra("start_recording", true) - ) - - for ((packageName, intent) in commonRecorderIntents) { - try { - if (isPackageInstalled(packageName, context.packageManager)) { - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) - return Result( - success = true, - message = "已打开录音应用并尝试自动开始录音" - ) - } - } catch (e: Exception) { - Log.e(TAG, "尝试打开录音应用 $packageName 失败: ${e.message}") - // 继续尝试下一个应用 - } - } - - // 如果特定Intent都失败,回退到原来的方法 - val commonRecorderPackages = listOf( - "com.android.soundrecorder", - "com.sec.android.app.voicenote", - "com.miui.soundrecorder", - "com.huawei.recorder", - "com.google.android.apps.recorder" - ) - - for (packageName in commonRecorderPackages) { - try { - val packageIntent = context.packageManager.getLaunchIntentForPackage(packageName) - if (packageIntent != null) { - packageIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - // 尝试添加通用的自动开始录音参数 - packageIntent.putExtra("android.intent.extra.quickCapture", true) - context.startActivity(packageIntent) - return Result( - success = true, - message = "已打开录音应用,请手动开始录音" - ) - } - } catch (e: Exception) { - Log.e(TAG, "尝试打开录音应用 $packageName 失败: ${e.message}") - } - } - - return Result( - success = false, - message = "未找到录音应用" - ) - } - } catch (e: Exception) { - Log.e(TAG, "打开录音机失败: ${e.message}", e) - return Result( - success = false, - message = "打开录音机失败: ${e.message}" - ) - } - } - - // 检查应用是否已安装 - private fun isPackageInstalled(packageName: String, packageManager: PackageManager): Boolean { - return try { - packageManager.getPackageInfo(packageName, 0) - true - } catch (e: Exception) { - false - } - } - - - /** - * 停止当前导航 - * @return 操作结果 - */ - fun stopNavigation():Result{ - Log.d(TAG, "结束当前导航服务") - try { -// val intent = Intent() -// intent.component = ComponentName("com.example.plugin", "com.example.plugin.MyBackgroundService") -// stopService(intent) - return Result( - success = true, - message = "当前导航服务已结束" - ) - } catch (e: Exception) { - Log.e(TAG, "结束当前导航服务失败: ${e.message}", e) - return Result( - success = false, - message = "结束当前导航服务失败: ${e.message}" - ) - } - } - - - //设置音量 - fun setVolume(context: Context, streamType: Int, volume: Int) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val maxVolume = audioManager.getStreamMaxVolume(streamType) - val safeVolume = volume.coerceIn(0, maxVolume) - audioManager.setStreamVolume(streamType, safeVolume, AudioManager.FLAG_SHOW_UI) - } - fun getCurrentVolume(context: Context, streamType: Int): Int { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - return audioManager.getStreamVolume(streamType) - } - fun getMaxVolume(context: Context, streamType: Int): Int { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - return audioManager.getStreamMaxVolume(streamType) - } - //增加音量/减少音量 - fun adjustVolume(context: Context, direction: Int) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, AudioManager.FLAG_SHOW_UI) - } - - /** - * 操作结果数据类 - */ - data class Result( - val success: Boolean, - val message: String - ) -} diff --git a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt b/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt deleted file mode 100644 index b198298a..00000000 --- a/apps/client/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt +++ /dev/null @@ -1,367 +0,0 @@ -package com.yunqiinnovation.open_ai_service.mcp - -import android.os.Parcel -import android.os.Parcelable -import android.util.Log -import io.ktor.client.* -import io.ktor.client.plugins.sse.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest -import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport -import kotlinx.coroutines.* -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.decodeFromString -import kotlin.properties.Delegates -import kotlin.time.Duration -import java.util.concurrent.atomic.AtomicBoolean -import org.json.JSONObject - - -/** - * 自定义SSE客户端传输层,修复原始SseClientTransport中的URL拼接问题 - * 解决URL查询参数与路径拼接错误的问题,确保消息端点URL格式正确 - */ -class CustomSseClientTransport( - private val client: HttpClient, - private val urlString: String?, - private val reconnectionTime: Duration? = null, - private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, -) : AbstractTransport() { - private val TAG = "CustomSseClientTransport" - - private val scope by lazy { - CoroutineScope(session.coroutineContext + SupervisorJob()) - } - - // 使用Java标准库的AtomicBoolean替代kotlinx.atomicfu - private val initialized = AtomicBoolean(false) - private var session: ClientSSESession by Delegates.notNull() - private val endpoint = CompletableDeferred() - - private var job: Job? = null - - // 创建JSON解析器,增强灵活性设置 - private val json = Json { - ignoreUnknownKeys = true // 忽略未知字段 - isLenient = true // 宽松解析模式 - coerceInputValues = true // 尝试强制转换类型 - encodeDefaults = true // 编码默认值 - explicitNulls = false // 不要求显式null值 - } - - // 保存基础URL(不包含查询参数)和查询参数 - private var baseUrlWithoutParams: String? = null - private var queryParams: Map = emptyMap() - private var hostPart: String = "" // 添加类级别变量 - private var pathPart: String = "" // 添加类级别变量 - - /** - * 解析URL,分离基础URL、路径和查询参数 - * 返回三元组: (主机部分URL, 路径部分, 查询参数Map) - */ - private fun parseUrl(url: String): Triple> { - return try { - val params = mutableMapOf() - - // 确保URL有协议部分 - var processedUrl = url.trim() - if (!processedUrl.startsWith("http://") && !processedUrl.startsWith("https://")) { - processedUrl = "https://$processedUrl" - Log.d(TAG, "添加默认协议: $processedUrl") - } - - val urlObj = java.net.URL(processedUrl) - - // 解析查询参数 - if (urlObj.query != null) { - urlObj.query.split("&").forEach { param -> - val parts = param.split("=", limit = 2) - if (parts.size == 2) { - params[parts[0]] = parts[1] - } - } - } - - // 构建主机部分URL(协议+主机+端口) - val port = if (urlObj.port == -1) "" else ":${urlObj.port}" - val hostUrl = "${urlObj.protocol}://${urlObj.host}$port" - - // 路径部分 - val path = urlObj.path - - Triple(hostUrl, path, params) - } catch (e: Exception) { - Log.e(TAG, "解析URL失败: $url, ${e.message}") - Triple(url, "", emptyMap()) - } - } - - /** - * 收集SSE事件 - */ - private suspend fun collectEvents() { - job = scope.launch(CoroutineName("CustomSseMcpClientTransport.collect#${hashCode()}")) { - session.incoming.collect { event -> - when (event.event) { - "error" -> { - val e = IllegalStateException("SSE error: ${event.data}") - Log.e(TAG, "SSE错误: ${event.data}") - _onError(e) - throw e - } - - "open" -> { - Log.d(TAG, "SSE连接已打开") - // 连接已打开,等待endpoint事件 - } - - "endpoint" -> { - try { - val eventData = event.data ?: "" - Log.d(TAG, "收到endpoint事件: $eventData") - - // 检查eventData是否已包含hostPart - val fullEndpoint = if (eventData.contains(hostPart)) { - // 如果已包含hostPart,则直接使用eventData - Log.d(TAG, "eventData已包含hostPart,直接使用") - eventData - } else if (eventData.startsWith("/")) { - // 如果以/开头但不包含hostPart,则拼接 - "$hostPart$eventData" - } else { - "$eventData" - } - - Log.d(TAG, "构建的endpoint路径(不含参数): $fullEndpoint") - - // 添加查询参数到endpoint - val endpointWithParams = if (queryParams.isNotEmpty()) { - // 检查endpoint是否已有查询参数 - if (fullEndpoint.contains("?")) { - // 已有查询参数,添加&并附加其他参数 - val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } - "$fullEndpoint&$queryString" - } else { - // 没有查询参数,添加?并附加参数 - val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } - "$fullEndpoint?$queryString" - } - } else { - fullEndpoint - } - - Log.d(TAG, "最终消息端点: $endpointWithParams") - endpoint.complete(endpointWithParams) - } catch (e: Exception) { - Log.e(TAG, "处理endpoint事件失败: ${e.message}", e) - _onError(e) - close() - error(e) - } - } - - else -> { - try { - // 解析JSON-RPC消息 - val data = event.data - if (data != null) { - // Log.d(TAG, "收到事件数据: $data") - try { - // 尝试安全地解析JSON消息 - safeParseMessage(data) - } catch (e: Exception) { - Log.e(TAG, "解析JSON-RPC消息失败: ${e.message}", e) - // 错误已记录,但不中断连接,只发送错误通知 - _onError(e) - } - } - } catch (e: Exception) { - Log.e(TAG, "处理事件失败: ${e.message}", e) - _onError(e) - } - } - } - } - } - } - - /** - * 安全解析JSON-RPC消息 - */ - private suspend fun safeParseMessage(data: String) { - try { - // 先尝试使用标准解析 - val message = json.decodeFromString(data) - _onMessage(message) - } catch (e: Exception) { - // 如果标准解析失败,记录错误并尝试使用备用解析方式 - Log.w(TAG, "标准解析失败,尝试备用解析: ${e.message}") - - try { - // 尝试修复nextCursor缺失问题 - if (e.message?.contains("nextCursor") == true) { - // 尝试手动添加缺失的nextCursor字段 - val jsonObj = JSONObject(data) - - // 只有在解析ListToolsResult时处理 - if (data.contains("\"tools\"")) { - Log.d(TAG, "尝试修复ListToolsResult缺少nextCursor字段的问题") - - // 手动解析result部分并添加nextCursor - val resultJson = try { - if (jsonObj.has("result")) { - val resultObj = jsonObj.getJSONObject("result") - if (!resultObj.has("nextCursor")) { - resultObj.put("nextCursor", "") - jsonObj.put("result", resultObj) - } - jsonObj.toString() - } else { - // 如果没有result字段,可能是其他类型的消息 - data - } - } catch (ex: Exception) { - Log.e(TAG, "手动修复JSON失败: ${ex.message}") - data - } - - // 重新尝试解析修复后的JSON - val fixedMessage = json.decodeFromString(resultJson) - _onMessage(fixedMessage) - return - } - } - - // 通用错误处理 - Log.e(TAG, "无法解析消息,跳过: $data") - } catch (ex: Exception) { - Log.e(TAG, "备用解析也失败: ${ex.message}", ex) - // 不抛出异常,只记录错误 - _onError(e) - } - } - } - - /** - * 启动传输层 - */ - override suspend fun start() { - if (!initialized.compareAndSet(false, true)) { - Log.e(TAG, "传输层已经启动,不能重复启动") - error("CustomSseClientTransport already started!") - } - - // 解析URL和参数 - if (urlString != null) { - // 解析URL,提取主机部分、路径部分和查询参数 - val urlInfo = parseUrl(urlString) - hostPart = urlInfo.first - pathPart = urlInfo.second - queryParams = urlInfo.third - - // 存储不带查询参数的基础URL(主机+路径) - baseUrlWithoutParams = hostPart + pathPart - - Log.d(TAG, "原始URL: $urlString") - Log.d(TAG, "主机部分: $hostPart") - Log.d(TAG, "路径部分: $pathPart") - Log.d(TAG, "查询参数: $queryParams") - Log.d(TAG, "完整基础URL: $baseUrlWithoutParams") - } - - // 创建SSE会话 - 直接使用原始URL,不添加/sse后缀 - session = urlString?.let { - // 完整的SSE连接URL(主机部分+原始路径+查询参数) - val sseConnectUrl = if (queryParams.isNotEmpty()) { - // 如果路径已包含查询参数,就不再添加 - if (pathPart.contains("?")) { - "$hostPart$pathPart" - } else { - val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } - "$hostPart$pathPart?$queryString" - } - } else { - "$hostPart$pathPart" - } - - Log.d(TAG, "SSE连接URL: $sseConnectUrl") - - client.sseSession( - urlString = sseConnectUrl, - reconnectionTime = reconnectionTime, - block = requestBuilder, - ) - } ?: client.sseSession( - reconnectionTime = reconnectionTime, - block = requestBuilder, - ) - - // 收集SSE事件 - collectEvents() - - // 等待endpoint就绪 - endpoint.await() - Log.d(TAG, "传输层启动完成,消息端点已就绪") - } - - /** - * 发送消息 - */ - @OptIn(ExperimentalCoroutinesApi::class) - override suspend fun send(message: JSONRPCMessage) { - if (!endpoint.isCompleted) { - Log.e(TAG, "发送失败: 未连接") - error("Not connected") - } - - try { - val messageEndpoint = endpoint.getCompleted() - Log.d(TAG, "发送消息到: $messageEndpoint") - - // 序列化消息 - val jsonString = json.encodeToString(message) - - val response = client.post(messageEndpoint) { - headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody(jsonString) - } - - if (!response.status.isSuccess()) { - val text = response.bodyAsText() - Log.e(TAG, "发送消息失败: HTTP ${response.status}, $text") - error("Error POSTing to endpoint (HTTP ${response.status}): $text") - } - } catch (e: Exception) { - Log.e(TAG, "发送消息异常: ${e.message}", e) - _onError(e) - throw e - } - } - - /** - * 关闭传输层 - */ - override suspend fun close() { - if (!initialized.get()) { - Log.e(TAG, "关闭失败: 传输层未初始化") - error("CustomSseClientTransport is not initialized!") - } - session.cancel() - _onClose() - job?.cancelAndJoin() - Log.d(TAG, "传输层已关闭") - } - - /** - * 检查传输层是否已初始化 - */ - fun isInitialized(): Boolean { - return initialized.get() - } -} - - \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/ios/Classes/MCPClient.swift b/apps/client/local_plugins/open_ai_service/ios/Classes/MCPClient.swift deleted file mode 100644 index 121653f0..00000000 --- a/apps/client/local_plugins/open_ai_service/ios/Classes/MCPClient.swift +++ /dev/null @@ -1,366 +0,0 @@ -import Foundation - -/// 工具类型枚举 -public enum ToolType { - case localFunction // 本地函数 - case mcpTool // MCP工具 -} - -/// 函数处理器协议 -public protocol FunctionHandler { - /// 处理函数调用 - /// - Parameter arguments: 函数参数,字典格式 - /// - Returns: 函数执行结果,字符串 - func handle(arguments: [String: Any]) async throws -> String -} - -/// 单个MCP子客户端 -/// 负责连接到一个具体的MCP服务器并管理其工具 -public class MCPSubClient { - private let tag: String - private let serverId: String - private let serverUrl: String - - // 服务器提供的工具列表 - private var tools: [[String: Any]] = [] - - // 工具Map列表,用于OpenAI请求 - private var toolMaps: [[String: Any]] = [] - - // 是否已连接 - private var isConnectedFlag = false - - // 工具名称到工具的映射,用于快速查找 - private var toolMapping: [String: [String: Any]] = [:] - - // URL会话 - private lazy var urlSession: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 30 - return URLSession(configuration: config) - }() - - public init(serverId: String, serverUrl: String) { - self.serverId = serverId - self.serverUrl = serverUrl - self.tag = "MCPSubClient[\(serverId)]" - print("\(tag) 初始化,URL: \(serverUrl)") - } - - /// 连接到MCP服务器(通过SSE) - /// - Returns: 是否连接成功 - public func connect() async -> Bool { - do { - print("\(tag) 开始连接服务器: \(serverUrl)") - - // 这里应该实现实际的SSE连接逻辑 - // 由于iOS没有直接的SSE库,我们可以使用URLSession来模拟 - // 或者使用第三方库如EventSource - - // 模拟连接成功并获取工具列表 - await processToolsList() - - isConnectedFlag = true - print("\(tag) 连接成功,可用工具: \(tools.count)个") - return true - } catch { - print("\(tag) 连接失败: \(error.localizedDescription)") - return false - } - } - - /// 处理工具列表 - private func processToolsList() async { - // 这里应该从MCP服务器获取实际的工具列表 - // 目前先模拟一些基本工具 - tools = [] - - // 更新工具映射 - toolMapping.removeAll() - for tool in tools { - if let name = tool["name"] as? String { - toolMapping[name] = tool - } - } - - // 更新工具Map列表 - updateToolMaps() - - print("\(tag) 成功处理 \(tools.count) 个工具") - } - - /// 更新工具Map列表,用于OpenAI请求 - /// 将MCP工具转换为OpenAI函数调用格式 - private func updateToolMaps() { - toolMaps.removeAll() - - for tool in tools { - guard let name = tool["name"] as? String, - let description = tool["description"] as? String else { - continue - } - - let functionMap: [String: Any] = [ - "name": name, - "description": description, - "parameters": [ - "type": "object", - "properties": [:], - "required": [] - ] - ] - - toolMaps.append([ - "type": "function", - "function": functionMap - ]) - } - - print("\(tag) 更新工具映射完成,共 \(toolMaps.count) 个工具") - } - - /// 获取工具Map列表,用于OpenAI请求 - public func getToolMaps() -> [[String: Any]] { - return toolMaps - } - - /// 获取可用的工具列表 - public func getTools() -> [[String: Any]] { - return tools - } - - /// 检查是否包含指定工具 - public func containsTool(name: String) -> Bool { - return toolMapping.keys.contains(name) - } - - /// 调用工具 - public func callTool(name: String, arguments: [String: Any]) async throws -> [String: Any] { - guard isConnectedFlag else { - print("\(tag) 未连接到服务器") - return ["context": "未连接到MCP服务器"] - } - - guard containsTool(name: name) else { - print("\(tag) 此客户端不包含工具: \(name)") - throw OpenAIException("工具不存在: \(name)") - } - - // 这里应该实现实际的工具调用逻辑 - // 目前先返回模拟结果 - return [ - "name": name, - "context": "工具调用结果: \(name)" - ] - } - - /// 检查是否已连接 - public func isConnected() -> Bool { - return isConnectedFlag - } - - /// 关闭连接 - public func close() { - isConnectedFlag = false - print("\(tag) 已关闭MCP服务器连接") - } -} - -/// MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 -public class MCPClient { - private let tag = "MCPClient" - - // 本地函数Map,函数名 -> 处理器 - private var localFunctions: [String: FunctionHandler] = [:] - - // 本地函数定义Map,函数名 -> 定义 - private var localFunctionDefs: [String: String] = [:] - - // 子客户端列表,每个连接一个MCP服务器 - private var subClients: [String: MCPSubClient] = [:] - - // 是否已连接 - private var isConnectedFlag = false - - // 系统功能处理器 - private var systemFunctionHandler: SystemFunctionHandler? - - public init() { - // 初始化系统功能处理器 - systemFunctionHandler = SystemFunctionHandler() - initializeVoiceFunctions() - } - - /// 初始化语音功能处理器 - private func initializeVoiceFunctions() { - do { - // 使用SystemFunctionHandler注册所有功能 - systemFunctionHandler?.registerAllFunctions(client: self) - print("\(tag) 语音功能初始化完成") - } catch { - print("\(tag) 语音功能初始化失败: \(error.localizedDescription)") - } - } - - /// 连接到MCP服务器(通过SSE) - /// 直接接收完整的JSON配置字符串 - /// - Parameter mcpConfigJson: 包含mcpServers字段的JSON配置字符串 - /// - Returns: 是否连接成功 - public func connectToSSE(mcpConfigJson: String) async throws -> Bool { - // 清除现有连接 - closeAllConnections() - - var serverMap: [String: String] = [:] - var configLoaded = false - - // 尝试解析传入的JSON配置 - if !mcpConfigJson.isEmpty { - do { - guard let data = mcpConfigJson.data(using: .utf8), - let config = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let mcpServers = config["mcpServers"] as? [String: [String: String]] else { - throw OpenAIException("无效的MCP配置格式") - } - - // 从服务器配置中提取URL - for (serverId, serverConfig) in mcpServers { - if let url = serverConfig["url"], !url.isEmpty { - serverMap[serverId] = url - print("\(tag) 从配置中添加服务器 [\(serverId)]: \(url)") - } - } - - configLoaded = !serverMap.isEmpty - } catch { - print("\(tag) 解析MCP配置失败: \(error.localizedDescription)") - } - } - - // 如果没有从配置中加载到服务器,使用默认配置 - if !configLoaded { - print("\(tag) 使用默认MCP服务器配置") - // 可以在这里添加默认的服务器配置 - } - - // 连接到所有服务器 - var successCount = 0 - for (serverId, serverUrl) in serverMap { - let subClient = MCPSubClient(serverId: serverId, serverUrl: serverUrl) - let connected = await subClient.connect() - - if connected { - subClients[serverId] = subClient - successCount += 1 - print("\(tag) 成功连接到服务器 [\(serverId)]") - } else { - print("\(tag) 连接服务器失败 [\(serverId)]") - } - } - - isConnectedFlag = successCount > 0 - print("\(tag) MCP客户端连接完成,成功连接 \(successCount)/\(serverMap.count) 个服务器") - - return isConnectedFlag - } - - /// 关闭所有连接 - private func closeAllConnections() { - for (_, subClient) in subClients { - subClient.close() - } - subClients.removeAll() - isConnectedFlag = false - } - - /// 获取工具Map列表,用于OpenAI请求 - public func getToolMaps() -> [[String: Any]] { - var allToolMaps: [[String: Any]] = [] - - // 添加所有子客户端的工具 - for (_, subClient) in subClients { - allToolMaps.append(contentsOf: subClient.getToolMaps()) - } - - // 添加本地函数 - for (name, _) in localFunctions { - if let def = localFunctionDefs[name], - let data = def.data(using: .utf8), - let functionDef = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - allToolMaps.append([ - "type": "function", - "function": functionDef - ]) - } - } - - return allToolMaps - } - - /// 注册本地函数 - public func registerLocalFunction(name: String, description: String, parameters: [String: Any], handler: FunctionHandler) -> Bool { - do { - localFunctions[name] = handler - - let functionDef: [String: Any] = [ - "name": name, - "description": description, - "parameters": parameters - ] - - let jsonData = try JSONSerialization.data(withJSONObject: functionDef) - let jsonString = String(data: jsonData, encoding: .utf8) ?? "" - localFunctionDefs[name] = jsonString - - // print("\(tag) 成功注册本地函数: \(name)") - return true - } catch { - print("\(tag) 注册本地函数失败: \(error.localizedDescription)") - return false - } - } - - /// 调用工具 - public func callTool(name: String, arguments: [String: Any]) async throws -> [String: Any] { - // 首先检查是否是本地函数 - if let handler = localFunctions[name] { - do { - let result = try await handler.handle(arguments: arguments) - return ["context": result] - } catch { - throw OpenAIException("本地函数调用失败: \(error.localizedDescription)") - } - } - - // 然后检查MCP工具 - for (_, subClient) in subClients { - if subClient.containsTool(name: name) { - return try await subClient.callTool(name: name, arguments: arguments) - } - } - - throw OpenAIException("未找到工具: \(name)") - } - - /// 解析JSON参数 - public func parseJsonArguments(_ argumentsJson: String) throws -> [String: Any] { - guard let data = argumentsJson.data(using: .utf8), - let arguments = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [:] - } - return arguments - } - - /// 检查是否已连接 - public func isConnected() -> Bool { - return isConnectedFlag || !localFunctions.isEmpty - } - - /// 关闭连接 - public func close() { - closeAllConnections() - localFunctions.removeAll() - localFunctionDefs.removeAll() - print("\(tag) 已关闭MCP客户端") - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift b/apps/client/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift deleted file mode 100644 index 35e40dc3..00000000 --- a/apps/client/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift +++ /dev/null @@ -1,869 +0,0 @@ -import Foundation -import UIKit -import os -import os.log - -/// OpenAI服务异常 -public struct OpenAIException: Error { - let message: String - - public init(_ message: String) { - self.message = message - } - - public var localizedDescription: String { - return "OpenAIException: \(message)" - } -} - -/// 流式回调协议 -public protocol StreamCallback { - func onToken(_ token: String) - func onComplete() - func onError(_ error: Error) - func onFunctionCall(_ functionCall: [String: Any]) - func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) -} - -/// 工具调用信息 -private class ToolCallInfo { - var id: String = "" - var name: String = "" - var arguments: String = "" - - func isValid() -> Bool { - return !id.isEmpty && !name.isEmpty - } -} - -/// OpenAI服务的原生实现 -public class OpenAIService: NSObject { - private let tag = "OpenAIService" - - // 日志对象 - private let logger = OSLog(subsystem: "com.yunqiinnovation.open_ai_service", category: "OpenAIService") - - // MARK: - 属性 - private var baseUrl = "" - private var apiKey = "" - private var model = "" - private var visionModel = "doubao-1-5-vision-pro-32k-250115" - private var isInitialized = false - - // MCP客户端 - private var mcpClient: MCPClient? - private var isMcpInitializedFlag = false - - // 流式请求相关 - private var currentStreamTask: URLSessionDataTask? - private var currentCallback: StreamCallback? - private var currentMessages: [[String: Any]] = [] - private var responseBuffer = Data() - private var toolCalls: [Int: ToolCallInfo] = [:] - private var isCanceled = false - - // 优化:添加行缓冲区,避免频繁字符串转换 - private var lineBuffer = Data() - private let newlineData = "\n".data(using: .utf8)! - - // 优化:添加重连机制 - private var retryCount = 0 - private let maxRetryCount = 3 - private var retryDelay: TimeInterval = 1.0 - - // 优化:添加性能监控 - private var streamStartTime: Date? - private var tokenCount = 0 - private var bytesReceived = 0 - - // URL会话 - private lazy var urlSession: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 30 - return URLSession(configuration: config) - }() - - // 流式会话(使用 delegate) - private lazy var streamSession: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 30 - - // 优化:减少网络缓存,避免内存压力 - config.urlCache = nil - config.requestCachePolicy = .reloadIgnoringLocalCacheData - - // 优化:限制并发连接数 - config.httpMaximumConnectionsPerHost = 2 - - // 优化:针对后台数据处理的线程配置 - let delegateQueue = OperationQueue() - // 使用utility QoS,平衡性能和功耗,不与UI线程竞争 - delegateQueue.qualityOfService = .utility - delegateQueue.maxConcurrentOperationCount = 1 - delegateQueue.name = "OpenAIService.StreamDelegate" - - return URLSession(configuration: config, delegate: self, delegateQueue: delegateQueue) - }() - - // MARK: - 初始化 - public override init() { - super.init() - } - - // MARK: - 公共方法 - - /// 初始化OpenAI服务 - public func initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String) -> Bool { - self.apiKey = apiKey - if !baseUrl.isEmpty { - self.baseUrl = baseUrl - } - if !model.isEmpty { - self.model = model - } - - // 初始化MCP客户端 - if mcpClient == nil { - mcpClient = MCPClient() - } - _ = initializeMcpClient(serverUrl: mcpServer) - - isInitialized = !apiKey.isEmpty - return isInitialized - } - - /// 创建用户消息 - public func createUserMessage(content: String) -> [String: Any] { - return [ - "role": "user", - "content": content - ] - } - - /// 创建助手消息 - public func createAssistantMessage(content: String) -> [String: Any] { - return [ - "role": "assistant", - "content": content - ] - } - - /// 创建系统消息 - public func createSystemMessage(content: String) -> [String: Any] { - return [ - "role": "system", - "content": content - ] - } - - /// 创建带图片的用户消息 - public func createUserMessageWithImage(text: String, imageBase64: String) -> [String: Any] { - var contentArray: [[String: Any]] = [] - - // 添加文本部分 - if !text.isEmpty { - contentArray.append([ - "type": "text", - "text": text - ]) - } - - // 添加图片部分 - contentArray.append([ - "type": "image_url", - "image_url": [ - "url": "data:image/jpeg;base64,\(imageBase64)" - ] - ]) - - return [ - "role": "user", - "content": contentArray - ] - } - - /// 发送消息(非流式输出) - public func sendMessage(messages: [[String: Any]]) throws -> String { - guard isInitialized && !apiKey.isEmpty else { - throw OpenAIException("OpenAI服务未初始化") - } - - // 检查是否包含图片,决定使用哪个模型 - var currentModel = model - if let lastMessage = messages.last, - let content = lastMessage["content"] as? String, - content.contains("image_url") { - currentModel = visionModel - } - - // 构建请求体 - var requestBody: [String: Any] = [ - "model": currentModel, - "messages": messages, - "temperature": 0.7, - "max_tokens": 2000, - "stream": false - ] - - // 添加工具列表 - if let tools = mcpClient?.getToolMaps(), !tools.isEmpty { - requestBody["tools"] = tools - } - - // 创建请求 - guard let url = URL(string: baseUrl), - let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { - throw OpenAIException("创建请求失败") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.httpBody = jsonData - - // 同步请求 - let semaphore = DispatchSemaphore(value: 0) - var result: String = "" - var error: Error? - - let task = urlSession.dataTask(with: request) { data, response, taskError in - defer { semaphore.signal() } - - if let taskError = taskError { - error = OpenAIException("请求失败: \(taskError.localizedDescription)") - return - } - - guard let httpResponse = response as? HTTPURLResponse else { - error = OpenAIException("无效响应") - return - } - - guard httpResponse.statusCode == 200 else { - error = OpenAIException("API调用失败: \(httpResponse.statusCode)") - return - } - - guard let data = data, - let jsonResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - error = OpenAIException("响应解析失败") - return - } - - // 检查是否有函数调用 - if let choices = jsonResponse["choices"] as? [[String: Any]], - let choice = choices.first, - let message = choice["message"] as? [String: Any] { - - // 检查是否有工具调用 - if let toolCalls = message["tool_calls"] as? [[String: Any]], - let toolCall = toolCalls.first, - let function = toolCall["function"] as? [String: Any], - let name = function["name"] as? String, - let arguments = function["arguments"] as? String, - let id = toolCall["id"] as? String { - - let functionCallJson: [String: Any] = [ - "name": name, - "arguments": arguments, - "id": id - ] - - if let jsonData = try? JSONSerialization.data(withJSONObject: functionCallJson), - let jsonString = String(data: jsonData, encoding: .utf8) { - result = jsonString - } else { - error = OpenAIException("函数调用序列化失败") - } - return - } - - // 如果没有工具调用,返回消息内容 - if let content = message["content"] as? String { - result = content - return - } - } - - error = OpenAIException("无效的响应格式") - } - - task.resume() - semaphore.wait() - - if let error = error { - throw error - } - - return result - } - - /// 发送消息(流式输出) - public func sendMessageStream(messages: [[String: Any]], callback: StreamCallback) { - guard isInitialized && !apiKey.isEmpty else { - callback.onError(OpenAIException("OpenAI服务未初始化")) - return - } - - // 重置状态 - isCanceled = false - currentCallback = callback - currentMessages = messages - responseBuffer = Data() - toolCalls = [:] - - // 优化:初始化性能监控 - streamStartTime = Date() - tokenCount = 0 - bytesReceived = 0 - - // 检查是否包含图片,决定使用哪个模型 - var currentModel = model - if let lastMessage = messages.last, - let content = lastMessage["content"] as? String, - content.contains("image_url") { - currentModel = visionModel - } - - // 构建请求体 - var requestBody: [String: Any] = [ - "model": currentModel, - "messages": messages, - "temperature": 0.7, - "max_tokens": 2000, - "stream": true - ] - - // 添加工具列表 - if let tools = mcpClient?.getToolMaps(), !tools.isEmpty { - requestBody["tools"] = tools - } - - // 创建请求 - guard let url = URL(string: baseUrl), - let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { - callback.onError(OpenAIException("创建请求失败")) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("text/event-stream", forHTTPHeaderField: "Accept") - request.setValue("cache-control", forHTTPHeaderField: "no-cache") - request.httpBody = jsonData - - os_log("开始流式请求: %{public}@", log: logger, type: .info, url.absoluteString) - - // 使用 delegate 模式的 URLSession 创建任务 - let task = streamSession.dataTask(with: request) - currentStreamTask = task - task.resume() - } - - /// 处理缓冲区数据 - private func processBufferedData(callback: StreamCallback, isComplete: Bool = false) { - // 优化:使用流式解析,避免频繁的字符串转换 - processStreamData(callback: callback, isComplete: isComplete) - } - - /// 优化的流式数据处理 - private func processStreamData(callback: StreamCallback, isComplete: Bool = false) { - lineBuffer.append(responseBuffer) - responseBuffer = Data() - - var searchRange = lineBuffer.startIndex.. Bool { - guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid() else { - return false - } - - // 创建函数调用字典 - let functionCall: [String: Any] = [ - "name": firstToolCall.name, - "arguments": firstToolCall.arguments, - "id": firstToolCall.id - ] - - os_log("工具调用: id=%{public}@, name=%{public}@, arguments=%{public}@", log: logger, type: .info, firstToolCall.id, firstToolCall.name, firstToolCall.arguments) - - // 通知上层工具调用事件 - callback.onFunctionCall(functionCall) - - // 在后台队列处理工具调用 - Task { - do { - if !self.isCanceled { - // 调用工具 - let result: [String: Any] - do { - let args = try self.parseJsonArguments(firstToolCall.arguments) - result = try await self.mcpClient?.callTool(name: firstToolCall.name, arguments: args) ?? ["context": "工具调用失败"] - } catch { - os_log("工具调用错误: %{public}@", log: logger, type: .error, error.localizedDescription) - result = ["context": "工具调用失败: \(error.localizedDescription)"] - } - - if !self.isCanceled { - // 处理结果 - callback.onFunctionCallResult(functionCall, result) - - let context = result["context"] as? String ?? "工具调用失败" - os_log("MCP工具调用完成: name=%{public}@, context=%{public}@", log: logger, type: .info, firstToolCall.name, context) - - // 将结果发送回OpenAI继续对话 - self.sendFunctionCallResult( - messages: self.currentMessages, - functionCall: functionCall, - functionResult: context, - callback: callback - ) - } - } - } catch { - if !self.isCanceled { - os_log("处理工具调用异常: %{public}@", log: logger, type: .error, error.localizedDescription) - let errorMessage = "工具调用处理失败: \(error.localizedDescription)" - self.sendFunctionCallResult( - messages: self.currentMessages, - functionCall: functionCall, - functionResult: errorMessage, - callback: callback - ) - } - } - } - - return true - } - - /// 发送函数调用结果 - public func sendFunctionCallResult(messages: [[String: Any]], functionCall: [String: Any], functionResult: String, callback: StreamCallback) { - if isCanceled { - os_log("请求已取消,不发送函数调用结果", log: logger, type: .info) - return - } - - var fullMessages = messages - - // 添加函数调用消息 - let callId = functionCall["id"] as? String ?? "call_\(Int(Date().timeIntervalSince1970))" - fullMessages.append([ - "role": "assistant", - "content": "", - "tool_calls": [[ - "id": callId, - "type": "function", - "function": [ - "name": functionCall["name"] as? String ?? "", - "arguments": functionCall["arguments"] as? String ?? "" - ] - ]] - ]) - - // 添加函数调用结果 - fullMessages.append([ - "role": "tool", - "content": functionResult, - "tool_call_id": callId - ]) - - // 发送完整对话 - sendMessageStream(messages: fullMessages, callback: callback) - } - - /// 取消当前流式请求 - public func cancelCurrentStream() -> Bool { - isCanceled = true - currentStreamTask?.cancel() - - // 优化:更彻底的状态清理 - cleanupStreamState() - - os_log("已取消当前流式请求", log: logger, type: .info) - return true - } - - /// 优化:统一的状态清理方法 - private func cleanupStreamState() { - currentStreamTask = nil - currentCallback = nil - currentMessages.removeAll() - responseBuffer = Data() - lineBuffer = Data() - toolCalls.removeAll() - retryCount = 0 - - // 优化:重置性能监控变量 - streamStartTime = nil - tokenCount = 0 - bytesReceived = 0 - } - - /// 取消所有操作并释放资源 - public func cancelAll() { - _ = cancelCurrentStream() - } - - // MARK: - MCP相关方法 - - /// 初始化MCP客户端 - public func initializeMcpClient(serverUrl: String) -> Bool { - if mcpClient != nil { - mcpClient?.close() - } - - mcpClient = MCPClient() - - Task { - do { - let result = try await mcpClient?.connectToSSE(mcpConfigJson: serverUrl) ?? false - isMcpInitializedFlag = result - os_log("MCP客户端初始化%{public}@", log: logger, type: .info, result ? "成功" : "失败") - } catch { - os_log("MCP客户端初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) - isMcpInitializedFlag = false - } - } - - return true // 立即返回,实际连接在后台进行 - } - - /// MCP客户端是否已初始化 - public func isMcpInitialized() -> Bool { - return isMcpInitializedFlag && mcpClient?.isConnected() == true - } - - /// 关闭MCP客户端 - public func closeMcpClient() { - mcpClient?.close() - mcpClient = nil - isMcpInitializedFlag = false - } - - /// 注册函数 - public func registerFunction(name: String, description: String, parameters: [String: Any]) -> Bool { - guard let mcpClient = mcpClient else { - os_log("MCP客户端未初始化", log: logger, type: .error) - return false - } - - // 创建函数处理器 - let handler = LocalFunctionHandler(name: name) - - // 注册本地函数 - return mcpClient.registerLocalFunction( - name: name, - description: description, - parameters: parameters, - handler: handler - ) - } - - /// 处理MCP工具调用 - public func handleMcpToolCall(functionCallJson: String) async throws -> String { - guard let mcpClient = mcpClient, isMcpInitializedFlag else { - return "MCP客户端未初始化" - } - - guard let jsonData = functionCallJson.data(using: .utf8), - let functionCall = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], - let name = functionCall["name"] as? String, - let argumentsJson = functionCall["arguments"] as? String else { - throw OpenAIException("解析函数调用失败") - } - - let arguments = try parseJsonArguments(argumentsJson) - let result = try await mcpClient.callTool(name: name, arguments: arguments) - - return result["context"] as? String ?? "无法处理MCP工具调用" - } - - // MARK: - 工具方法 - - /// 解析JSON参数 - private func parseJsonArguments(_ argumentsJson: String) throws -> [String: Any] { - guard let data = argumentsJson.data(using: .utf8), - let arguments = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [:] - } - return arguments - } - - /// 检查字符串是否为有效的JSON - private func isValidJson(jsonString: String) -> Bool { - guard !jsonString.isEmpty, - jsonString.hasPrefix("{"), - jsonString.hasSuffix("}") else { - return false - } - - var braceCount = 0 - var insideQuotes = false - var escapeNext = false - - for char in jsonString { - if escapeNext { - escapeNext = false - continue - } - - switch char { - case "\\": - escapeNext = true - case "\"": - if !escapeNext { - insideQuotes.toggle() - } - case "{": - if !insideQuotes { - braceCount += 1 - } - case "}": - if !insideQuotes { - braceCount -= 1 - } - default: - break - } - - // 如果括号不匹配(闭合太多),提前返回 - if braceCount < 0 { - return false - } - } - - // 所有引号都应该闭合,所有括号也应该匹配 - return braceCount == 0 && !insideQuotes - } - - /// 将文件转换为Base64字符串 - public func fileToBase64(filePath: String, maxSizeKB: Int = 20480) -> String? { - guard let image = UIImage(contentsOfFile: filePath) else { - os_log("文件不存在或无法读取: %{public}@", log: logger, type: .error, filePath) - return nil - } - - var processedImage = image - - // 检查图片尺寸,限制最大为1024*1024 - let maxDimension: CGFloat = 1024 - if image.size.width > maxDimension || image.size.height > maxDimension { - let ratio = min(maxDimension / image.size.width, maxDimension / image.size.height) - let newSize = CGSize(width: image.size.width * ratio, height: image.size.height * ratio) - - UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) - image.draw(in: CGRect(origin: .zero, size: newSize)) - processedImage = UIGraphicsGetImageFromCurrentImageContext() ?? image - UIGraphicsEndImageContext() - } - - // 压缩图片 - guard let imageData = processedImage.jpegData(compressionQuality: 0.8) else { - return nil - } - - return imageData.base64EncodedString() - } -} - -// MARK: - URLSessionDataDelegate Extension -extension OpenAIService: URLSessionDataDelegate { - - /// 接收到响应头 - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - guard let httpResponse = response as? HTTPURLResponse else { - if !isCanceled { - // 直接回调,由调用方处理线程切换 - currentCallback?.onError(OpenAIException("无效响应")) - } - completionHandler(.cancel) - return - } - - guard httpResponse.statusCode == 200 else { - if !isCanceled { - // 直接回调,由调用方处理线程切换 - currentCallback?.onError(OpenAIException("API调用失败: \(httpResponse.statusCode)")) - } - completionHandler(.cancel) - return - } - - os_log("收到响应头,状态码: %d", log: logger, type: .info, httpResponse.statusCode) - completionHandler(.allow) - } - - /// 实时接收数据块 - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - guard !isCanceled, let callback = currentCallback else { return } - - // 优化:更新性能指标 - bytesReceived += data.count - - // 将新数据添加到缓冲区 - responseBuffer.append(data) - - // 处理缓冲区中的完整行 - processBufferedData(callback: callback) - } - - /// 请求完成 - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didCompleteWithError error: Error?) { - defer { - // 优化:使用统一的清理方法 - cleanupStreamState() - } - - guard !isCanceled else { - os_log("流式请求已取消", log: logger, type: .info) - return - } - - if let error = error { - os_log("流式请求错误: %{public}@", log: logger, type: .error, error.localizedDescription) - // 直接回调,由调用方处理线程切换 - currentCallback?.onError(OpenAIException("请求失败: \(error.localizedDescription)")) - return - } - - // 处理剩余的缓冲区数据 - if let callback = currentCallback { - processBufferedData(callback: callback, isComplete: true) - } - - // 优化:输出性能统计 - if let startTime = streamStartTime { - let duration = Date().timeIntervalSince(startTime) - let tokensPerSecond = duration > 0 ? Double(tokenCount) / duration : 0 - os_log("流式请求性能统计 - 耗时: %.2fs, tokens: %d, 字节: %d, tokens/s: %.2f", - log: logger, type: .info, duration, tokenCount, bytesReceived, tokensPerSecond) - } - - os_log("流式请求完成", log: logger, type: .info) - } -} - -/// 本地函数处理器 -private class LocalFunctionHandler: FunctionHandler { - private let functionName: String - - init(name: String) { - self.functionName = name - } - - func handle(arguments: [String: Any]) async throws -> String { - // 由于本地函数的实际处理是在Flutter端完成的 - // 这里只需返回一个标记,表示该函数是本地函数 - return "LOCAL_FUNCTION:\(functionName)" - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift b/apps/client/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift deleted file mode 100644 index bf99d940..00000000 --- a/apps/client/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift +++ /dev/null @@ -1,340 +0,0 @@ -import Flutter -import UIKit -import Foundation - -/// OpenAI服务插件主类 -public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler { - - // MARK: - 常量 - private static let methodChannelName = "com.yunqiinnovation.open_ai_service/methods" - private static let eventChannelName = "com.yunqiinnovation.open_ai_service/events" - - // MARK: - 属性 - private var methodChannel: FlutterMethodChannel? - private var eventChannel: FlutterEventChannel? - private var eventSink: FlutterEventSink? - private var openAIService: OpenAIService? - - // MARK: - 插件注册 - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = OpenAIServicePlugin() - - // 初始化方法通道 - let methodChannel = FlutterMethodChannel( - name: methodChannelName, - binaryMessenger: registrar.messenger() - ) - instance.methodChannel = methodChannel - registrar.addMethodCallDelegate(instance, channel: methodChannel) - - // 初始化事件通道 - let eventChannel = FlutterEventChannel( - name: eventChannelName, - binaryMessenger: registrar.messenger() - ) - instance.eventChannel = eventChannel - eventChannel.setStreamHandler(instance) - - // 初始化OpenAI服务 - instance.openAIService = OpenAIService() - } - - // MARK: - FlutterPlugin - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let service = openAIService else { - result(FlutterError(code: "SERVICE_NOT_INITIALIZED", - message: "OpenAI服务未初始化", - details: nil)) - return - } - - switch call.method { - case "initialize": - handleInitialize(call: call, result: result, service: service) - - case "registerFunction": - handleRegisterFunction(call: call, result: result, service: service) - - case "sendMessage": - handleSendMessage(call: call, result: result, service: service) - - case "sendMessageStream": - handleSendMessageStream(call: call, result: result, service: service) - - case "sendFunctionCallResult": - handleSendFunctionCallResult(call: call, result: result, service: service) - - case "createUserMessage": - handleCreateUserMessage(call: call, result: result, service: service) - - case "createAssistantMessage": - handleCreateAssistantMessage(call: call, result: result, service: service) - - case "initializeMcpClient": - handleInitializeMcpClient(call: call, result: result, service: service) - - case "isMcpInitialized": - handleIsMcpInitialized(call: call, result: result, service: service) - - case "closeMcpClient": - handleCloseMcpClient(call: call, result: result, service: service) - - case "handleMcpToolCall": - handleMcpToolCall(call: call, result: result, service: service) - - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - FlutterStreamHandler - public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - self.eventSink = events - return nil - } - - public func onCancel(withArguments arguments: Any?) -> FlutterError? { - self.eventSink = nil - return nil - } - - // MARK: - 方法处理 - private func handleInitialize(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let apiKey = args["apiKey"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少必要参数", - details: nil)) - return - } - - let baseUrl = args["baseUrl"] as? String ?? "" - let model = args["model"] as? String ?? "" - let mcpServer = args["mcpServer"] as? String ?? "" - - let success = service.initialize( - apiKey: apiKey, - baseUrl: baseUrl, - model: model, - mcpServer: mcpServer - ) - - result(success) - } - - private func handleRegisterFunction(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let name = args["name"] as? String, - let description = args["description"] as? String, - let parameters = args["parameters"] as? [String: Any] else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少必要参数", - details: nil)) - return - } - - let success = service.registerFunction(name: name, description: description, parameters: parameters) - result(success) - } - - private func handleSendMessage(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let messagesArray = args["messages"] as? [[String: Any]] else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少消息参数", - details: nil)) - return - } - - do { - let response = try service.sendMessage(messages: messagesArray) - result(response) - } catch { - result(FlutterError(code: "OPENAI_ERROR", - message: error.localizedDescription, - details: nil)) - } - } - - private func handleSendMessageStream(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let messagesArray = args["messages"] as? [[String: Any]] else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少消息参数", - details: nil)) - return - } - - guard let eventSink = self.eventSink else { - result(FlutterError(code: "NO_EVENT_SINK", - message: "没有可用的事件流接收器", - details: nil)) - return - } - - // 创建回调处理器 - let callback = StreamCallbackImpl(eventSink: eventSink) - - // 发送流式消息 - service.sendMessageStream(messages: messagesArray, callback: callback) - - result(true) - } - - private func handleSendFunctionCallResult(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let messagesArray = args["messages"] as? [[String: Any]], - let functionCall = args["functionCall"] as? [String: Any], - let functionResult = args["functionResult"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少必要参数", - details: nil)) - return - } - - guard let eventSink = self.eventSink else { - result(FlutterError(code: "NO_EVENT_SINK", - message: "没有可用的事件流接收器", - details: nil)) - return - } - - // 创建回调处理器 - let callback = StreamCallbackImpl(eventSink: eventSink) - - // 发送函数调用结果 - service.sendFunctionCallResult( - messages: messagesArray, - functionCall: functionCall, - functionResult: functionResult, - callback: callback - ) - - result(true) - } - - private func handleCreateUserMessage(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let content = args["content"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少内容参数", - details: nil)) - return - } - - let message = service.createUserMessage(content: content) - result(message) - } - - private func handleCreateAssistantMessage(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let content = args["content"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少内容参数", - details: nil)) - return - } - - let message = service.createAssistantMessage(content: content) - result(message) - } - - private func handleInitializeMcpClient(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let serverUrl = args["serverUrl"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少服务器URL参数", - details: nil)) - return - } - - let success = service.initializeMcpClient(serverUrl: serverUrl) - result(success) - } - - private func handleIsMcpInitialized(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - let isInitialized = service.isMcpInitialized() - result(isInitialized) - } - - private func handleCloseMcpClient(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - service.closeMcpClient() - result(true) - } - - private func handleMcpToolCall(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let functionCallJson = args["functionCall"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少函数调用参数", - details: nil)) - return - } - - Task { - do { - let toolResult = try await service.handleMcpToolCall(functionCallJson: functionCallJson) - DispatchQueue.main.async { - result(toolResult) - } - } catch { - DispatchQueue.main.async { - result(FlutterError(code: "MCP_TOOL_ERROR", - message: "处理MCP工具调用失败: \(error.localizedDescription)", - details: nil)) - } - } - } - } -} - -// MARK: - 流式回调实现 -private class StreamCallbackImpl: StreamCallback { - private let eventSink: FlutterEventSink - - init(eventSink: @escaping FlutterEventSink) { - self.eventSink = eventSink - } - - func onToken(_ token: String) { - DispatchQueue.main.async { - self.eventSink(["type": "token", "content": token]) - } - } - - func onComplete() { - DispatchQueue.main.async { - self.eventSink(["type": "complete"]) - } - } - - func onError(_ error: Error) { - DispatchQueue.main.async { - self.eventSink(["type": "error", "content": error.localizedDescription]) - } - } - - func onFunctionCall(_ functionCall: [String: Any]) { - DispatchQueue.main.async { - if let jsonData = try? JSONSerialization.data(withJSONObject: functionCall), - let jsonString = String(data: jsonData, encoding: .utf8) { - self.eventSink(["type": "function_call", "content": jsonString]) - } - } - } - - func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) { - DispatchQueue.main.async { - if let callData = try? JSONSerialization.data(withJSONObject: functionCall), - let callString = String(data: callData, encoding: .utf8), - let resultData = try? JSONSerialization.data(withJSONObject: functionCallResult), - let resultString = String(data: resultData, encoding: .utf8) { - self.eventSink([ - "type": "function_call_result", - "function_call": callString, - "result": resultString - ]) - } - } - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift b/apps/client/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift deleted file mode 100644 index 7c577e70..00000000 --- a/apps/client/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift +++ /dev/null @@ -1,680 +0,0 @@ -import Foundation -import UIKit -import EventKit -import CoreLocation -import MediaPlayer -import AVFoundation - -/// 操作结果 -public struct Result { - let success: Bool - let message: String - - public init(success: Bool, message: String) { - self.success = success - self.message = message - } -} - -/// 系统功能处理器 - 负责调用系统API执行各种功能以及注册系统功能 -public class SystemFunctionHandler: NSObject { - private let tag = "SystemFunctionHandler" - - // 位置管理器 - private var locationManager: CLLocationManager? - private var locationCompletion: ((Result) -> Void)? - - // 事件存储 - private var eventStore: EKEventStore? - - public override init() { - super.init() - setupLocationManager() - setupEventStore() - } - - /// 设置位置管理器 - private func setupLocationManager() { - locationManager = CLLocationManager() - locationManager?.delegate = self - locationManager?.desiredAccuracy = kCLLocationAccuracyBest - } - - /// 设置事件存储 - private func setupEventStore() { - eventStore = EKEventStore() - } - - /// 在MCPClient中注册所有系统功能 - /// - Parameter client: MCPClient实例 - public func registerAllFunctions(client: MCPClient) { - do { - // 注册退出交互函数 - registerExitInteractionFunction(client: client) - - // 注册翻译模式函数 - registerTranslationModeFunction(client: client) - - // 注册设置提醒函数 - registerSetReminderFunction(client: client) - - // 注册获取当前时间函数 - registerGetCurrentTimeFunction(client: client) - - // 注册获取当前位置函数 - registerGetCurrentLocationFunction(client: client) - - // 注册媒体控制函数 - registerMediaPlayFunction(client: client) - registerMediaPauseFunction(client: client) - registerMediaPreviousFunction(client: client) - registerMediaNextFunction(client: client) - - // 注册打开录音机功能 - registerOpenRecorderFunction(client: client) - - print("\(tag) 所有系统功能注册完成") - } catch { - print("\(tag) 注册系统功能失败: \(error.localizedDescription)") - } - } - - // MARK: - 函数注册 - - /// 注册退出交互函数 - private func registerExitInteractionFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = ExitInteractionHandler() - _ = client.registerLocalFunction( - name: "exit_interaction", - description: "结束当前交互", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册退出交互功能") - } - - /// 注册翻译模式函数 - private func registerTranslationModeFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = TranslationModeHandler() - _ = client.registerLocalFunction( - name: "enter_translation_mode", - description: "用户请求进入实时翻译模式时,启动实时翻译功能", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册翻译模式功能") - } - - /// 注册设置提醒函数 - private func registerSetReminderFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [ - "title": [ - "type": "string", - "description": "提醒标题" - ], - "content": [ - "type": "string", - "description": "提醒内容" - ], - "time": [ - "type": "string", - "description": "提醒时间,格式为'yyyy-MM-dd HH:mm',如'2023-12-31 14:30'" - ] - ], - "required": ["title", "time"] - ] - - let handler = SetReminderHandler(eventStore: eventStore) - _ = client.registerLocalFunction( - name: "set_reminder", - description: "设置提醒事项", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册设置提醒功能") - } - - /// 注册获取当前时间函数 - private func registerGetCurrentTimeFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [ - "format": [ - "type": "string", - "enum": ["short", "date", "time", "full", "iso"], - "description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。" - ] - ], - "required": [] - ] - - let handler = GetCurrentTimeHandler() - _ = client.registerLocalFunction( - name: "get_current_time", - description: "获取当前日期和时间,返回值已按当前语言本地化并对TTS友好(不含冒号等易被误读的符号)。播报或回复给用户时必须原样使用 result 字段的文本,不要自行改写为 HH:mm:ss 等格式。", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册获取当前时间功能") - } - - /// 注册获取当前位置函数 - private func registerGetCurrentLocationFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = GetCurrentLocationHandler(systemHandler: self) - _ = client.registerLocalFunction( - name: "get_current_location", - description: "获取当前地理位置", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册获取当前位置功能") - } - - /// 注册媒体播放功能 - private func registerMediaPlayFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = MediaPlayHandler() - _ = client.registerLocalFunction( - name: "media_play", - description: "播放媒体", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册媒体播放功能") - } - - /// 注册媒体暂停功能 - private func registerMediaPauseFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = MediaPauseHandler() - _ = client.registerLocalFunction( - name: "media_pause", - description: "暂停媒体播放", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册媒体暂停功能") - } - - /// 注册媒体上一首功能 - private func registerMediaPreviousFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = MediaPreviousHandler() - _ = client.registerLocalFunction( - name: "media_previous", - description: "播放上一首", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册媒体上一首功能") - } - - /// 注册媒体下一首功能 - private func registerMediaNextFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = MediaNextHandler() - _ = client.registerLocalFunction( - name: "media_next", - description: "播放下一首", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册媒体下一首功能") - } - - /// 注册打开录音机功能 - private func registerOpenRecorderFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = OpenRecorderHandler() - _ = client.registerLocalFunction( - name: "open_recorder", - description: "打开系统录音机并开始录音", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册打开录音机功能") - } - - // MARK: - 内部方法 - - /// 获取当前位置 - internal func getCurrentLocation() async -> Result { - return await withCheckedContinuation { continuation in - locationCompletion = { result in - continuation.resume(returning: result) - } - - guard let locationManager = locationManager else { - continuation.resume(returning: Result(success: false, message: "位置管理器未初始化")) - return - } - - // 检查位置权限 - let authStatus = locationManager.authorizationStatus - switch authStatus { - case .notDetermined: - locationManager.requestWhenInUseAuthorization() - return - case .denied, .restricted: - continuation.resume(returning: Result(success: false, message: "位置权限被拒绝")) - return - case .authorizedWhenInUse, .authorizedAlways: - break - @unknown default: - continuation.resume(returning: Result(success: false, message: "未知的位置权限状态")) - return - } - - // 检查位置服务是否可用 - guard CLLocationManager.locationServicesEnabled() else { - continuation.resume(returning: Result(success: false, message: "位置服务未启用")) - return - } - - // 请求位置 - locationManager.requestLocation() - } - } -} - -// MARK: - CLLocationManagerDelegate -extension SystemFunctionHandler: CLLocationManagerDelegate { - public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.last else { - locationCompletion?(Result(success: false, message: "无法获取位置信息")) - return - } - - let latitude = location.coordinate.latitude - let longitude = location.coordinate.longitude - let message = "当前位置:纬度 \(latitude),经度 \(longitude)" - - locationCompletion?(Result(success: true, message: message)) - locationCompletion = nil - } - - public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - locationCompletion?(Result(success: false, message: "获取位置失败:\(error.localizedDescription)")) - locationCompletion = nil - } - - public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - if status == .authorizedWhenInUse || status == .authorizedAlways { - manager.requestLocation() - } else if status == .denied || status == .restricted { - locationCompletion?(Result(success: false, message: "位置权限被拒绝")) - locationCompletion = nil - } - } -} - -// MARK: - 功能处理器实现 - -/// 退出交互处理器 -private class ExitInteractionHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - // 发送退出交互通知 - NotificationCenter.default.post(name: NSNotification.Name("ExitInteraction"), object: nil) - return "{\"result\": \"已结束当前交互\"}" - } -} - -/// 翻译模式处理器 -private class TranslationModeHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - // 发送进入翻译模式通知 - NotificationCenter.default.post(name: NSNotification.Name("EnterTranslationMode"), object: nil) - return "{\"result\": \"已进入翻译模式\"}" - } -} - -/// 设置提醒处理器 -private class SetReminderHandler: FunctionHandler { - private let eventStore: EKEventStore? - - init(eventStore: EKEventStore?) { - self.eventStore = eventStore - } - - func handle(arguments: [String: Any]) async throws -> String { - guard let title = arguments["title"] as? String, - let timeString = arguments["time"] as? String else { - throw OpenAIException("缺少必要参数") - } - - let content = arguments["content"] as? String ?? "" - let result = await setReminder(title: title, content: content, time: timeString) - return "{\"result\": \"\(result.message)\"}" - } - - private func setReminder(title: String, content: String, time: String) async -> Result { - guard let eventStore = eventStore else { - return Result(success: false, message: "事件存储未初始化") - } - - // 请求日历权限 - let status = await requestCalendarAccess() - guard status == .authorized else { - return Result(success: false, message: "日历权限被拒绝") - } - - // 解析时间 - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm" - guard let date = formatter.date(from: time) else { - return Result(success: false, message: "时间格式错误") - } - - // 创建提醒 - let reminder = EKReminder(eventStore: eventStore) - reminder.title = title - reminder.notes = content - reminder.calendar = eventStore.defaultCalendarForNewReminders() - - let alarm = EKAlarm(absoluteDate: date) - reminder.addAlarm(alarm) - - do { - try eventStore.save(reminder, commit: true) - return Result(success: true, message: "提醒设置成功") - } catch { - return Result(success: false, message: "设置提醒失败:\(error.localizedDescription)") - } - } - - private func requestCalendarAccess() async -> EKAuthorizationStatus { - return await withCheckedContinuation { continuation in - eventStore?.requestAccess(to: .reminder) { granted, error in - let status = EKEventStore.authorizationStatus(for: .reminder) - continuation.resume(returning: status) - } - } - } -} - -/// 获取当前时间处理器 -private class GetCurrentTimeHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let format = arguments["format"] as? String - let result = getCurrentTime(format: format) - return "{\"result\": \"\(result.message)\", \"time\": \"\(result.message)\"}" - } - - private func getCurrentTime(format: String?) -> Result { - 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) - } - - // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字 - // CJK 使用单位数 m/s(去掉前导零),其他语言使用英文单词避免冒号 - private func formatShortTime(_ date: Date, lang: String) -> String { - let f = DateFormatter() - switch lang { - case "zh": f.dateFormat = "H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) - case "ja": f.dateFormat = "H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) - case "ko": f.dateFormat = "H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) - default: return formatTimeWestern(date, withSeconds: false) - } - } - - 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时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) - case "ja": f.dateFormat = "H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) - case "ko": f.dateFormat = "H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) - default: return formatTimeWestern(date, withSeconds: true) - } - } - - private func formatFull(_ date: Date, lang: String) -> String { - let f = DateFormatter() - switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) - case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) - case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) - default: - f.dateFormat = "MMMM d, yyyy, EEEE, "; f.locale = Locale.current - return f.string(from: date) + formatTimeWestern(date, withSeconds: true) - } - } - - private func formatDefault(_ date: Date, lang: String) -> String { - let f = DateFormatter() - switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) - case "ja": f.dateFormat = "yyyy年M月d日 H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) - case "ko": f.dateFormat = "yyyy년 M월 d일 H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) - default: - f.dateFormat = "MMMM d, yyyy, "; f.locale = Locale.current - return f.string(from: date) + formatTimeWestern(date, withSeconds: false) - } - } - - /// 非 CJK 语言的时间格式:避免冒号,使用英文单词,对 TTS 更稳定 - private func formatTimeWestern(_ date: Date, withSeconds: Bool) -> String { - let cal = Calendar.current - let hour24 = cal.component(.hour, from: date) - let minute = cal.component(.minute, from: date) - let second = cal.component(.second, from: date) - let h12Raw = hour24 % 12 - let h12 = h12Raw == 0 ? 12 : h12Raw - let ampm = hour24 < 12 ? "AM" : "PM" - - var parts: [String] = [] - parts.append("\(h12) \(h12 == 1 ? "hour" : "hours")") - if minute > 0 || withSeconds { - parts.append("\(minute) \(minute == 1 ? "minute" : "minutes")") - } - if withSeconds { - parts.append("\(second) \(second == 1 ? "second" : "seconds")") - } - parts.append(ampm) - return parts.joined(separator: " ") - } -} - -/// 获取当前位置处理器 -private class GetCurrentLocationHandler: FunctionHandler { - private weak var systemHandler: SystemFunctionHandler? - - init(systemHandler: SystemFunctionHandler) { - self.systemHandler = systemHandler - } - - func handle(arguments: [String: Any]) async throws -> String { - guard let systemHandler = systemHandler else { - throw OpenAIException("系统处理器未初始化") - } - - let result = await systemHandler.getCurrentLocation() - return "{\"result\": \"\(result.message)\", \"success\": \(result.success)}" - } -} - -/// 媒体播放处理器 -private class MediaPlayHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = controlMedia(action: "play") - return "{\"result\": \"\(result.message)\"}" - } - - private func controlMedia(action: String) -> Result { - let commandCenter = MPRemoteCommandCenter.shared() - - switch action { - case "play": - // 发送播放命令通知 - NotificationCenter.default.post(name: NSNotification.Name("MediaPlay"), object: nil) - return Result(success: true, message: "已开始播放媒体") - default: - return Result(success: false, message: "未知的媒体操作") - } - } -} - -/// 媒体暂停处理器 -private class MediaPauseHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = controlMedia(action: "pause") - return "{\"result\": \"\(result.message)\"}" - } - - private func controlMedia(action: String) -> Result { - switch action { - case "pause": - // 发送暂停命令通知 - NotificationCenter.default.post(name: NSNotification.Name("MediaPause"), object: nil) - return Result(success: true, message: "已暂停媒体播放") - default: - return Result(success: false, message: "未知的媒体操作") - } - } -} - -/// 媒体上一首处理器 -private class MediaPreviousHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = controlMedia(action: "previous") - return "{\"result\": \"\(result.message)\"}" - } - - private func controlMedia(action: String) -> Result { - switch action { - case "previous": - // 发送上一首命令通知 - NotificationCenter.default.post(name: NSNotification.Name("MediaPrevious"), object: nil) - return Result(success: true, message: "已切换到上一首") - default: - return Result(success: false, message: "未知的媒体操作") - } - } -} - -/// 媒体下一首处理器 -private class MediaNextHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = controlMedia(action: "next") - return "{\"result\": \"\(result.message)\"}" - } - - private func controlMedia(action: String) -> Result { - switch action { - case "next": - // 发送下一首命令通知 - NotificationCenter.default.post(name: NSNotification.Name("MediaNext"), object: nil) - return Result(success: true, message: "已切换到下一首") - default: - return Result(success: false, message: "未知的媒体操作") - } - } -} - -/// 打开录音机处理器 -private class OpenRecorderHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = openRecorder() - return "{\"result\": \"\(result.message)\"}" - } - - private func openRecorder() -> Result { - // 尝试打开语音备忘录应用 - if let url = URL(string: "voicememos://") { - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - return Result(success: true, message: "已打开语音备忘录") - } - } - - return Result(success: false, message: "无法打开录音机应用") - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Package.swift b/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Package.swift deleted file mode 100644 index 64aa6b14..00000000 --- a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Package.swift +++ /dev/null @@ -1,44 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "open_ai_service", - platforms: [.iOS("13.0")], - products: [ - .library(name: "open-ai-service", targets: ["open_ai_service"]) - ], - dependencies: [ - // OpenAI Swift SDK - .package(url: "https://github.com/MacPaw/OpenAI.git", from: "0.4.3") - ], - targets: [ - .target( - name: "open_ai_service", - dependencies: [ - .product(name: "OpenAI", package: "OpenAI") - ], - path: "Sources/open_ai_service", - linkerSettings: [ - .linkedFramework("Foundation"), - .linkedFramework("UIKit"), - .linkedFramework("EventKit"), - .linkedFramework("CoreLocation"), - .linkedFramework("MediaPlayer"), - .linkedFramework("AVFoundation"), - .linkedFramework("AudioToolbox"), - .linkedFramework("Accelerate"), - .linkedFramework("QuartzCore"), - .linkedFramework("Security"), - .linkedFramework("SystemConfiguration"), - .linkedFramework("CoreTelephony"), - .linkedFramework("CFNetwork"), - .linkedFramework("CoreGraphics"), - .linkedFramework("MobileCoreServices"), - .linkedFramework("CoreData"), - .linkedFramework("ImageIO"), - .linkedFramework("CoreText"), - .linkedFramework("CoreImage") - ] - ) - ] -) \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/MCPClient.swift b/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/MCPClient.swift deleted file mode 100644 index 121653f0..00000000 --- a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/MCPClient.swift +++ /dev/null @@ -1,366 +0,0 @@ -import Foundation - -/// 工具类型枚举 -public enum ToolType { - case localFunction // 本地函数 - case mcpTool // MCP工具 -} - -/// 函数处理器协议 -public protocol FunctionHandler { - /// 处理函数调用 - /// - Parameter arguments: 函数参数,字典格式 - /// - Returns: 函数执行结果,字符串 - func handle(arguments: [String: Any]) async throws -> String -} - -/// 单个MCP子客户端 -/// 负责连接到一个具体的MCP服务器并管理其工具 -public class MCPSubClient { - private let tag: String - private let serverId: String - private let serverUrl: String - - // 服务器提供的工具列表 - private var tools: [[String: Any]] = [] - - // 工具Map列表,用于OpenAI请求 - private var toolMaps: [[String: Any]] = [] - - // 是否已连接 - private var isConnectedFlag = false - - // 工具名称到工具的映射,用于快速查找 - private var toolMapping: [String: [String: Any]] = [:] - - // URL会话 - private lazy var urlSession: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 30 - return URLSession(configuration: config) - }() - - public init(serverId: String, serverUrl: String) { - self.serverId = serverId - self.serverUrl = serverUrl - self.tag = "MCPSubClient[\(serverId)]" - print("\(tag) 初始化,URL: \(serverUrl)") - } - - /// 连接到MCP服务器(通过SSE) - /// - Returns: 是否连接成功 - public func connect() async -> Bool { - do { - print("\(tag) 开始连接服务器: \(serverUrl)") - - // 这里应该实现实际的SSE连接逻辑 - // 由于iOS没有直接的SSE库,我们可以使用URLSession来模拟 - // 或者使用第三方库如EventSource - - // 模拟连接成功并获取工具列表 - await processToolsList() - - isConnectedFlag = true - print("\(tag) 连接成功,可用工具: \(tools.count)个") - return true - } catch { - print("\(tag) 连接失败: \(error.localizedDescription)") - return false - } - } - - /// 处理工具列表 - private func processToolsList() async { - // 这里应该从MCP服务器获取实际的工具列表 - // 目前先模拟一些基本工具 - tools = [] - - // 更新工具映射 - toolMapping.removeAll() - for tool in tools { - if let name = tool["name"] as? String { - toolMapping[name] = tool - } - } - - // 更新工具Map列表 - updateToolMaps() - - print("\(tag) 成功处理 \(tools.count) 个工具") - } - - /// 更新工具Map列表,用于OpenAI请求 - /// 将MCP工具转换为OpenAI函数调用格式 - private func updateToolMaps() { - toolMaps.removeAll() - - for tool in tools { - guard let name = tool["name"] as? String, - let description = tool["description"] as? String else { - continue - } - - let functionMap: [String: Any] = [ - "name": name, - "description": description, - "parameters": [ - "type": "object", - "properties": [:], - "required": [] - ] - ] - - toolMaps.append([ - "type": "function", - "function": functionMap - ]) - } - - print("\(tag) 更新工具映射完成,共 \(toolMaps.count) 个工具") - } - - /// 获取工具Map列表,用于OpenAI请求 - public func getToolMaps() -> [[String: Any]] { - return toolMaps - } - - /// 获取可用的工具列表 - public func getTools() -> [[String: Any]] { - return tools - } - - /// 检查是否包含指定工具 - public func containsTool(name: String) -> Bool { - return toolMapping.keys.contains(name) - } - - /// 调用工具 - public func callTool(name: String, arguments: [String: Any]) async throws -> [String: Any] { - guard isConnectedFlag else { - print("\(tag) 未连接到服务器") - return ["context": "未连接到MCP服务器"] - } - - guard containsTool(name: name) else { - print("\(tag) 此客户端不包含工具: \(name)") - throw OpenAIException("工具不存在: \(name)") - } - - // 这里应该实现实际的工具调用逻辑 - // 目前先返回模拟结果 - return [ - "name": name, - "context": "工具调用结果: \(name)" - ] - } - - /// 检查是否已连接 - public func isConnected() -> Bool { - return isConnectedFlag - } - - /// 关闭连接 - public func close() { - isConnectedFlag = false - print("\(tag) 已关闭MCP服务器连接") - } -} - -/// MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 -public class MCPClient { - private let tag = "MCPClient" - - // 本地函数Map,函数名 -> 处理器 - private var localFunctions: [String: FunctionHandler] = [:] - - // 本地函数定义Map,函数名 -> 定义 - private var localFunctionDefs: [String: String] = [:] - - // 子客户端列表,每个连接一个MCP服务器 - private var subClients: [String: MCPSubClient] = [:] - - // 是否已连接 - private var isConnectedFlag = false - - // 系统功能处理器 - private var systemFunctionHandler: SystemFunctionHandler? - - public init() { - // 初始化系统功能处理器 - systemFunctionHandler = SystemFunctionHandler() - initializeVoiceFunctions() - } - - /// 初始化语音功能处理器 - private func initializeVoiceFunctions() { - do { - // 使用SystemFunctionHandler注册所有功能 - systemFunctionHandler?.registerAllFunctions(client: self) - print("\(tag) 语音功能初始化完成") - } catch { - print("\(tag) 语音功能初始化失败: \(error.localizedDescription)") - } - } - - /// 连接到MCP服务器(通过SSE) - /// 直接接收完整的JSON配置字符串 - /// - Parameter mcpConfigJson: 包含mcpServers字段的JSON配置字符串 - /// - Returns: 是否连接成功 - public func connectToSSE(mcpConfigJson: String) async throws -> Bool { - // 清除现有连接 - closeAllConnections() - - var serverMap: [String: String] = [:] - var configLoaded = false - - // 尝试解析传入的JSON配置 - if !mcpConfigJson.isEmpty { - do { - guard let data = mcpConfigJson.data(using: .utf8), - let config = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let mcpServers = config["mcpServers"] as? [String: [String: String]] else { - throw OpenAIException("无效的MCP配置格式") - } - - // 从服务器配置中提取URL - for (serverId, serverConfig) in mcpServers { - if let url = serverConfig["url"], !url.isEmpty { - serverMap[serverId] = url - print("\(tag) 从配置中添加服务器 [\(serverId)]: \(url)") - } - } - - configLoaded = !serverMap.isEmpty - } catch { - print("\(tag) 解析MCP配置失败: \(error.localizedDescription)") - } - } - - // 如果没有从配置中加载到服务器,使用默认配置 - if !configLoaded { - print("\(tag) 使用默认MCP服务器配置") - // 可以在这里添加默认的服务器配置 - } - - // 连接到所有服务器 - var successCount = 0 - for (serverId, serverUrl) in serverMap { - let subClient = MCPSubClient(serverId: serverId, serverUrl: serverUrl) - let connected = await subClient.connect() - - if connected { - subClients[serverId] = subClient - successCount += 1 - print("\(tag) 成功连接到服务器 [\(serverId)]") - } else { - print("\(tag) 连接服务器失败 [\(serverId)]") - } - } - - isConnectedFlag = successCount > 0 - print("\(tag) MCP客户端连接完成,成功连接 \(successCount)/\(serverMap.count) 个服务器") - - return isConnectedFlag - } - - /// 关闭所有连接 - private func closeAllConnections() { - for (_, subClient) in subClients { - subClient.close() - } - subClients.removeAll() - isConnectedFlag = false - } - - /// 获取工具Map列表,用于OpenAI请求 - public func getToolMaps() -> [[String: Any]] { - var allToolMaps: [[String: Any]] = [] - - // 添加所有子客户端的工具 - for (_, subClient) in subClients { - allToolMaps.append(contentsOf: subClient.getToolMaps()) - } - - // 添加本地函数 - for (name, _) in localFunctions { - if let def = localFunctionDefs[name], - let data = def.data(using: .utf8), - let functionDef = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - allToolMaps.append([ - "type": "function", - "function": functionDef - ]) - } - } - - return allToolMaps - } - - /// 注册本地函数 - public func registerLocalFunction(name: String, description: String, parameters: [String: Any], handler: FunctionHandler) -> Bool { - do { - localFunctions[name] = handler - - let functionDef: [String: Any] = [ - "name": name, - "description": description, - "parameters": parameters - ] - - let jsonData = try JSONSerialization.data(withJSONObject: functionDef) - let jsonString = String(data: jsonData, encoding: .utf8) ?? "" - localFunctionDefs[name] = jsonString - - // print("\(tag) 成功注册本地函数: \(name)") - return true - } catch { - print("\(tag) 注册本地函数失败: \(error.localizedDescription)") - return false - } - } - - /// 调用工具 - public func callTool(name: String, arguments: [String: Any]) async throws -> [String: Any] { - // 首先检查是否是本地函数 - if let handler = localFunctions[name] { - do { - let result = try await handler.handle(arguments: arguments) - return ["context": result] - } catch { - throw OpenAIException("本地函数调用失败: \(error.localizedDescription)") - } - } - - // 然后检查MCP工具 - for (_, subClient) in subClients { - if subClient.containsTool(name: name) { - return try await subClient.callTool(name: name, arguments: arguments) - } - } - - throw OpenAIException("未找到工具: \(name)") - } - - /// 解析JSON参数 - public func parseJsonArguments(_ argumentsJson: String) throws -> [String: Any] { - guard let data = argumentsJson.data(using: .utf8), - let arguments = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [:] - } - return arguments - } - - /// 检查是否已连接 - public func isConnected() -> Bool { - return isConnectedFlag || !localFunctions.isEmpty - } - - /// 关闭连接 - public func close() { - closeAllConnections() - localFunctions.removeAll() - localFunctionDefs.removeAll() - print("\(tag) 已关闭MCP客户端") - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/OpenAIService.swift b/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/OpenAIService.swift deleted file mode 100644 index 35e40dc3..00000000 --- a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/OpenAIService.swift +++ /dev/null @@ -1,869 +0,0 @@ -import Foundation -import UIKit -import os -import os.log - -/// OpenAI服务异常 -public struct OpenAIException: Error { - let message: String - - public init(_ message: String) { - self.message = message - } - - public var localizedDescription: String { - return "OpenAIException: \(message)" - } -} - -/// 流式回调协议 -public protocol StreamCallback { - func onToken(_ token: String) - func onComplete() - func onError(_ error: Error) - func onFunctionCall(_ functionCall: [String: Any]) - func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) -} - -/// 工具调用信息 -private class ToolCallInfo { - var id: String = "" - var name: String = "" - var arguments: String = "" - - func isValid() -> Bool { - return !id.isEmpty && !name.isEmpty - } -} - -/// OpenAI服务的原生实现 -public class OpenAIService: NSObject { - private let tag = "OpenAIService" - - // 日志对象 - private let logger = OSLog(subsystem: "com.yunqiinnovation.open_ai_service", category: "OpenAIService") - - // MARK: - 属性 - private var baseUrl = "" - private var apiKey = "" - private var model = "" - private var visionModel = "doubao-1-5-vision-pro-32k-250115" - private var isInitialized = false - - // MCP客户端 - private var mcpClient: MCPClient? - private var isMcpInitializedFlag = false - - // 流式请求相关 - private var currentStreamTask: URLSessionDataTask? - private var currentCallback: StreamCallback? - private var currentMessages: [[String: Any]] = [] - private var responseBuffer = Data() - private var toolCalls: [Int: ToolCallInfo] = [:] - private var isCanceled = false - - // 优化:添加行缓冲区,避免频繁字符串转换 - private var lineBuffer = Data() - private let newlineData = "\n".data(using: .utf8)! - - // 优化:添加重连机制 - private var retryCount = 0 - private let maxRetryCount = 3 - private var retryDelay: TimeInterval = 1.0 - - // 优化:添加性能监控 - private var streamStartTime: Date? - private var tokenCount = 0 - private var bytesReceived = 0 - - // URL会话 - private lazy var urlSession: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 30 - return URLSession(configuration: config) - }() - - // 流式会话(使用 delegate) - private lazy var streamSession: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 30 - - // 优化:减少网络缓存,避免内存压力 - config.urlCache = nil - config.requestCachePolicy = .reloadIgnoringLocalCacheData - - // 优化:限制并发连接数 - config.httpMaximumConnectionsPerHost = 2 - - // 优化:针对后台数据处理的线程配置 - let delegateQueue = OperationQueue() - // 使用utility QoS,平衡性能和功耗,不与UI线程竞争 - delegateQueue.qualityOfService = .utility - delegateQueue.maxConcurrentOperationCount = 1 - delegateQueue.name = "OpenAIService.StreamDelegate" - - return URLSession(configuration: config, delegate: self, delegateQueue: delegateQueue) - }() - - // MARK: - 初始化 - public override init() { - super.init() - } - - // MARK: - 公共方法 - - /// 初始化OpenAI服务 - public func initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String) -> Bool { - self.apiKey = apiKey - if !baseUrl.isEmpty { - self.baseUrl = baseUrl - } - if !model.isEmpty { - self.model = model - } - - // 初始化MCP客户端 - if mcpClient == nil { - mcpClient = MCPClient() - } - _ = initializeMcpClient(serverUrl: mcpServer) - - isInitialized = !apiKey.isEmpty - return isInitialized - } - - /// 创建用户消息 - public func createUserMessage(content: String) -> [String: Any] { - return [ - "role": "user", - "content": content - ] - } - - /// 创建助手消息 - public func createAssistantMessage(content: String) -> [String: Any] { - return [ - "role": "assistant", - "content": content - ] - } - - /// 创建系统消息 - public func createSystemMessage(content: String) -> [String: Any] { - return [ - "role": "system", - "content": content - ] - } - - /// 创建带图片的用户消息 - public func createUserMessageWithImage(text: String, imageBase64: String) -> [String: Any] { - var contentArray: [[String: Any]] = [] - - // 添加文本部分 - if !text.isEmpty { - contentArray.append([ - "type": "text", - "text": text - ]) - } - - // 添加图片部分 - contentArray.append([ - "type": "image_url", - "image_url": [ - "url": "data:image/jpeg;base64,\(imageBase64)" - ] - ]) - - return [ - "role": "user", - "content": contentArray - ] - } - - /// 发送消息(非流式输出) - public func sendMessage(messages: [[String: Any]]) throws -> String { - guard isInitialized && !apiKey.isEmpty else { - throw OpenAIException("OpenAI服务未初始化") - } - - // 检查是否包含图片,决定使用哪个模型 - var currentModel = model - if let lastMessage = messages.last, - let content = lastMessage["content"] as? String, - content.contains("image_url") { - currentModel = visionModel - } - - // 构建请求体 - var requestBody: [String: Any] = [ - "model": currentModel, - "messages": messages, - "temperature": 0.7, - "max_tokens": 2000, - "stream": false - ] - - // 添加工具列表 - if let tools = mcpClient?.getToolMaps(), !tools.isEmpty { - requestBody["tools"] = tools - } - - // 创建请求 - guard let url = URL(string: baseUrl), - let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { - throw OpenAIException("创建请求失败") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.httpBody = jsonData - - // 同步请求 - let semaphore = DispatchSemaphore(value: 0) - var result: String = "" - var error: Error? - - let task = urlSession.dataTask(with: request) { data, response, taskError in - defer { semaphore.signal() } - - if let taskError = taskError { - error = OpenAIException("请求失败: \(taskError.localizedDescription)") - return - } - - guard let httpResponse = response as? HTTPURLResponse else { - error = OpenAIException("无效响应") - return - } - - guard httpResponse.statusCode == 200 else { - error = OpenAIException("API调用失败: \(httpResponse.statusCode)") - return - } - - guard let data = data, - let jsonResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - error = OpenAIException("响应解析失败") - return - } - - // 检查是否有函数调用 - if let choices = jsonResponse["choices"] as? [[String: Any]], - let choice = choices.first, - let message = choice["message"] as? [String: Any] { - - // 检查是否有工具调用 - if let toolCalls = message["tool_calls"] as? [[String: Any]], - let toolCall = toolCalls.first, - let function = toolCall["function"] as? [String: Any], - let name = function["name"] as? String, - let arguments = function["arguments"] as? String, - let id = toolCall["id"] as? String { - - let functionCallJson: [String: Any] = [ - "name": name, - "arguments": arguments, - "id": id - ] - - if let jsonData = try? JSONSerialization.data(withJSONObject: functionCallJson), - let jsonString = String(data: jsonData, encoding: .utf8) { - result = jsonString - } else { - error = OpenAIException("函数调用序列化失败") - } - return - } - - // 如果没有工具调用,返回消息内容 - if let content = message["content"] as? String { - result = content - return - } - } - - error = OpenAIException("无效的响应格式") - } - - task.resume() - semaphore.wait() - - if let error = error { - throw error - } - - return result - } - - /// 发送消息(流式输出) - public func sendMessageStream(messages: [[String: Any]], callback: StreamCallback) { - guard isInitialized && !apiKey.isEmpty else { - callback.onError(OpenAIException("OpenAI服务未初始化")) - return - } - - // 重置状态 - isCanceled = false - currentCallback = callback - currentMessages = messages - responseBuffer = Data() - toolCalls = [:] - - // 优化:初始化性能监控 - streamStartTime = Date() - tokenCount = 0 - bytesReceived = 0 - - // 检查是否包含图片,决定使用哪个模型 - var currentModel = model - if let lastMessage = messages.last, - let content = lastMessage["content"] as? String, - content.contains("image_url") { - currentModel = visionModel - } - - // 构建请求体 - var requestBody: [String: Any] = [ - "model": currentModel, - "messages": messages, - "temperature": 0.7, - "max_tokens": 2000, - "stream": true - ] - - // 添加工具列表 - if let tools = mcpClient?.getToolMaps(), !tools.isEmpty { - requestBody["tools"] = tools - } - - // 创建请求 - guard let url = URL(string: baseUrl), - let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { - callback.onError(OpenAIException("创建请求失败")) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("text/event-stream", forHTTPHeaderField: "Accept") - request.setValue("cache-control", forHTTPHeaderField: "no-cache") - request.httpBody = jsonData - - os_log("开始流式请求: %{public}@", log: logger, type: .info, url.absoluteString) - - // 使用 delegate 模式的 URLSession 创建任务 - let task = streamSession.dataTask(with: request) - currentStreamTask = task - task.resume() - } - - /// 处理缓冲区数据 - private func processBufferedData(callback: StreamCallback, isComplete: Bool = false) { - // 优化:使用流式解析,避免频繁的字符串转换 - processStreamData(callback: callback, isComplete: isComplete) - } - - /// 优化的流式数据处理 - private func processStreamData(callback: StreamCallback, isComplete: Bool = false) { - lineBuffer.append(responseBuffer) - responseBuffer = Data() - - var searchRange = lineBuffer.startIndex.. Bool { - guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid() else { - return false - } - - // 创建函数调用字典 - let functionCall: [String: Any] = [ - "name": firstToolCall.name, - "arguments": firstToolCall.arguments, - "id": firstToolCall.id - ] - - os_log("工具调用: id=%{public}@, name=%{public}@, arguments=%{public}@", log: logger, type: .info, firstToolCall.id, firstToolCall.name, firstToolCall.arguments) - - // 通知上层工具调用事件 - callback.onFunctionCall(functionCall) - - // 在后台队列处理工具调用 - Task { - do { - if !self.isCanceled { - // 调用工具 - let result: [String: Any] - do { - let args = try self.parseJsonArguments(firstToolCall.arguments) - result = try await self.mcpClient?.callTool(name: firstToolCall.name, arguments: args) ?? ["context": "工具调用失败"] - } catch { - os_log("工具调用错误: %{public}@", log: logger, type: .error, error.localizedDescription) - result = ["context": "工具调用失败: \(error.localizedDescription)"] - } - - if !self.isCanceled { - // 处理结果 - callback.onFunctionCallResult(functionCall, result) - - let context = result["context"] as? String ?? "工具调用失败" - os_log("MCP工具调用完成: name=%{public}@, context=%{public}@", log: logger, type: .info, firstToolCall.name, context) - - // 将结果发送回OpenAI继续对话 - self.sendFunctionCallResult( - messages: self.currentMessages, - functionCall: functionCall, - functionResult: context, - callback: callback - ) - } - } - } catch { - if !self.isCanceled { - os_log("处理工具调用异常: %{public}@", log: logger, type: .error, error.localizedDescription) - let errorMessage = "工具调用处理失败: \(error.localizedDescription)" - self.sendFunctionCallResult( - messages: self.currentMessages, - functionCall: functionCall, - functionResult: errorMessage, - callback: callback - ) - } - } - } - - return true - } - - /// 发送函数调用结果 - public func sendFunctionCallResult(messages: [[String: Any]], functionCall: [String: Any], functionResult: String, callback: StreamCallback) { - if isCanceled { - os_log("请求已取消,不发送函数调用结果", log: logger, type: .info) - return - } - - var fullMessages = messages - - // 添加函数调用消息 - let callId = functionCall["id"] as? String ?? "call_\(Int(Date().timeIntervalSince1970))" - fullMessages.append([ - "role": "assistant", - "content": "", - "tool_calls": [[ - "id": callId, - "type": "function", - "function": [ - "name": functionCall["name"] as? String ?? "", - "arguments": functionCall["arguments"] as? String ?? "" - ] - ]] - ]) - - // 添加函数调用结果 - fullMessages.append([ - "role": "tool", - "content": functionResult, - "tool_call_id": callId - ]) - - // 发送完整对话 - sendMessageStream(messages: fullMessages, callback: callback) - } - - /// 取消当前流式请求 - public func cancelCurrentStream() -> Bool { - isCanceled = true - currentStreamTask?.cancel() - - // 优化:更彻底的状态清理 - cleanupStreamState() - - os_log("已取消当前流式请求", log: logger, type: .info) - return true - } - - /// 优化:统一的状态清理方法 - private func cleanupStreamState() { - currentStreamTask = nil - currentCallback = nil - currentMessages.removeAll() - responseBuffer = Data() - lineBuffer = Data() - toolCalls.removeAll() - retryCount = 0 - - // 优化:重置性能监控变量 - streamStartTime = nil - tokenCount = 0 - bytesReceived = 0 - } - - /// 取消所有操作并释放资源 - public func cancelAll() { - _ = cancelCurrentStream() - } - - // MARK: - MCP相关方法 - - /// 初始化MCP客户端 - public func initializeMcpClient(serverUrl: String) -> Bool { - if mcpClient != nil { - mcpClient?.close() - } - - mcpClient = MCPClient() - - Task { - do { - let result = try await mcpClient?.connectToSSE(mcpConfigJson: serverUrl) ?? false - isMcpInitializedFlag = result - os_log("MCP客户端初始化%{public}@", log: logger, type: .info, result ? "成功" : "失败") - } catch { - os_log("MCP客户端初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) - isMcpInitializedFlag = false - } - } - - return true // 立即返回,实际连接在后台进行 - } - - /// MCP客户端是否已初始化 - public func isMcpInitialized() -> Bool { - return isMcpInitializedFlag && mcpClient?.isConnected() == true - } - - /// 关闭MCP客户端 - public func closeMcpClient() { - mcpClient?.close() - mcpClient = nil - isMcpInitializedFlag = false - } - - /// 注册函数 - public func registerFunction(name: String, description: String, parameters: [String: Any]) -> Bool { - guard let mcpClient = mcpClient else { - os_log("MCP客户端未初始化", log: logger, type: .error) - return false - } - - // 创建函数处理器 - let handler = LocalFunctionHandler(name: name) - - // 注册本地函数 - return mcpClient.registerLocalFunction( - name: name, - description: description, - parameters: parameters, - handler: handler - ) - } - - /// 处理MCP工具调用 - public func handleMcpToolCall(functionCallJson: String) async throws -> String { - guard let mcpClient = mcpClient, isMcpInitializedFlag else { - return "MCP客户端未初始化" - } - - guard let jsonData = functionCallJson.data(using: .utf8), - let functionCall = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], - let name = functionCall["name"] as? String, - let argumentsJson = functionCall["arguments"] as? String else { - throw OpenAIException("解析函数调用失败") - } - - let arguments = try parseJsonArguments(argumentsJson) - let result = try await mcpClient.callTool(name: name, arguments: arguments) - - return result["context"] as? String ?? "无法处理MCP工具调用" - } - - // MARK: - 工具方法 - - /// 解析JSON参数 - private func parseJsonArguments(_ argumentsJson: String) throws -> [String: Any] { - guard let data = argumentsJson.data(using: .utf8), - let arguments = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [:] - } - return arguments - } - - /// 检查字符串是否为有效的JSON - private func isValidJson(jsonString: String) -> Bool { - guard !jsonString.isEmpty, - jsonString.hasPrefix("{"), - jsonString.hasSuffix("}") else { - return false - } - - var braceCount = 0 - var insideQuotes = false - var escapeNext = false - - for char in jsonString { - if escapeNext { - escapeNext = false - continue - } - - switch char { - case "\\": - escapeNext = true - case "\"": - if !escapeNext { - insideQuotes.toggle() - } - case "{": - if !insideQuotes { - braceCount += 1 - } - case "}": - if !insideQuotes { - braceCount -= 1 - } - default: - break - } - - // 如果括号不匹配(闭合太多),提前返回 - if braceCount < 0 { - return false - } - } - - // 所有引号都应该闭合,所有括号也应该匹配 - return braceCount == 0 && !insideQuotes - } - - /// 将文件转换为Base64字符串 - public func fileToBase64(filePath: String, maxSizeKB: Int = 20480) -> String? { - guard let image = UIImage(contentsOfFile: filePath) else { - os_log("文件不存在或无法读取: %{public}@", log: logger, type: .error, filePath) - return nil - } - - var processedImage = image - - // 检查图片尺寸,限制最大为1024*1024 - let maxDimension: CGFloat = 1024 - if image.size.width > maxDimension || image.size.height > maxDimension { - let ratio = min(maxDimension / image.size.width, maxDimension / image.size.height) - let newSize = CGSize(width: image.size.width * ratio, height: image.size.height * ratio) - - UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) - image.draw(in: CGRect(origin: .zero, size: newSize)) - processedImage = UIGraphicsGetImageFromCurrentImageContext() ?? image - UIGraphicsEndImageContext() - } - - // 压缩图片 - guard let imageData = processedImage.jpegData(compressionQuality: 0.8) else { - return nil - } - - return imageData.base64EncodedString() - } -} - -// MARK: - URLSessionDataDelegate Extension -extension OpenAIService: URLSessionDataDelegate { - - /// 接收到响应头 - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - guard let httpResponse = response as? HTTPURLResponse else { - if !isCanceled { - // 直接回调,由调用方处理线程切换 - currentCallback?.onError(OpenAIException("无效响应")) - } - completionHandler(.cancel) - return - } - - guard httpResponse.statusCode == 200 else { - if !isCanceled { - // 直接回调,由调用方处理线程切换 - currentCallback?.onError(OpenAIException("API调用失败: \(httpResponse.statusCode)")) - } - completionHandler(.cancel) - return - } - - os_log("收到响应头,状态码: %d", log: logger, type: .info, httpResponse.statusCode) - completionHandler(.allow) - } - - /// 实时接收数据块 - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - guard !isCanceled, let callback = currentCallback else { return } - - // 优化:更新性能指标 - bytesReceived += data.count - - // 将新数据添加到缓冲区 - responseBuffer.append(data) - - // 处理缓冲区中的完整行 - processBufferedData(callback: callback) - } - - /// 请求完成 - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didCompleteWithError error: Error?) { - defer { - // 优化:使用统一的清理方法 - cleanupStreamState() - } - - guard !isCanceled else { - os_log("流式请求已取消", log: logger, type: .info) - return - } - - if let error = error { - os_log("流式请求错误: %{public}@", log: logger, type: .error, error.localizedDescription) - // 直接回调,由调用方处理线程切换 - currentCallback?.onError(OpenAIException("请求失败: \(error.localizedDescription)")) - return - } - - // 处理剩余的缓冲区数据 - if let callback = currentCallback { - processBufferedData(callback: callback, isComplete: true) - } - - // 优化:输出性能统计 - if let startTime = streamStartTime { - let duration = Date().timeIntervalSince(startTime) - let tokensPerSecond = duration > 0 ? Double(tokenCount) / duration : 0 - os_log("流式请求性能统计 - 耗时: %.2fs, tokens: %d, 字节: %d, tokens/s: %.2f", - log: logger, type: .info, duration, tokenCount, bytesReceived, tokensPerSecond) - } - - os_log("流式请求完成", log: logger, type: .info) - } -} - -/// 本地函数处理器 -private class LocalFunctionHandler: FunctionHandler { - private let functionName: String - - init(name: String) { - self.functionName = name - } - - func handle(arguments: [String: Any]) async throws -> String { - // 由于本地函数的实际处理是在Flutter端完成的 - // 这里只需返回一个标记,表示该函数是本地函数 - return "LOCAL_FUNCTION:\(functionName)" - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/OpenAIServicePlugin.swift b/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/OpenAIServicePlugin.swift deleted file mode 100644 index bf99d940..00000000 --- a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/OpenAIServicePlugin.swift +++ /dev/null @@ -1,340 +0,0 @@ -import Flutter -import UIKit -import Foundation - -/// OpenAI服务插件主类 -public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler { - - // MARK: - 常量 - private static let methodChannelName = "com.yunqiinnovation.open_ai_service/methods" - private static let eventChannelName = "com.yunqiinnovation.open_ai_service/events" - - // MARK: - 属性 - private var methodChannel: FlutterMethodChannel? - private var eventChannel: FlutterEventChannel? - private var eventSink: FlutterEventSink? - private var openAIService: OpenAIService? - - // MARK: - 插件注册 - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = OpenAIServicePlugin() - - // 初始化方法通道 - let methodChannel = FlutterMethodChannel( - name: methodChannelName, - binaryMessenger: registrar.messenger() - ) - instance.methodChannel = methodChannel - registrar.addMethodCallDelegate(instance, channel: methodChannel) - - // 初始化事件通道 - let eventChannel = FlutterEventChannel( - name: eventChannelName, - binaryMessenger: registrar.messenger() - ) - instance.eventChannel = eventChannel - eventChannel.setStreamHandler(instance) - - // 初始化OpenAI服务 - instance.openAIService = OpenAIService() - } - - // MARK: - FlutterPlugin - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let service = openAIService else { - result(FlutterError(code: "SERVICE_NOT_INITIALIZED", - message: "OpenAI服务未初始化", - details: nil)) - return - } - - switch call.method { - case "initialize": - handleInitialize(call: call, result: result, service: service) - - case "registerFunction": - handleRegisterFunction(call: call, result: result, service: service) - - case "sendMessage": - handleSendMessage(call: call, result: result, service: service) - - case "sendMessageStream": - handleSendMessageStream(call: call, result: result, service: service) - - case "sendFunctionCallResult": - handleSendFunctionCallResult(call: call, result: result, service: service) - - case "createUserMessage": - handleCreateUserMessage(call: call, result: result, service: service) - - case "createAssistantMessage": - handleCreateAssistantMessage(call: call, result: result, service: service) - - case "initializeMcpClient": - handleInitializeMcpClient(call: call, result: result, service: service) - - case "isMcpInitialized": - handleIsMcpInitialized(call: call, result: result, service: service) - - case "closeMcpClient": - handleCloseMcpClient(call: call, result: result, service: service) - - case "handleMcpToolCall": - handleMcpToolCall(call: call, result: result, service: service) - - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - FlutterStreamHandler - public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - self.eventSink = events - return nil - } - - public func onCancel(withArguments arguments: Any?) -> FlutterError? { - self.eventSink = nil - return nil - } - - // MARK: - 方法处理 - private func handleInitialize(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let apiKey = args["apiKey"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少必要参数", - details: nil)) - return - } - - let baseUrl = args["baseUrl"] as? String ?? "" - let model = args["model"] as? String ?? "" - let mcpServer = args["mcpServer"] as? String ?? "" - - let success = service.initialize( - apiKey: apiKey, - baseUrl: baseUrl, - model: model, - mcpServer: mcpServer - ) - - result(success) - } - - private func handleRegisterFunction(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let name = args["name"] as? String, - let description = args["description"] as? String, - let parameters = args["parameters"] as? [String: Any] else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少必要参数", - details: nil)) - return - } - - let success = service.registerFunction(name: name, description: description, parameters: parameters) - result(success) - } - - private func handleSendMessage(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let messagesArray = args["messages"] as? [[String: Any]] else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少消息参数", - details: nil)) - return - } - - do { - let response = try service.sendMessage(messages: messagesArray) - result(response) - } catch { - result(FlutterError(code: "OPENAI_ERROR", - message: error.localizedDescription, - details: nil)) - } - } - - private func handleSendMessageStream(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let messagesArray = args["messages"] as? [[String: Any]] else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少消息参数", - details: nil)) - return - } - - guard let eventSink = self.eventSink else { - result(FlutterError(code: "NO_EVENT_SINK", - message: "没有可用的事件流接收器", - details: nil)) - return - } - - // 创建回调处理器 - let callback = StreamCallbackImpl(eventSink: eventSink) - - // 发送流式消息 - service.sendMessageStream(messages: messagesArray, callback: callback) - - result(true) - } - - private func handleSendFunctionCallResult(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let messagesArray = args["messages"] as? [[String: Any]], - let functionCall = args["functionCall"] as? [String: Any], - let functionResult = args["functionResult"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少必要参数", - details: nil)) - return - } - - guard let eventSink = self.eventSink else { - result(FlutterError(code: "NO_EVENT_SINK", - message: "没有可用的事件流接收器", - details: nil)) - return - } - - // 创建回调处理器 - let callback = StreamCallbackImpl(eventSink: eventSink) - - // 发送函数调用结果 - service.sendFunctionCallResult( - messages: messagesArray, - functionCall: functionCall, - functionResult: functionResult, - callback: callback - ) - - result(true) - } - - private func handleCreateUserMessage(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let content = args["content"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少内容参数", - details: nil)) - return - } - - let message = service.createUserMessage(content: content) - result(message) - } - - private func handleCreateAssistantMessage(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let content = args["content"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少内容参数", - details: nil)) - return - } - - let message = service.createAssistantMessage(content: content) - result(message) - } - - private func handleInitializeMcpClient(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let serverUrl = args["serverUrl"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少服务器URL参数", - details: nil)) - return - } - - let success = service.initializeMcpClient(serverUrl: serverUrl) - result(success) - } - - private func handleIsMcpInitialized(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - let isInitialized = service.isMcpInitialized() - result(isInitialized) - } - - private func handleCloseMcpClient(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - service.closeMcpClient() - result(true) - } - - private func handleMcpToolCall(call: FlutterMethodCall, result: @escaping FlutterResult, service: OpenAIService) { - guard let args = call.arguments as? [String: Any], - let functionCallJson = args["functionCall"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", - message: "缺少函数调用参数", - details: nil)) - return - } - - Task { - do { - let toolResult = try await service.handleMcpToolCall(functionCallJson: functionCallJson) - DispatchQueue.main.async { - result(toolResult) - } - } catch { - DispatchQueue.main.async { - result(FlutterError(code: "MCP_TOOL_ERROR", - message: "处理MCP工具调用失败: \(error.localizedDescription)", - details: nil)) - } - } - } - } -} - -// MARK: - 流式回调实现 -private class StreamCallbackImpl: StreamCallback { - private let eventSink: FlutterEventSink - - init(eventSink: @escaping FlutterEventSink) { - self.eventSink = eventSink - } - - func onToken(_ token: String) { - DispatchQueue.main.async { - self.eventSink(["type": "token", "content": token]) - } - } - - func onComplete() { - DispatchQueue.main.async { - self.eventSink(["type": "complete"]) - } - } - - func onError(_ error: Error) { - DispatchQueue.main.async { - self.eventSink(["type": "error", "content": error.localizedDescription]) - } - } - - func onFunctionCall(_ functionCall: [String: Any]) { - DispatchQueue.main.async { - if let jsonData = try? JSONSerialization.data(withJSONObject: functionCall), - let jsonString = String(data: jsonData, encoding: .utf8) { - self.eventSink(["type": "function_call", "content": jsonString]) - } - } - } - - func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) { - DispatchQueue.main.async { - if let callData = try? JSONSerialization.data(withJSONObject: functionCall), - let callString = String(data: callData, encoding: .utf8), - let resultData = try? JSONSerialization.data(withJSONObject: functionCallResult), - let resultString = String(data: resultData, encoding: .utf8) { - self.eventSink([ - "type": "function_call_result", - "function_call": callString, - "result": resultString - ]) - } - } - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift b/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift deleted file mode 100644 index 996a9385..00000000 --- a/apps/client/local_plugins/open_ai_service/ios/open_ai_service/Sources/open_ai_service/SystemFunctionHandler.swift +++ /dev/null @@ -1,701 +0,0 @@ -import Foundation -import UIKit -import EventKit -import CoreLocation -import MediaPlayer -import AVFoundation - -/// 操作结果 -public struct Result { - let success: Bool - let message: String - - public init(success: Bool, message: String) { - self.success = success - self.message = message - } -} - -/// 系统功能处理器 - 负责调用系统API执行各种功能以及注册系统功能 -public class SystemFunctionHandler: NSObject { - private let tag = "SystemFunctionHandler" - - // 位置管理器 - private var locationManager: CLLocationManager? - private var locationCompletion: ((Result) -> Void)? - - // 事件存储 - private var eventStore: EKEventStore? - - public override init() { - super.init() - setupLocationManager() - setupEventStore() - } - - /// 设置位置管理器 - private func setupLocationManager() { - locationManager = CLLocationManager() - locationManager?.delegate = self - locationManager?.desiredAccuracy = kCLLocationAccuracyBest - } - - /// 设置事件存储 - private func setupEventStore() { - eventStore = EKEventStore() - } - - /// 在MCPClient中注册所有系统功能 - /// - Parameter client: MCPClient实例 - public func registerAllFunctions(client: MCPClient) { - do { - // 注册退出交互函数 - registerExitInteractionFunction(client: client) - - // 注册翻译模式函数 - registerTranslationModeFunction(client: client) - - // 注册设置提醒函数 - registerSetReminderFunction(client: client) - - // 注册获取当前时间函数 - registerGetCurrentTimeFunction(client: client) - - // 注册获取当前位置函数 - registerGetCurrentLocationFunction(client: client) - - // 注册媒体控制函数 - registerMediaPlayFunction(client: client) - registerMediaPauseFunction(client: client) - registerMediaPreviousFunction(client: client) - registerMediaNextFunction(client: client) - - // 注册打开录音机功能 - registerOpenRecorderFunction(client: client) - - print("\(tag) 所有系统功能注册完成") - } catch { - print("\(tag) 注册系统功能失败: \(error.localizedDescription)") - } - } - - // MARK: - 函数注册 - - /// 注册退出交互函数 - private func registerExitInteractionFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = ExitInteractionHandler() - _ = client.registerLocalFunction( - name: "exit_interaction", - description: "结束当前交互", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册退出交互功能") - } - - /// 注册翻译模式函数 - private func registerTranslationModeFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = TranslationModeHandler() - _ = client.registerLocalFunction( - name: "enter_translation_mode", - description: "用户请求进入实时翻译模式时,启动实时翻译功能", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册翻译模式功能") - } - - /// 注册设置提醒函数 - private func registerSetReminderFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [ - "title": [ - "type": "string", - "description": "提醒标题" - ], - "content": [ - "type": "string", - "description": "提醒内容" - ], - "time": [ - "type": "string", - "description": "提醒时间,格式为'yyyy-MM-dd HH:mm',如'2023-12-31 14:30'" - ] - ], - "required": ["title", "time"] - ] - - let handler = SetReminderHandler(eventStore: eventStore) - _ = client.registerLocalFunction( - name: "set_reminder", - description: "设置提醒事项", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册设置提醒功能") - } - - /// 注册获取当前时间函数 - private func registerGetCurrentTimeFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [ - "format": [ - "type": "string", - "enum": ["short", "date", "time", "full", "iso"], - "description": "时间输出粒度,仅可使用枚举值:short(仅时分)、date(仅日期)、time(时分秒)、full(日期+星期+时间)、iso(机器可读)。禁止传入 SimpleDateFormat/strftime 等格式串。" - ] - ], - "required": [] - ] - - let handler = GetCurrentTimeHandler() - _ = client.registerLocalFunction( - name: "get_current_time", - description: "获取当前日期和时间,返回值已按当前语言本地化并对TTS友好(不含冒号等易被误读的符号)。播报或回复给用户时必须原样使用 result 字段的文本,不要自行改写为 HH:mm:ss 等格式。", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册获取当前时间功能") - } - - /// 注册获取当前位置函数 - private func registerGetCurrentLocationFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = GetCurrentLocationHandler(systemHandler: self) - _ = client.registerLocalFunction( - name: "get_current_location", - description: "获取当前地理位置", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册获取当前位置功能") - } - - /// 注册媒体播放功能 - private func registerMediaPlayFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = MediaPlayHandler() - _ = client.registerLocalFunction( - name: "media_play", - description: "播放媒体", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册媒体播放功能") - } - - /// 注册媒体暂停功能 - private func registerMediaPauseFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = MediaPauseHandler() - _ = client.registerLocalFunction( - name: "media_pause", - description: "暂停媒体播放", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册媒体暂停功能") - } - - /// 注册媒体上一首功能 - private func registerMediaPreviousFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = MediaPreviousHandler() - _ = client.registerLocalFunction( - name: "media_previous", - description: "播放上一首", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册媒体上一首功能") - } - - /// 注册媒体下一首功能 - private func registerMediaNextFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = MediaNextHandler() - _ = client.registerLocalFunction( - name: "media_next", - description: "播放下一首", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册媒体下一首功能") - } - - /// 注册打开录音机功能 - private func registerOpenRecorderFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [:], - "required": [] - ] - - let handler = OpenRecorderHandler() - _ = client.registerLocalFunction( - name: "open_recorder", - description: "打开系统录音机并开始录音", - parameters: parameters, - handler: handler - ) - print("\(tag) 已注册打开录音机功能") - } - - // MARK: - 内部方法 - - /// 获取当前位置 - internal func getCurrentLocation() async -> Result { - return await withCheckedContinuation { continuation in - locationCompletion = { result in - continuation.resume(returning: result) - } - - // 设置15秒超时 - DispatchQueue.global().asyncAfter(deadline: .now() + 15.0) { [weak self] in - if self?.locationCompletion != nil { - self?.locationCompletion?(Result(success: false, message: "位置请求超时")) - self?.locationCompletion = nil - } - } - - // 检查位置服务是否可用 - guard CLLocationManager.locationServicesEnabled() else { - continuation.resume(returning: Result(success: false, message: "位置服务未启用")) - return - } - - // 确保locationManager已初始化 - guard let locationManager = locationManager else { - continuation.resume(returning: Result(success: false, message: "位置管理器未初始化")) - return - } - - // 检查位置权限 - 兼容iOS 13.0+ - let authStatus: CLAuthorizationStatus - if #available(iOS 14.0, *) { - authStatus = locationManager.authorizationStatus - } else { - authStatus = CLLocationManager.authorizationStatus() - } - - switch authStatus { - case .notDetermined: - // 请求权限,权限结果会在 didChangeAuthorization 回调中处理 - locationManager.requestWhenInUseAuthorization() - // 不要在这里 return,让权限回调来处理后续逻辑 - case .denied, .restricted: - continuation.resume(returning: Result(success: false, message: "位置权限被拒绝")) - return - case .authorizedWhenInUse, .authorizedAlways: - // 已有权限,直接请求位置 - locationManager.requestLocation() - @unknown default: - continuation.resume(returning: Result(success: false, message: "未知的位置权限状态")) - return - } - } - } -} - -// MARK: - CLLocationManagerDelegate -extension SystemFunctionHandler: CLLocationManagerDelegate { - public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.last else { - locationCompletion?(Result(success: false, message: "无法获取位置信息")) - return - } - - let latitude = location.coordinate.latitude - let longitude = location.coordinate.longitude - let message = "当前位置:纬度 \(latitude),经度 \(longitude)" - - locationCompletion?(Result(success: true, message: message)) - locationCompletion = nil - } - - public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - locationCompletion?(Result(success: false, message: "获取位置失败:\(error.localizedDescription)")) - locationCompletion = nil - } - - public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - // 只有在有位置请求等待时才处理权限变化 - guard locationCompletion != nil else { - return - } - - if status == .authorizedWhenInUse || status == .authorizedAlways { - manager.requestLocation() - } else if status == .denied || status == .restricted { - locationCompletion?(Result(success: false, message: "位置权限被拒绝")) - locationCompletion = nil - } - } -} - -// MARK: - 功能处理器实现 - -/// 退出交互处理器 -private class ExitInteractionHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - // 发送退出交互通知 - NotificationCenter.default.post(name: NSNotification.Name("ExitInteraction"), object: nil) - return "{\"result\": \"已结束当前交互\"}" - } -} - -/// 翻译模式处理器 -private class TranslationModeHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - // 发送进入翻译模式通知 - NotificationCenter.default.post(name: NSNotification.Name("EnterTranslationMode"), object: nil) - return "{\"result\": \"已进入翻译模式\"}" - } -} - -/// 设置提醒处理器 -private class SetReminderHandler: FunctionHandler { - private let eventStore: EKEventStore? - - init(eventStore: EKEventStore?) { - self.eventStore = eventStore - } - - func handle(arguments: [String: Any]) async throws -> String { - guard let title = arguments["title"] as? String, - let timeString = arguments["time"] as? String else { - throw OpenAIException("缺少必要参数") - } - - let content = arguments["content"] as? String ?? "" - let result = await setReminder(title: title, content: content, time: timeString) - return "{\"result\": \"\(result.message)\"}" - } - - private func setReminder(title: String, content: String, time: String) async -> Result { - guard let eventStore = eventStore else { - return Result(success: false, message: "事件存储未初始化") - } - - // 请求日历权限 - let status = await requestCalendarAccess() - guard status == .authorized else { - return Result(success: false, message: "日历权限被拒绝") - } - - // 解析时间 - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm" - guard let date = formatter.date(from: time) else { - return Result(success: false, message: "时间格式错误") - } - - // 创建提醒 - let reminder = EKReminder(eventStore: eventStore) - reminder.title = title - reminder.notes = content - reminder.calendar = eventStore.defaultCalendarForNewReminders() - - let alarm = EKAlarm(absoluteDate: date) - reminder.addAlarm(alarm) - - do { - try eventStore.save(reminder, commit: true) - return Result(success: true, message: "提醒设置成功") - } catch { - return Result(success: false, message: "设置提醒失败:\(error.localizedDescription)") - } - } - - private func requestCalendarAccess() async -> EKAuthorizationStatus { - return await withCheckedContinuation { continuation in - eventStore?.requestAccess(to: .reminder) { granted, error in - let status = EKEventStore.authorizationStatus(for: .reminder) - continuation.resume(returning: status) - } - } - } -} - -/// 获取当前时间处理器 -private class GetCurrentTimeHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let format = arguments["format"] as? String - let result = getCurrentTime(format: format) - return "{\"result\": \"\(result.message)\", \"time\": \"\(result.message)\"}" - } - - private func getCurrentTime(format: String?) -> Result { - 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) - } - - // TTS友好的时间格式,避免AI将":"分隔的时间误读为数字 - // CJK 使用单位数 m/s(去掉前导零),其他语言使用英文单词避免冒号 - private func formatShortTime(_ date: Date, lang: String) -> String { - let f = DateFormatter() - switch lang { - case "zh": f.dateFormat = "H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) - case "ja": f.dateFormat = "H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) - case "ko": f.dateFormat = "H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) - default: return formatTimeWestern(date, withSeconds: false) - } - } - - 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时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) - case "ja": f.dateFormat = "H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) - case "ko": f.dateFormat = "H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) - default: return formatTimeWestern(date, withSeconds: true) - } - } - - private func formatFull(_ date: Date, lang: String) -> String { - let f = DateFormatter() - switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 EEEE H时m分s秒"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) - case "ja": f.dateFormat = "yyyy年M月d日 EEEE H時m分s秒"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) - case "ko": f.dateFormat = "yyyy년 M월 d일 EEEE H시 m분 s초"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) - default: - f.dateFormat = "MMMM d, yyyy, EEEE, "; f.locale = Locale.current - return f.string(from: date) + formatTimeWestern(date, withSeconds: true) - } - } - - private func formatDefault(_ date: Date, lang: String) -> String { - let f = DateFormatter() - switch lang { - case "zh": f.dateFormat = "yyyy年M月d日 H时m分"; f.locale = Locale(identifier: "zh_CN"); return f.string(from: date) - case "ja": f.dateFormat = "yyyy年M月d日 H時m分"; f.locale = Locale(identifier: "ja_JP"); return f.string(from: date) - case "ko": f.dateFormat = "yyyy년 M월 d일 H시 m분"; f.locale = Locale(identifier: "ko_KR"); return f.string(from: date) - default: - f.dateFormat = "MMMM d, yyyy, "; f.locale = Locale.current - return f.string(from: date) + formatTimeWestern(date, withSeconds: false) - } - } - - /// 非 CJK 语言的时间格式:避免冒号,使用英文单词,对 TTS 更稳定 - private func formatTimeWestern(_ date: Date, withSeconds: Bool) -> String { - let cal = Calendar.current - let hour24 = cal.component(.hour, from: date) - let minute = cal.component(.minute, from: date) - let second = cal.component(.second, from: date) - let h12Raw = hour24 % 12 - let h12 = h12Raw == 0 ? 12 : h12Raw - let ampm = hour24 < 12 ? "AM" : "PM" - - var parts: [String] = [] - parts.append("\(h12) \(h12 == 1 ? "hour" : "hours")") - if minute > 0 || withSeconds { - parts.append("\(minute) \(minute == 1 ? "minute" : "minutes")") - } - if withSeconds { - parts.append("\(second) \(second == 1 ? "second" : "seconds")") - } - parts.append(ampm) - return parts.joined(separator: " ") - } -} - -/// 获取当前位置处理器 -private class GetCurrentLocationHandler: FunctionHandler { - private weak var systemHandler: SystemFunctionHandler? - - init(systemHandler: SystemFunctionHandler) { - self.systemHandler = systemHandler - } - - func handle(arguments: [String: Any]) async throws -> String { - guard let systemHandler = systemHandler else { - throw OpenAIException("系统处理器未初始化") - } - - let result = await systemHandler.getCurrentLocation() - return "{\"result\": \"\(result.message)\", \"success\": \(result.success)}" - } -} - -/// 媒体播放处理器 -private class MediaPlayHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = controlMedia(action: "play") - return "{\"result\": \"\(result.message)\"}" - } - - private func controlMedia(action: String) -> Result { - let commandCenter = MPRemoteCommandCenter.shared() - - switch action { - case "play": - // 发送播放命令通知 - NotificationCenter.default.post(name: NSNotification.Name("MediaPlay"), object: nil) - return Result(success: true, message: "已开始播放媒体") - default: - return Result(success: false, message: "未知的媒体操作") - } - } -} - -/// 媒体暂停处理器 -private class MediaPauseHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = controlMedia(action: "pause") - return "{\"result\": \"\(result.message)\"}" - } - - private func controlMedia(action: String) -> Result { - switch action { - case "pause": - // 发送暂停命令通知 - NotificationCenter.default.post(name: NSNotification.Name("MediaPause"), object: nil) - return Result(success: true, message: "已暂停媒体播放") - default: - return Result(success: false, message: "未知的媒体操作") - } - } -} - -/// 媒体上一首处理器 -private class MediaPreviousHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = controlMedia(action: "previous") - return "{\"result\": \"\(result.message)\"}" - } - - private func controlMedia(action: String) -> Result { - switch action { - case "previous": - // 发送上一首命令通知 - NotificationCenter.default.post(name: NSNotification.Name("MediaPrevious"), object: nil) - return Result(success: true, message: "已切换到上一首") - default: - return Result(success: false, message: "未知的媒体操作") - } - } -} - -/// 媒体下一首处理器 -private class MediaNextHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = controlMedia(action: "next") - return "{\"result\": \"\(result.message)\"}" - } - - private func controlMedia(action: String) -> Result { - switch action { - case "next": - // 发送下一首命令通知 - NotificationCenter.default.post(name: NSNotification.Name("MediaNext"), object: nil) - return Result(success: true, message: "已切换到下一首") - default: - return Result(success: false, message: "未知的媒体操作") - } - } -} - -/// 打开录音机处理器 -private class OpenRecorderHandler: FunctionHandler { - func handle(arguments: [String: Any]) async throws -> String { - let result = openRecorder() - return "{\"result\": \"\(result.message)\"}" - } - - private func openRecorder() -> Result { - // 尝试打开语音备忘录应用 - if let url = URL(string: "voicememos://") { - if UIApplication.shared.canOpenURL(url) { - Task { - await UIApplication.shared.open(url) - } - return Result(success: true, message: "已打开语音备忘录") - } - } - - return Result(success: false, message: "无法打开录音机应用") - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/open_ai_service/lib/open_ai_service.dart b/apps/client/local_plugins/open_ai_service/lib/open_ai_service.dart deleted file mode 100644 index 89ada557..00000000 --- a/apps/client/local_plugins/open_ai_service/lib/open_ai_service.dart +++ /dev/null @@ -1,420 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/services.dart'; - -/// OpenAI服务异常 -class OpenAIException implements Exception { - final String message; - - OpenAIException(this.message); - - @override - String toString() => 'OpenAIException: $message'; -} - -/// OpenAI服务事件类型 -enum OpenAIEventType { - token, - complete, - error, - functionCall, -} - -/// OpenAI服务事件 -class OpenAIEvent { - final OpenAIEventType type; - final dynamic content; - final Map? meta; - - OpenAIEvent({required this.type, this.content, this.meta}); - - factory OpenAIEvent.fromMap(Map map) { - final typeStr = map['type'] as String; - final content = map['content']; - final rawMeta = map['meta'] as String?; - final meta = rawMeta != null ? jsonDecode(rawMeta) : null; - return OpenAIEvent( - type: _typeFromString(typeStr), - content: content, - meta: meta, - ); - } - - static OpenAIEventType _typeFromString(String typeStr) { - switch (typeStr) { - case 'token': - return OpenAIEventType.token; - case 'complete': - return OpenAIEventType.complete; - case 'error': - return OpenAIEventType.error; - case 'functionCall': - return OpenAIEventType.functionCall; - default: - throw ArgumentError('未知的事件类型: $typeStr'); - } - } -} - -/// OpenAI服务插件 -class OpenAIService { - static const MethodChannel _channel = - MethodChannel('com.yunqiinnovation.open_ai_service/methods'); - static const EventChannel _eventChannel = - EventChannel('com.yunqiinnovation.open_ai_service/events'); - - /// 事件流控制器 - StreamController? _eventStreamController; - - /// 事件流 - Stream? _eventStream; - - /// 获取事件流 - Stream get eventStream { - if (_eventStream == null) { - _eventStreamController = StreamController.broadcast(); - _eventStream = _eventStreamController!.stream; - - // 监听原生事件 - _eventChannel.receiveBroadcastStream().listen( - (dynamic event) { - // 收到事件 OpenAIEvent: $event - if (event is Map) { - final eventMap = Map.from(event); - final openAIEvent = OpenAIEvent.fromMap(eventMap); - _eventStreamController!.add(openAIEvent); - } - }, - onError: (error) { - _eventStreamController!.addError(OpenAIException('事件流错误: $error')); - }, - ); - } - - return _eventStream!; - } - - /// 初始化OpenAI服务 - /// - /// [apiKey] OpenAI API密钥 - /// [baseUrl] 可选,自定义API基础URL - /// [model] 可选,自定义使用的模型 - /// [mcpServer] 可选,MCP服务器地址配置 - Future initialize({ - required String apiKey, - String baseUrl = '', - String model = '', - String mcpServer = '', - }) async { - try { - final result = await _channel.invokeMethod( - 'initialize', - { - 'apiKey': apiKey, - 'baseUrl': baseUrl, - 'model': model, - 'mcpServer': mcpServer, - }, - ); - - return result ?? false; - } catch (e) { - throw OpenAIException('初始化失败: $e'); - } - } - - /// 注册函数 - /// - /// [name] 函数名称 - /// [description] 函数描述 - /// [parameters] 函数参数 - Future registerFunction({ - required String name, - required String description, - required Map parameters, - }) async { - try { - final result = await _channel.invokeMethod( - 'registerFunction', - { - 'name': name, - 'description': description, - 'parameters': parameters, - }, - ); - - return result ?? false; - } catch (e) { - throw OpenAIException('注册函数失败: $e'); - } - } - - /// 创建用户消息 - /// - /// [content] 消息内容 - Future> createUserMessage(String content) async { - try { - final result = await _channel.invokeMethod>( - 'createUserMessage', - {'content': content}, - ); - - if (result == null) { - throw OpenAIException('创建用户消息失败: 结果为空'); - } - - return Map.from(result); - } catch (e) { - throw OpenAIException('创建用户消息失败: $e'); - } - } - - /// 创建助手消息 - /// - /// [content] 消息内容 - Future> createAssistantMessage(String content) async { - try { - final result = await _channel.invokeMethod>( - 'createAssistantMessage', - {'content': content}, - ); - - if (result == null) { - throw OpenAIException('创建助手消息失败: 结果为空'); - } - - return Map.from(result); - } catch (e) { - throw OpenAIException('创建助手消息失败: $e'); - } - } - - /// 发送消息(非流式输出) - /// - /// [messages] 消息列表 - Future sendMessage({ - required List> messages, - String? systemPrompt, //提示词 - String? userProperties, //用户属性 - }) async { - try { - // 构建新的消息列表 - final List> updatedMessages = []; - // 如果有提示词,先添加 system role - if (systemPrompt != null && systemPrompt.isNotEmpty) { - updatedMessages.add({ - 'role': 'system', - 'content': systemPrompt, - }); - } - - // 如果有用户属性,可以作为 user 的一个“隐式指令”传进去 - if (userProperties != null && userProperties.isNotEmpty) { - updatedMessages.add({ - 'role': 'user', - 'content': '【用户属性】$userProperties', - }); - } - // 添加原始消息 - updatedMessages.addAll(messages); - final result = await _channel.invokeMethod( - 'sendMessage', - { - 'messages': updatedMessages, - }, - ); - - if (result == null) { - throw OpenAIException('发送消息失败: 结果为空'); - } - - return result; - } catch (e) { - throw OpenAIException('发送消息失败: $e'); - } - } - - /// 发送消息(流式输出) - /// - /// [messages] 消息列表 - /// - /// 返回一个布尔值,表示请求是否已开始 - Future sendMessageStream({ - required List> messages, - String? systemPrompt, //提示词 - String? userProperties, //用户属性 - }) async { - try { - // 构建新的消息列表 - final List> updatedMessages = []; - // 如果有提示词,先添加 system role - if (systemPrompt != null && systemPrompt.isNotEmpty) { - updatedMessages.add({ - 'role': 'system', - 'content': systemPrompt, - }); - } - - // 如果有用户属性,可以作为 user 的一个“隐式指令”传进去 - if (userProperties != null && userProperties.isNotEmpty) { - updatedMessages.add({ - 'role': 'user', - 'content': '【用户属性】$userProperties', - }); - } - // 添加原始消息 - updatedMessages.addAll(messages); - final result = await _channel.invokeMethod( - 'sendMessageStream', - { - 'messages': updatedMessages, - }, - ); - - return result ?? false; - } catch (e) { - throw OpenAIException('发送流式消息失败: $e'); - } - } - - /// 发送函数调用结果 - /// - /// [messages] 消息列表 - /// [functionCall] 函数调用信息 - /// [functionResult] 函数调用结果 - /// - /// 返回一个布尔值,表示请求是否已开始 - Future sendFunctionCallResult({ - required List> messages, - required Map functionCall, - required String functionResult, - }) async { - try { - final result = await _channel.invokeMethod( - 'sendFunctionCallResult', - { - 'messages': messages, - 'functionCall': functionCall, - 'functionResult': functionResult, - }, - ); - - return result ?? false; - } catch (e) { - throw OpenAIException('发送函数调用结果失败: $e'); - } - } - - /// 从JSON字符串解析函数调用 - Map parseFunctionCall(String functionCallJson) { - try { - return json.decode(functionCallJson) as Map; - } catch (e) { - throw OpenAIException('解析函数调用失败: $e'); - } - } - - /// 初始化MCP客户端 - /// - /// [serverUrl] MCP服务器WebSocket地址 - /// - /// 返回一个布尔值,表示请求是否已开始初始化(实际初始化过程是异步的) - Future initializeMcpClient(String serverUrl) async { - try { - final result = await _channel.invokeMethod( - 'initializeMcpClient', - {'serverUrl': serverUrl}, - ); - - return result ?? false; - } catch (e) { - throw OpenAIException('初始化MCP客户端失败: $e'); - } - } - - /// 检查MCP客户端是否已初始化 - Future isMcpInitialized() async { - try { - final result = await _channel.invokeMethod('isMcpInitialized'); - return result ?? false; - } catch (e) { - throw OpenAIException('检查MCP客户端状态失败: $e'); - } - } - - /// 关闭MCP客户端 - Future closeMcpClient() async { - try { - final result = await _channel.invokeMethod('closeMcpClient'); - return result ?? false; - } catch (e) { - throw OpenAIException('关闭MCP客户端失败: $e'); - } - } - - /// 处理MCP工具调用 - /// - /// [functionCall] 函数调用信息,必须包含name和arguments字段 - /// - /// 返回工具调用结果 - Future handleMcpToolCall(Map functionCall) async { - try { - final result = await _channel.invokeMethod( - 'handleMcpToolCall', - {'functionCall': jsonEncode(functionCall)}, - ); - - return result ?? "处理MCP工具调用失败: 结果为空"; - } catch (e) { - throw OpenAIException('处理MCP工具调用失败: $e'); - } - } - - /// 处理函数调用(自动处理本地函数和MCP工具) - /// - /// 这是一个辅助方法,用于简化函数调用处理流程: - /// 1. MCP工具会被自动处理,不会通过回调返回 - /// 2. 只有本地函数才需要手动处理 - /// 3. 提供统一的处理接口 - /// - /// 参数: - /// [functionCall] 函数调用信息 - /// [messages] 消息上下文 - /// [localFunctionHandler] 本地函数处理器,接收函数名和参数,返回处理结果 - /// - /// 返回值: - /// 布尔值表示是否成功处理(已将结果发送回OpenAI) - Future handleFunctionCall({ - required Map functionCall, - required List> messages, - required Future Function( - String name, Map arguments) - localFunctionHandler, - }) async { - final name = functionCall['name'] as String; - final argumentsJson = functionCall['arguments'] as String; - - // 解析参数 - Map arguments; - try { - arguments = json.decode(argumentsJson) as Map; - } catch (e) { - arguments = {}; - } - - try { - // 调用本地函数处理器 - final result = await localFunctionHandler(name, arguments); - - // 发送结果回OpenAI - return sendFunctionCallResult( - messages: messages, - functionCall: functionCall, - functionResult: result, - ); - } catch (e) { - throw OpenAIException('处理函数调用失败: $e'); - } - } -} diff --git a/apps/client/local_plugins/open_ai_service/pubspec.yaml b/apps/client/local_plugins/open_ai_service/pubspec.yaml deleted file mode 100644 index d616f5ee..00000000 --- a/apps/client/local_plugins/open_ai_service/pubspec.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: open_ai_service -description: 原生OpenAI服务插件,提供与OpenAI API的交互功能,支持流式输出和函数调用。 -version: 0.0.1 -homepage: https://github.com/yunqiinnovation/deep_voice - -environment: - sdk: ">=2.17.0 <4.0.0" - flutter: ">=2.5.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -flutter: - plugin: - platforms: - android: - package: com.yunqiinnovation.open_ai_service - pluginClass: OpenAIServicePlugin - ios: - pluginClass: OpenAIServicePlugin - swiftPackage: - path: ios/open_ai_service \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/.gitignore b/apps/client/local_plugins/realtime/.gitignore deleted file mode 100644 index f5af9847..00000000 --- a/apps/client/local_plugins/realtime/.gitignore +++ /dev/null @@ -1,51 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# Visual Studio Code related -.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -build/ - -# iOS related -**/ios/.generated/ -**/ios/Flutter/Flutter.framework -**/ios/Flutter/Flutter.podspec -**/ios/Flutter/Generated.xcconfig -**/ios/Flutter/app.flx -**/ios/Flutter/app.zip -**/ios/Flutter/flutter_assets/ -**/ios/Flutter/flutter_export_environment.sh -**/ios/ServiceDefinitions.json -**/ios/Runner/GeneratedPluginRegistrant.* -.build/ - -# Coverage -coverage/ - -# Exceptions to above rules. -!**/ios/**/default.mode1v3 -!**/ios/**/default.mode2v3 -!**/ios/**/default.pbxuser -!**/ios/**/default.perspectivev3 \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/IMPLEMENTATION.md b/apps/client/local_plugins/realtime/IMPLEMENTATION.md deleted file mode 100644 index a5d49a97..00000000 --- a/apps/client/local_plugins/realtime/IMPLEMENTATION.md +++ /dev/null @@ -1,190 +0,0 @@ -# Realtime Plugin 实现总结 - -## 项目概述 - -基于您提供的Android原生示例,我为DeepVoice项目创建了一个realtime插件,用于通过WebSocket接入Vocode服务器实现实时语音聊天功能。该插件已完成Android和iOS双平台实现。 - -## 架构设计 - -### 插件结构 -``` -local_plugins/realtime/ -├── android/ # Android实现 -│ ├── build.gradle # Android构建配置 -│ ├── src/main/ -│ │ ├── AndroidManifest.xml # Android权限配置 -│ │ └── kotlin/com/yunqiinnovation/realtime/ -│ │ ├── RealtimePlugin.kt # 主插件入口 -│ │ ├── RealtimeAudioManager.kt # 音频管理器(AudioRecord/AudioTrack) -│ │ └── RealtimeWebSocketManager.kt # WebSocket管理器(OkHttp) -├── ios/ # iOS实现 -│ ├── Classes/ # Objective-C桥接文件 -│ │ ├── RealtimePlugin.h -│ │ └── RealtimePlugin.m -│ ├── realtime/ # Swift Package -│ │ ├── Package.swift -│ │ └── Sources/realtime/ -│ │ ├── RealtimePlugin.swift # 主插件入口 -│ │ ├── RealtimeAudioManager.swift # 音频管理器(AVAudioEngine) -│ │ └── RealtimeWebSocketManager.swift # WebSocket管理器(URLSession) -│ └── realtime.podspec -├── lib/ -│ └── realtime.dart # Flutter接口 -├── pubspec.yaml -└── README.md -``` - -### 核心组件 - -1. **RealtimePlugin (SwiftRealtimePlugin)** - - 主插件入口,处理Flutter方法调用 - - 管理各组件间的协调 - - 处理事件分发 - -2. **RealtimeAudioManager** - - 使用AVAudioEngine进行音频录制 - - 使用AVAudioPlayerNode进行音频播放 - - 支持16kHz/16-bit/单声道格式 - - 实现20ms帧长的音频处理 - -3. **RealtimeWebSocketManager** - - 使用URLSessionWebSocketTask实现WebSocket通信 - - 支持音频数据和文本消息的收发 - - 自动连接状态管理 - -## 技术实现对照 - -| 功能 | Android示例 | iOS实现 | -|------|-------------|---------| -| 音频录制 | AudioRecord | AVAudioEngine + AVAudioInputNode | -| 音频播放 | AudioTrack | AVAudioPlayerNode | -| WebSocket | OkHttp WebSocket | URLSessionWebSocketTask | -| 线程管理 | Kotlin协程 | DispatchQueue | -| 音频格式 | 16kHz/16-bit/单声道 | 16kHz/16-bit/单声道 | -| 帧长 | 20ms (320字节) | 20ms (320字节) | - -## Flutter接口 - -### 主要类 -- `RealtimeService`: 主服务类 -- `RealtimeEvent`: 事件类 -- `ConnectionStatus`: 连接状态枚举 -- `VoiceStatus`: 语音状态枚举 -- `RealtimeException`: 异常类 - -### 主要方法 -- `initialize()`: 初始化服务 -- `connect()`: 连接服务器 -- `disconnect()`: 断开连接 -- `startRecording()`: 开始录音 -- `stopRecording()`: 停止录音 -- `eventStream`: 事件流 - -## 使用方式 - -### 1. 在RealtimeController中集成 -已更新`lib/modules/realtime/controllers/realtime_controller.dart`使用真实的realtime插件,取代了原来的模拟实现。 - -### 2. 事件监听 -```dart -_realtimeService.eventStream.listen((event) { - switch (event.type) { - case 'connectionStatusChanged': - // 处理连接状态变化 - case 'voiceStatusChanged': - // 处理语音状态变化 - case 'textReceived': - // 处理文本消息 - case 'error': - // 处理错误 - } -}); -``` - -## 配置要求 - -### 依赖配置 -已添加到主项目的`pubspec.yaml`: -```yaml -realtime: - path: local_plugins/realtime -``` - -### 权限配置 -iOS的`Info.plist`已包含必要的麦克风权限: -```xml -NSMicrophoneUsageDescription -需要麦克风权限用于语音识别和录音功能 -``` - -## 与Android示例的对应关系 - -1. **初始化对应** - - Android: 创建AudioRecord, AudioTrack, OkHttpClient - - iOS: 创建AVAudioEngine, AVAudioPlayerNode, URLSession - -2. **录音线程对应** - - Android: `loopRecordSend()` 协程 - - iOS: AVAudioInputNode的installTap回调 - -3. **播放线程对应** - - Android: `loopPlayback()` 协程 + LinkedBlockingQueue - - iOS: DispatchQueue + AVAudioPCMBuffer队列 - -4. **WebSocket对应** - - Android: OkHttp WebSocketListener - - iOS: URLSessionWebSocketDelegate - -## 特性支持 - -✅ **已实现** -- Android和iOS双平台支持 -- 实时音频录制和播放(16kHz/16-bit/单声道) -- WebSocket双向通信 -- 状态管理和事件通知 -- 错误处理 -- 资源管理和清理 -- 20ms帧长处理 -- Kotlin协程和DispatchQueue线程管理 - -❌ **未实现(可扩展)** -- 音频编码(Opus等) -- 噪声消除/回声抑制 -- 自动重连机制 -- 音频质量自适应 - -## 使用注意事项 - -1. **服务器地址配置** - ```dart - // 需要替换为实际的Vocode服务器地址 - serverUrl: 'wss://your-vocode-server/ws' - ``` - -2. **音频格式一致性** - - 确保服务器支持16kHz/16-bit/单声道格式 - - 帧长固定为20ms (320字节) - -3. **错误处理** - - 监听事件流中的错误 - - 处理网络断线和重连 - -4. **资源管理** - - 及时调用dispose()释放资源 - - 避免内存泄漏 - -## 测试建议 - -1. **本地测试** - - 先用echo服务器测试WebSocket连接 - - 验证音频录制和播放功能 - -2. **集成测试** - - 与真实Vocode服务器集成 - - 测试端到端语音交互 - -3. **性能测试** - - 测试长时间使用的稳定性 - - 监控内存和CPU使用 - -这个实现为DeepVoice项目提供了完整的实时语音交互能力,可以直接与Vocode服务器进行通信,实现类似Android示例的功能。 \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/README.md b/apps/client/local_plugins/realtime/README.md deleted file mode 100644 index a5bc746f..00000000 --- a/apps/client/local_plugins/realtime/README.md +++ /dev/null @@ -1,125 +0,0 @@ -# Realtime Plugin - -实时语音聊天插件,通过WebSocket连接Vocode服务器实现实时语音交互。 - -## 功能特性 - -- **实时音频录制**: 16kHz/16-bit/单声道格式录音 -- **WebSocket通信**: 与Vocode服务器进行实时数据传输 -- **音频播放**: 播放服务器返回的语音数据 -- **状态管理**: 连接状态和语音状态监控 -- **事件回调**: 支持各种事件的监听和处理 - -## 技术实现 - -### Android端实现 -- **音频录制**: 使用AudioRecord进行16kHz/16-bit/单声道录音 -- **音频播放**: 使用AudioTrack进行音频播放 -- **WebSocket**: 使用OkHttp WebSocket客户端 -- **线程管理**: 使用Kotlin协程处理录音和播放 - -### iOS端实现 -- **音频录制**: 使用AVAudioEngine和AVAudioInputNode -- **音频播放**: 使用AVAudioPlayerNode进行播放 -- **WebSocket**: 使用URLSessionWebSocketTask -- **线程管理**: 使用DispatchQueue管理录音和播放线程 - -### 核心组件 -- `RealtimePlugin`: 主插件入口,处理Flutter方法调用 -- `RealtimeAudioManager`: 音频管理器,负责录音和播放 -- `RealtimeWebSocketManager`: WebSocket管理器,负责网络通信 - -**Android端组件**: -- AudioRecord + AudioTrack + OkHttp + Kotlin协程 -- 对应您提供的Android原生示例功能 - -**iOS端组件**: -- AVAudioEngine + AVAudioPlayerNode + URLSessionWebSocketTask + DispatchQueue - -## 使用方法 - -```dart -import 'package:realtime/realtime.dart'; - -final realtimeService = RealtimeService(); - -// 初始化 -await realtimeService.initialize( - serverUrl: 'wss://your-server/ws', - sampleRate: 16000, - channels: 1, - bitsPerSample: 16, -); - -// 监听事件 -realtimeService.eventStream.listen((event) { - switch (event.type) { - case 'connectionStatusChanged': - // 处理连接状态变化 - break; - case 'voiceStatusChanged': - // 处理语音状态变化 - break; - case 'textReceived': - // 处理收到的文本消息 - break; - case 'error': - // 处理错误 - break; - } -}); - -// 连接服务器 -await realtimeService.connect(); - -// 开始录音 -await realtimeService.startRecording(); - -// 停止录音 -await realtimeService.stopRecording(); - -// 断开连接 -await realtimeService.disconnect(); - -// 释放资源 -await realtimeService.dispose(); -``` - -## 音频格式 - -- **采样率**: 16000 Hz -- **声道数**: 1(单声道) -- **位深**: 16-bit -- **帧长**: 20ms (320字节) - -## 权限要求 - -### Android -权限已自动包含在插件中: -- `RECORD_AUDIO`: 音频录制权限 -- `INTERNET`: 网络访问权限 -- `ACCESS_NETWORK_STATE`: 网络状态权限 - -### iOS -在Info.plist中添加麦克风权限: -```xml -NSMicrophoneUsageDescription -应用需要麦克风权限进行语音录制 -``` - -## 注意事项 - -1. 确保服务器地址正确且可访问 -2. 网络环境良好,避免频繁断线 -3. 音频格式与服务器保持一致 -4. 及时释放资源,避免内存泄漏 - -## 错误处理 - -插件会自动处理常见错误: -- 网络连接失败 -- 音频设备不可用 -- 权限被拒绝 -- 服务器断开连接 - -通过事件流可以监听这些错误并进行相应处理。 \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/android/build.gradle.kts b/apps/client/local_plugins/realtime/android/build.gradle.kts deleted file mode 100644 index 10c4dad2..00000000 --- a/apps/client/local_plugins/realtime/android/build.gradle.kts +++ /dev/null @@ -1,47 +0,0 @@ -plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - kotlin("plugin.serialization") version "1.9.24" -} - -android { - namespace = "com.yunqiinnovation.realtime" - compileSdk = 35 - - defaultConfig { - minSdk = 21 - targetSdk = 35 - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = "11" - } - - packaging { - resources { - excludes.add("META-INF/DEPENDENCIES") - excludes.add("META-INF/LICENSE") - excludes.add("META-INF/LICENSE.txt") - excludes.add("META-INF/license.txt") - excludes.add("META-INF/NOTICE") - excludes.add("META-INF/NOTICE.txt") - excludes.add("META-INF/notice.txt") - excludes.add("META-INF/*.kotlin_module") - } - } -} - -dependencies { - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") - - // OkHttp for WebSocket - implementation(platform("com.squareup.okhttp3:okhttp-bom:4.12.0")) - implementation("com.squareup.okhttp3:okhttp") -} \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/android/settings.gradle.kts b/apps/client/local_plugins/realtime/android/settings.gradle.kts deleted file mode 100644 index dd4a80ad..00000000 --- a/apps/client/local_plugins/realtime/android/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "realtime" \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/realtime/android/src/main/AndroidManifest.xml deleted file mode 100644 index 64df6a0a..00000000 --- a/apps/client/local_plugins/realtime/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt b/apps/client/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt deleted file mode 100644 index d4c130e7..00000000 --- a/apps/client/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt +++ /dev/null @@ -1,590 +0,0 @@ -package com.yunqiinnovation.realtime - -import android.content.Context -import android.media.* -import android.media.audiofx.AcousticEchoCanceler -import android.media.audiofx.AutomaticGainControl -import android.media.audiofx.NoiseSuppressor -import android.os.Build -import android.util.Log -import kotlinx.coroutines.* -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.concurrent.LinkedBlockingQueue -import kotlin.math.sqrt - - -/** - * 实时音频管理器 - * - * 负责音频的录制和播放,对应Android示例中的AudioRecord和AudioTrack功能 - */ -class RealtimeAudioManager(private val context: Context) { - companion object { - private const val TAG = "RealtimeAudioManager" - } - - // 音频配置 - private var audioConfig: AudioConfig? = null - - // 录音相关 - private var audioRecord: AudioRecord? = null - private var isRecording = false - private var recordJob: Job? = null - - // 播放相关 - private var audioTrack: AudioTrack? = null - private var isPlaying = false - private var playJob: Job? = null - private val playbackQueue = LinkedBlockingQueue() - private val playbackInternalBuffer = ByteArrayOutputStream() - - // 回音消除相关 - private var acousticEchoCanceler: AcousticEchoCanceler? = null - private var noiseSuppressor: NoiseSuppressor? = null - private var automaticGainControl: AutomaticGainControl? = null - private var audioManager: AudioManager? = null - private var originalAudioMode = AudioManager.MODE_NORMAL - - // 音频焦点 - private var audioFocusRequest: AudioFocusRequest? = null - private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> - Log.d(TAG, "音频焦点变化: $focusChange") - } - - // 回调 - var onAudioData: ((ByteArray) -> Unit)? = null - var onStatusChanged: ((VoiceStatus) -> Unit)? = null - var onRmsChanged: ((Float) -> Unit)? = null // 新增RMS回调 - - // 协程作用域 - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - /// 初始化音频管理器 - fun initialize(config: AudioConfig): Boolean { - this.audioConfig = config - - // 初始化系统音频管理器 - audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - // 保存原始音频模式 - originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL - - return setupAudioRecord(config) && setupAudioTrack(config) - } - - /// 设置AudioRecord(录音) - private fun setupAudioRecord(config: AudioConfig): Boolean { - try { - val channelConfig = if (config.channels == 1) { - AudioFormat.CHANNEL_IN_MONO - } else { - AudioFormat.CHANNEL_IN_STEREO - } - - val audioFormat = when (config.bitsPerSample) { - 16 -> AudioFormat.ENCODING_PCM_16BIT - 8 -> AudioFormat.ENCODING_PCM_8BIT - else -> AudioFormat.ENCODING_PCM_16BIT - } - - val bufferSize = AudioRecord.getMinBufferSize( - config.sampleRate, - channelConfig, - audioFormat - ) - - if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) { - Log.e(TAG, "无法获取音频录制缓冲区大小") - return false - } - - audioRecord = AudioRecord( - MediaRecorder.AudioSource.MIC, - config.sampleRate, - channelConfig, - audioFormat, - bufferSize - ) - - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { - Log.e(TAG, "AudioRecord初始化失败") - return false - } - - Log.i(TAG, "AudioRecord初始化成功,采样率: ${config.sampleRate}, 声道: ${config.channels}, 位深: ${config.bitsPerSample}") - return true - } catch (e: Exception) { - Log.e(TAG, "设置AudioRecord失败: ${e.message}", e) - return false - } - } - - /// 设置AudioTrack(播放) - private fun setupAudioTrack(config: AudioConfig): Boolean { - try { - val channelConfig = if (config.channels == 1) { - AudioFormat.CHANNEL_OUT_MONO - } else { - AudioFormat.CHANNEL_OUT_STEREO - } - - val audioFormat = when (config.bitsPerSample) { - 16 -> AudioFormat.ENCODING_PCM_16BIT - 8 -> AudioFormat.ENCODING_PCM_8BIT - else -> AudioFormat.ENCODING_PCM_16BIT - } - - val bufferSize = AudioTrack.getMinBufferSize( - config.sampleRate, - channelConfig, - audioFormat - ) - - if (bufferSize == AudioTrack.ERROR || bufferSize == AudioTrack.ERROR_BAD_VALUE) { - Log.e(TAG, "无法获取音频播放缓冲区大小") - return false - } - - // 总是使用VOICE_COMMUNICATION模式以获得最佳语音处理效果 - val audioAttributes = AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - - audioTrack = AudioTrack.Builder() - .setAudioAttributes(audioAttributes) - .setAudioFormat( - AudioFormat.Builder() - .setSampleRate(config.sampleRate) - .setChannelMask(channelConfig) - .setEncoding(audioFormat) - .build() - ) - .setBufferSizeInBytes(bufferSize) - .setTransferMode(AudioTrack.MODE_STREAM) - .build() - - if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) { - Log.e(TAG, "AudioTrack初始化失败") - return false - } - - Log.i(TAG, "AudioTrack初始化成功, 启用语音通信模式") - return true - } catch (e: Exception) { - Log.e(TAG, "设置AudioTrack失败: ${e.message}", e) - return false - } - } - - /// 开始录音 - fun startRecording(): Boolean { - if (isRecording || audioRecord == null) { - return false - } - - try { - // 抢占音频焦点(暂停外部和内部其他音乐) - requestAudioFocus() - - // 设置回音消除(总是启用) - setupEchoCancellation() - - audioRecord?.startRecording() - isRecording = true - onStatusChanged?.invoke(VoiceStatus.RECORDING) - - // 启动录音协程 - recordJob = scope.launch { - loopRecordSend() - } - - Log.i(TAG, "开始录音, 回音消除: ${acousticEchoCanceler?.enabled}") - return true - } catch (e: Exception) { - Log.e(TAG, "开始录音失败: ${e.message}", e) - return false - } - } - - /// 录音循环(对应Android示例中的loopRecordSend) - private suspend fun loopRecordSend() { - val config = audioConfig ?: return - val frameSize = config.frameSize - val buffer = ByteArray(frameSize) - - while (isRecording && audioRecord != null) { - try { - val bytesRead = audioRecord!!.read(buffer, 0, frameSize) - - if (bytesRead > 0) { - // 确保读取的数据长度正确 - val audioData = if (bytesRead == frameSize) { - buffer - } else { - buffer.copyOf(bytesRead) - } - - // 计算RMS值 - val rms = calculateRMS(audioData) - onRmsChanged?.invoke(rms) - - // 直接回调音频数据到Flutter层 - onAudioData?.invoke(audioData) - } else { - Log.w(TAG, "录音读取数据失败: $bytesRead") - } - } catch (e: Exception) { - Log.e(TAG, "录音循环异常: ${e.message}", e) - break - } - } - } - - /// 停止录音 - fun stopRecording(): Boolean { - if (!isRecording) { - return false - } - - try { - isRecording = false - recordJob?.cancel() - audioRecord?.stop() - - // 清理回音消除 - cleanupEchoCancellation() - - // 录音和播放都停止时归还焦点,让外部音乐恢复 - if (!isPlaying) { - abandonAudioFocus() - } - - onStatusChanged?.invoke(VoiceStatus.IDLE) - Log.i(TAG, "录音已停止") - return true - } catch (e: Exception) { - Log.e(TAG, "停止录音失败: ${e.message}", e) - return false - } - } - - /// 播放音频数据 - fun playAudioData(data: ByteArray) { - if (data.isNotEmpty()) { - // 计算播放数据的RMS值 - val rms = calculateRMS(data) - onRmsChanged?.invoke(rms) - - playbackQueue.offer(data) - if (!isPlaying) { - startPlayback() - } - } - } - - /// 开始播放 - private fun startPlayback() { - if (isPlaying || audioTrack == null) { - return - } - - try { - isPlaying = true - onStatusChanged?.invoke(VoiceStatus.PLAYING) - audioTrack?.play() - - playJob = scope.launch { - loopPlayback() - } - Log.i(TAG, "开始播放") - } catch (e: Exception) { - Log.e(TAG, "开始播放失败: ${e.message}", e) - } - } - - /// 播放循环 - private suspend fun loopPlayback() { - Log.d(TAG, "启动播放循环") - - while (isPlaying) { - try { - // take() 会一直等待直到队列中有数据 - val data = playbackQueue.take() - - - // 直接播放整个数据块,让AudioTrack自己处理时序 - audioTrack?.write(data, 0, data.size) - - } catch (e: InterruptedException) { - Log.d(TAG, "播放循环被中断") - break - } catch (e: Exception) { - Log.e(TAG, "播放循环异常: ${e.message}", e) - } - } - Log.d(TAG, "播放循环结束") - stopPlaybackInternal() - } - - /// 内部停止播放方法 - private fun stopPlaybackInternal() { - if (!isPlaying) return - isPlaying = false - - playbackQueue.clear() - - // 使用try-catch保护,因为AudioTrack状态可能不稳定 - try { - if (audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING) { - audioTrack?.pause() - audioTrack?.flush() - } - // 不要在这里调用stop,stop是用来释放资源的,pause/flush更适合流式播放的暂停 - } catch (e: Exception) { - Log.e(TAG, "暂停和清空AudioTrack失败: ${e.message}", e) - } - - onStatusChanged?.invoke(VoiceStatus.IDLE) - Log.i(TAG, "播放已通过内部调用停止") - } - - /// 停止播放(外部调用) - fun stopPlaying(): Boolean { - if (!isPlaying) { - return true - } - try { - isPlaying = false - playJob?.cancel() // 取消协程 - playJob = null - stopPlaybackInternal() - - // 恢复音频模式 - restoreAudioMode() - - // 录音和播放都停止时归还焦点,让外部音乐恢复 - if (!isRecording) { - abandonAudioFocus() - } - - return true - } catch (e: Exception) { - Log.e(TAG, "停止播放失败: ${e.message}", e) - return false - } - } - - /// 更新配置 - fun updateConfig(config: AudioConfig): Boolean { - // 如果正在录音或播放,先停止 - if (isRecording) { - stopRecording() - } - if (isPlaying) { - stopPlaying() - } - - // 释放旧的音频对象 - releaseAudioObjects() - - return initialize(config) - } - - /// 释放音频对象 - private fun releaseAudioObjects() { - try { - audioRecord?.release() - audioRecord = null - - audioTrack?.release() - audioTrack = null - } catch (e: Exception) { - Log.e(TAG, "释放音频对象失败: ${e.message}", e) - } - } - - /// 释放资源 - fun dispose() { - stopRecording() - stopPlaying() - - // 清理回音消除和恢复音频模式 - cleanupEchoCancellation() - restoreAudioMode() - - // 兜底归还音频焦点 - abandonAudioFocus() - - scope.cancel() - releaseAudioObjects() - playbackQueue.clear() - - Log.i(TAG, "音频管理器已释放") - } - - /// 请求音频焦点:使用 AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,让外部和内部其他音频暂停 - private fun requestAudioFocus() { - val am = audioManager ?: return - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (audioFocusRequest != null) return - val attrs = AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) - .setAudioAttributes(attrs) - .setOnAudioFocusChangeListener(audioFocusChangeListener) - .build() - audioFocusRequest = req - val result = am.requestAudioFocus(req) - Log.i(TAG, "请求音频焦点(GAIN) 结果=$result") - } else { - @Suppress("DEPRECATION") - val result = am.requestAudioFocus( - audioFocusChangeListener, - AudioManager.STREAM_VOICE_CALL, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE - ) - Log.i(TAG, "请求音频焦点(GAIN, legacy) 结果=$result") - } - } catch (e: Exception) { - Log.e(TAG, "请求音频焦点失败: ${e.message}", e) - } - } - - /// 归还音频焦点 - private fun abandonAudioFocus() { - val am = audioManager ?: return - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest?.let { am.abandonAudioFocusRequest(it) } - audioFocusRequest = null - } else { - @Suppress("DEPRECATION") - am.abandonAudioFocus(audioFocusChangeListener) - } - Log.i(TAG, "已归还音频焦点") - } catch (e: Exception) { - Log.e(TAG, "归还音频焦点失败: ${e.message}", e) - } - } - - - - - /// 设置回音消除(总是启用) - private fun setupEchoCancellation() { - val record = audioRecord ?: return - - try { - // 设置音频模式为通信模式 - audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION - - // 设置声学回音消除 - if (AcousticEchoCanceler.isAvailable()) { - acousticEchoCanceler = AcousticEchoCanceler.create(record.audioSessionId) - acousticEchoCanceler?.enabled = true - Log.i(TAG, "声学回音消除已启用") - } else { - Log.w(TAG, "设备不支持声学回音消除") - } - - // 设置噪声抑制 - if (NoiseSuppressor.isAvailable()) { - noiseSuppressor = NoiseSuppressor.create(record.audioSessionId) - noiseSuppressor?.enabled = true - Log.i(TAG, "噪声抑制已启用") - } - - // 设置自动增益控制 - if (AutomaticGainControl.isAvailable()) { - automaticGainControl = AutomaticGainControl.create(record.audioSessionId) - automaticGainControl?.enabled = true - Log.i(TAG, "自动增益控制已启用") - } - - Log.i(TAG, "回音消除和音频增强功能已全部启用") - } catch (e: Exception) { - Log.e(TAG, "设置回音消除失败: ${e.message}", e) - } - } - - /// 清理回音消除 - private fun cleanupEchoCancellation() { - try { - acousticEchoCanceler?.let { - it.enabled = false - it.release() - acousticEchoCanceler = null - Log.d(TAG, "声学回音消除已释放") - } - - noiseSuppressor?.let { - it.enabled = false - it.release() - noiseSuppressor = null - Log.d(TAG, "噪声抑制已释放") - } - - automaticGainControl?.let { - it.enabled = false - it.release() - automaticGainControl = null - Log.d(TAG, "自动增益控制已释放") - } - } catch (e: Exception) { - Log.e(TAG, "清理回音消除失败: ${e.message}", e) - } - } - - /// 恢复音频模式 - private fun restoreAudioMode() { - try { - audioManager?.mode = originalAudioMode - Log.d(TAG, "音频模式已恢复为: $originalAudioMode") - } catch (e: Exception) { - Log.e(TAG, "恢复音频模式失败: ${e.message}", e) - } - } - - fun destroy() { - Log.d(TAG, "销毁AudioManager") - stopRecording() - stopPlaying() - scope.cancel() // 确保所有协程都已取消 - - audioRecord?.release() - audioTrack?.release() - releaseAudioObjects() - playbackQueue.clear() - - Log.i(TAG, "音频管理器已释放") - } - - /// 计算音频数据的RMS值 - private fun calculateRMS(audioData: ByteArray): Float { - if (audioData.isEmpty()) return 0f - - var sum = 0.0 - // 处理16位PCM数据 - for (i in 0 until audioData.size step 2) { - if (i + 1 < audioData.size) { - // Little-endian: 低字节在前 - val sample = (audioData[i].toInt() and 0xFF) or - (audioData[i + 1].toInt() shl 8) - // 转换为有符号16位整数 - val signedSample = sample.toShort().toFloat() - // 归一化到[-1, 1] - val normalizedSample = signedSample / 32768f - sum += normalizedSample * normalizedSample - } - } - - val mean = sum / (audioData.size / 2) - return sqrt(mean).toFloat() - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt b/apps/client/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt deleted file mode 100644 index b8077c2c..00000000 --- a/apps/client/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt +++ /dev/null @@ -1,455 +0,0 @@ -package com.yunqiinnovation.realtime - -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.util.Log -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import kotlinx.coroutines.* -import org.json.JSONObject - -/** - * 实时语音聊天插件 - * - * 通过WebSocket连接Vocode服务器实现实时语音交互 - * 包含录音、播放、WebSocket通信等功能 - */ -class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { - companion object { - private const val TAG = "RealtimePlugin" - private const val METHOD_CHANNEL = "realtime/methods" - private const val EVENT_CHANNEL = "realtime/events" - } - - private lateinit var context: Context - private lateinit var methodChannel: MethodChannel - private lateinit var eventChannel: EventChannel - private var eventSink: EventChannel.EventSink? = null - - // 核心组件:设为可空,因为它们的生命周期与页面会话绑定 - private var audioManager: RealtimeAudioManager? = null - private var webSocketManager: RealtimeWebSocketManager? = null - - // 协程作用域:需要可变,因为在重用插件时需要重新创建 - private var scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - - // 主线程Handler,用于向Flutter发送事件与回调 - private val mainHandler = Handler(Looper.getMainLooper()) - - // 配置参数 - private var serverUrl: String = "" - private var sampleRate: Int = 16000 - private var channels: Int = 1 - private var bitsPerSample: Int = 16 - - // 状态 - @Volatile - private var connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED - @Volatile - private var voiceStatus: VoiceStatus = VoiceStatus.IDLE - - // 清理状态标志,确保清理逻辑只执行一次 - @Volatile - private var isCleanedUp = false - private val cleanupLock = Any() - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - - methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, METHOD_CHANNEL) - methodChannel.setMethodCallHandler(this) - - eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, EVENT_CHANNEL) - eventChannel.setStreamHandler(this) - - Log.i(TAG, "Realtime插件已附加到引擎") - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - Log.i(TAG, "插件正在从引擎分离,执行清理...") - performCleanup() - methodChannel.setMethodCallHandler(null) - eventChannel.setStreamHandler(null) - Log.i(TAG, "Realtime插件已从引擎分离") - } - - /// 设置各组件的回调 - private fun setupCallbacks() { - Log.d(TAG, "setupCallbacks: 正在为新的管理器实例设置回调...") - - // 音频管理器回调 - audioManager?.onAudioData = { data -> - // 发送到WebSocket - webSocketManager?.sendAudioData(data) - // 发送录音PCM数据到Flutter用于可视化 - val pcm16Data = convertByteArrayToPCM16(data) - sendEvent("recordingPcmData", pcm16Data) - } - - audioManager?.onStatusChanged = { status -> - voiceStatus = status - sendEvent("voiceStatusChanged", status.value) - } - - // 新增RMS回调 - audioManager?.onRmsChanged = { rms -> - sendEvent("audioRms", rms) - } - - // WebSocket管理器回调 - webSocketManager?.onConnectionStatusChanged = { status -> - connectionStatus = status - sendEvent("connectionStatusChanged", status.value) - } - - webSocketManager?.onAudioReceived = { data -> - // 添加音频数据前50字节的日志输出 - val dataPreview = if (data.size > 50) { - data.sliceArray(0..49).joinToString(" ") { "%02x".format(it) } + "..." - } else { - data.joinToString(" ") { "%02x".format(it) } - } - Log.d(TAG, "收到音频数据前50字节: $dataPreview") - Log.d(TAG, "音频数据总长度: ${data.size} 字节") - - audioManager?.playAudioData(data) - // 发送播放PCM数据到Flutter用于可视化 - val pcm16Data = convertByteArrayToPCM16(data) - sendEvent("playbackPcmData", pcm16Data) - } - - webSocketManager?.onTextReceived = { text -> - // 直接发送原始JSON文本到Flutter层处理 - sendEvent("textReceived", text) - } - - webSocketManager?.onError = { error -> - sendEvent("error", error) - } - } - - /// 发送事件到Flutter端 - private fun sendEvent(type: String, data: Any?) { - val sink = eventSink - if (sink == null) { - Log.d(TAG, "事件流已关闭,忽略事件: $type") - return - } - - val event = mapOf( - "type" to type, - "data" to data - ) - - try { - mainHandler.post { - try { - sink.success(event) - } catch (e: Exception) { - Log.w(TAG, "发送事件失败: \\${e.message}") - } - } - } catch (e: Exception) { - Log.w(TAG, "发送事件时出现异常: \\${e.message}") - } - } - - override fun onMethodCall(call: MethodCall, result: Result) { - when (call.method) { - "initialize" -> handleInitialize(call, result) - "connect" -> handleConnect(result) - "disconnect" -> handleDisconnect(result) - "startRecording" -> handleStartRecording(result) - "stopRecording" -> handleStopRecording(result) - "stopPlaying" -> handleStopPlaying(result) - "getConnectionStatus" -> result.success(connectionStatus.value) - "getVoiceStatus" -> result.success(voiceStatus.value) - "sendTextMessage" -> handleSendTextMessage(call, result) - "setAudioConfig" -> handleSetAudioConfig(call, result) - "dispose" -> handleDispose(result) - else -> result.notImplemented() - } - } - - private fun handleInitialize(call: MethodCall, result: Result) { - try { - Log.d(TAG, "handleInitialize: 开始新一轮的初始化...") - - // 1. 如果之前的协程作用域已被取消,则创建一个全新的 - if (!scope.isActive) { - scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - Log.i(TAG, "handleInitialize: 检测到协程作用域已失效,已创建新实例。") - } - - // 2. 重置清理状态标志,允许下一次的清理操作 - synchronized(cleanupLock) { - if (isCleanedUp) { - isCleanedUp = false - Log.i(TAG, "handleInitialize: 清理状态已重置,插件可再次使用。") - } - } - - // 3. 为新会话创建全新的管理器实例 - Log.d(TAG, "handleInitialize: 正在创建新的AudioManager和WebSocketManager实例...") - audioManager = RealtimeAudioManager(context) - webSocketManager = RealtimeWebSocketManager() - - // 4. 为新实例设置回调 - setupCallbacks() - - val args = call.arguments as? Map - val serverUrl = args?.get("serverUrl") as? String - ?: return result.error("INVALID_ARGUMENTS", "服务器地址不能为空", null) - - this.serverUrl = serverUrl - - args["sampleRate"]?.let { this.sampleRate = (it as Number).toInt() } - args["channels"]?.let { this.channels = (it as Number).toInt() } - args["bitsPerSample"]?.let { this.bitsPerSample = (it as Number).toInt() } - - // 初始化音频管理器 - val audioConfig = AudioConfig(sampleRate, channels, bitsPerSample) - val success = audioManager?.initialize(audioConfig) ?: false - - if (success) { - webSocketManager?.initialize(serverUrl) - Log.i(TAG, "实时语音插件初始化成功") - } else { - Log.e(TAG, "实时语音插件初始化失败") - } - - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "初始化失败: ${e.message}", e) - result.error("INITIALIZATION_ERROR", "初始化失败: ${e.message}", null) - } - } - - private fun handleConnect(result: Result) { - Log.d(TAG, "handleConnect: 收到连接请求。scope是否活跃? ${scope.isActive}") - scope.launch { - try { - val success = webSocketManager?.connect() ?: false - mainHandler.post { result.success(success) } - } catch (e: Exception) { - Log.e(TAG, "连接失败: ${e.message}", e) - mainHandler.post { result.error("CONNECTION_ERROR", "连接失败: ${e.message}", null) } - } - } - } - - private fun handleDisconnect(result: Result) { - scope.launch { - try { - webSocketManager?.disconnect() - mainHandler.post { result.success(true) } - } catch (e: Exception) { - Log.e(TAG, "断开连接失败: ${e.message}", e) - mainHandler.post { result.error("DISCONNECTION_ERROR", "断开连接失败: ${e.message}", null) } - } - } - } - - private fun handleStartRecording(result: Result) { - scope.launch { - try { - val success = audioManager?.startRecording() ?: false - mainHandler.post { result.success(success) } - } catch (e: Exception) { - Log.e(TAG, "开始录音失败: ${e.message}", e) - mainHandler.post { result.error("RECORDING_ERROR", "开始录音失败: ${e.message}", null) } - } - } - } - - private fun handleStopRecording(result: Result) { - scope.launch { - try { - val success = audioManager?.stopRecording() ?: false - mainHandler.post { result.success(success) } - } catch (e: Exception) { - Log.e(TAG, "停止录音失败: ${e.message}", e) - mainHandler.post { result.error("RECORDING_ERROR", "停止录音失败: ${e.message}", null) } - } - } - } - - private fun handleStopPlaying(result: Result) { - scope.launch { - try { - val success = audioManager?.stopPlaying() ?: false - mainHandler.post { result.success(success) } - } catch (e: Exception) { - Log.e(TAG, "停止播放失败: ${e.message}", e) - mainHandler.post { result.error("PLAYBACK_ERROR", "停止播放失败: ${e.message}", null) } - } - } - } - - private fun handleSendTextMessage(call: MethodCall, result: Result) { - try { - val args = call.arguments as? Map - val message = args?.get("message") as? String - ?: return result.error("INVALID_ARGUMENTS", "消息内容不能为空", null) - - val success = webSocketManager?.sendTextMessage(message) ?: false - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "发送文本消息失败: ${e.message}", e) - result.error("MESSAGE_ERROR", "发送文本消息失败: ${e.message}", null) - } - } - - private fun handleSetAudioConfig(call: MethodCall, result: Result) { - try { - val args = call.arguments as? Map - ?: return result.error("INVALID_ARGUMENTS", "参数无效", null) - - Log.d(TAG, "收到的音频配置参数: $args") - - args["sampleRate"]?.let { - Log.d(TAG, "sampleRate: $it, type: ${it.javaClass.name}") - this.sampleRate = (it as Number).toInt() - } - args["channels"]?.let { - Log.d(TAG, "channels: $it, type: ${it.javaClass.name}") - this.channels = (it as Number).toInt() - } - args["bitsPerSample"]?.let { - Log.d(TAG, "bitsPerSample: $it, type: ${it.javaClass.name}") - this.bitsPerSample = (it as Number).toInt() - } - - val audioConfig = AudioConfig(sampleRate, channels, bitsPerSample) - val success = audioManager?.updateConfig(audioConfig) ?: false - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "设置音频配置失败: ${e.message}", e) - result.error("CONFIG_ERROR", "设置音频配置失败: ${e.message}", null) - } - } - - private fun handleDispose(result: Result) { - Log.i(TAG, "Flutter端请求释放资源...") - performCleanup() - // 立即返回,不等待异步清理完成 - result.success(null) - } - - /** - * 将字节数组转换为PCM16整数数组 - * PCM16格式:16-bit signed integers, little-endian - */ - private fun convertByteArrayToPCM16(data: ByteArray): List { - val pcm16List = mutableListOf() - - // 确保数据长度是偶数(每2个字节一个采样) - val validSize = data.size and 0xFFFFFFFE.toInt() // 清除最低位,确保偶数 - - // 每2个字节组成一个16-bit采样 - for (i in 0 until validSize step 2) { - if (i + 1 < data.size) { // 安全检查 - // Little-endian: 低字节在前 - val low = data[i].toInt() and 0xFF - val high = data[i + 1].toInt() shl 8 - val sample = (high or low).toShort().toInt() // 转换为有符号16位整数 - pcm16List.add(sample) - } - } - - return pcm16List - } - - /** - * 执行核心清理操作。 - * 此方法是幂等的(可重复调用)和线程安全的。 - */ - private fun performCleanup() { - synchronized(cleanupLock) { - if (isCleanedUp) { - Log.d(TAG, "资源已清理,跳过。") - return - } - Log.i(TAG, "开始执行核心资源清理...") - - // 立即标记,防止重入 - isCleanedUp = true - - // 立即停止接收事件 - eventSink = null - - // 在后台IO线程中执行所有耗时操作,避免阻塞主线程 - CoroutineScope(Dispatchers.IO).launch { - runCatching { audioManager?.stopRecording() } - .onFailure { Log.w(TAG, "停止录音时异常: ${it.message}") } - - runCatching { audioManager?.stopPlaying() } - .onFailure { Log.w(TAG, "停止播放时异常: ${it.message}") } - - runCatching { webSocketManager?.disconnect() } - .onFailure { Log.w(TAG, "断开WebSocket时异常: ${it.message}") } - - // 短暂延迟,以确保挂起的操作有时间完成 - // delay(100L) - - runCatching { audioManager?.dispose() } - .onFailure { Log.w(TAG, "释放音频管理器时异常: ${it.message}") } - - runCatching { webSocketManager?.dispose() } - .onFailure { Log.w(TAG, "释放WebSocket管理器时异常: ${it.message}") } - - runCatching { - scope.cancel() - Log.i(TAG, "插件协程作用域已取消。") - }.onFailure { Log.w(TAG, "取消协程作用域时异常: ${it.message}") } - - // 清理完成,将实例置空 - audioManager = null - webSocketManager = null - - Log.i(TAG, "核心资源清理完成。") - } - } - } - - // EventChannel.StreamHandler 实现 - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - eventSink = events - Log.i(TAG, "事件流监听已开始") - } - - override fun onCancel(arguments: Any?) { - eventSink = null - Log.i(TAG, "事件流监听已取消") - } -} - -// 枚举定义 -enum class ConnectionStatus(val value: String) { - DISCONNECTED("disconnected"), - CONNECTING("connecting"), - CONNECTED("connected"), - ERROR("error") -} - -enum class VoiceStatus(val value: String) { - IDLE("idle"), - RECORDING("recording"), - PROCESSING("processing"), - PLAYING("playing") -} - -// 音频配置 -data class AudioConfig( - val sampleRate: Int, - val channels: Int, - val bitsPerSample: Int -) { - val frameSize: Int - get() = (sampleRate * channels * bitsPerSample / 8 * 20) / 1000 // 20ms帧 -} \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt b/apps/client/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt deleted file mode 100644 index 2726ceef..00000000 --- a/apps/client/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt +++ /dev/null @@ -1,303 +0,0 @@ -package com.yunqiinnovation.realtime - -import android.util.Log -import kotlinx.coroutines.* -import okhttp3.* -import okio.ByteString -import java.util.concurrent.TimeUnit - -/** - * 实时WebSocket管理器 - * - * 负责与Vocode服务器的WebSocket通信,对应Android示例中的OkHttp WebSocket功能 - */ -class RealtimeWebSocketManager : WebSocketListener() { - companion object { - private const val TAG = "RealtimeWebSocketManager" - private const val CONNECT_TIMEOUT = 30L - private const val READ_TIMEOUT = 60L - private const val WRITE_TIMEOUT = 30L - } - - // OkHttp客户端和WebSocket - private var okHttpClient: OkHttpClient? = null - private var webSocket: WebSocket? = null - - // 服务器URL - private var serverUrl: String = "" - - // 连接状态 - @Volatile - private var isConnected = false - @Volatile - private var isConnecting = false - - // 回调 - var onConnectionStatusChanged: ((ConnectionStatus) -> Unit)? = null - var onAudioReceived: ((ByteArray) -> Unit)? = null - var onTextReceived: ((String) -> Unit)? = null - var onError: ((String) -> Unit)? = null - - // 协程作用域 - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - /// 初始化WebSocket管理器 - fun initialize(serverUrl: String) { - Log.d(TAG, "initialize: 开始初始化。当前httpClient是否为null? ${okHttpClient == null}") - this.serverUrl = serverUrl - - // 创建OkHttp客户端 - okHttpClient = OkHttpClient.Builder() - .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) - .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) - .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build() - - Log.i(TAG, "initialize: WebSocket管理器初始化完成。服务器地址: $serverUrl") - } - - /// 连接到服务器 - suspend fun connect(): Boolean = withContext(Dispatchers.IO) { - Log.d(TAG, "connect: 尝试连接。当前状态 - isConnected: $isConnected, isConnecting: $isConnecting") - if (isConnected || isConnecting) { - Log.w(TAG, "connect: 连接请求被拒绝,因为已有连接或正在连接中。") - return@withContext false - } - - val client = okHttpClient ?: run { - Log.e(TAG, "connect: 连接失败,因为OkHttp客户端为null。请确保已调用initialize()。") - onError?.invoke("客户端未初始化") - return@withContext false - } - - Log.d(TAG, "connect: OkHttpClient实例存在,准备发起新连接。") - - try { - isConnecting = true - onConnectionStatusChanged?.invoke(ConnectionStatus.CONNECTING) - - val request = Request.Builder() - .url(serverUrl) - .build() - - Log.d(TAG, "connect: 正在创建新的WebSocket实例...") - webSocket = client.newWebSocket(request, this@RealtimeWebSocketManager) - - // 等待连接建立 - var waitTime = 0 - while (isConnecting && waitTime < CONNECT_TIMEOUT * 1000) { - delay(100) - waitTime += 100 - } - - if (isConnected) { - Log.i(TAG, "connect: WebSocket连接成功") - return@withContext true - } else { - Log.e(TAG, "connect: WebSocket连接超时") - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) - return@withContext false - } - } catch (e: Exception) { - Log.e(TAG, "WebSocket连接失败: ${e.message}", e) - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) - onError?.invoke("连接失败: ${e.message}") - return@withContext false - } - } - - /// 断开连接 - fun disconnect() { - try { - isConnected = false - isConnecting = false - - webSocket?.close(1000, "正常关闭") - webSocket = null - - onConnectionStatusChanged?.invoke(ConnectionStatus.DISCONNECTED) - Log.i(TAG, "WebSocket已断开连接") - } catch (e: Exception) { - Log.e(TAG, "断开WebSocket连接失败: ${e.message}", e) - } - } - - /// 发送音频数据 - fun sendAudioData(data: ByteArray) { - if (!isConnected) { - return - } - - try { - // 将音频数据编码为Base64并包装成Vocode WebSocket格式 - val base64AudioData = android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP) - val audioMessage = org.json.JSONObject().apply { - put("type", "websocket_audio") - put("data", base64AudioData) - } - - val success = webSocket?.send(audioMessage.toString()) ?: false - - if (!success) { - Log.w(TAG, "发送音频数据失败") - } - } catch (e: Exception) { - Log.e(TAG, "发送音频数据异常: ${e.message}", e) - } - } - - /// 发送文本消息 - fun sendTextMessage(message: String): Boolean { - if (!isConnected) { - Log.w(TAG, "WebSocket未连接,无法发送文本消息") - return false - } - - return try { - val success = webSocket?.send(message) ?: false - if (!success) { - Log.w(TAG, "发送文本消息失败") - } - success - } catch (e: Exception) { - Log.e(TAG, "发送文本消息异常: ${e.message}", e) - false - } - } - - /// 释放资源 - fun dispose() { - disconnect() - - // 立即取消scope,不等待 - try { - scope.cancel() - } catch (e: Exception) { - Log.w(TAG, "取消scope时出现异常: ${e.message}") - } - - // 立即强制关闭WebSocket - try { - webSocket?.close(1001, "强制关闭") - webSocket = null - } catch (e: Exception) { - Log.w(TAG, "强制关闭WebSocket时出现异常: ${e.message}") - } - - // 异步关闭线程池,设置更短的超时时间 - okHttpClient?.dispatcher?.executorService?.let { executor -> - Thread { - try { - executor.shutdownNow() // 立即强制关闭 - // 最多等待1秒,避免长时间等待 - if (!executor.awaitTermination(1, TimeUnit.SECONDS)) { - Log.w(TAG, "线程池强制关闭超时,放弃等待") - } - } catch (e: Exception) { - Log.w(TAG, "关闭线程池时出现异常: ${e.message}") - } - }.start() - } - okHttpClient = null - - Log.i(TAG, "WebSocket管理器已释放") - } - - // WebSocketListener 回调实现 - override fun onOpen(webSocket: WebSocket, response: Response) { - Log.i(TAG, "WebSocket连接已建立") - isConnected = true - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.CONNECTED) - } - - override fun onMessage(webSocket: WebSocket, text: String) { - // 添加收到文本消息的日志输出,显示消息类型和前20字符 - val textPreview = if (text.length > 50) text.substring(0, 50) + "..." else text - Log.d(TAG, "收到文本消息前20字符: $textPreview") - - try { - // 尝试解析为JSON来检查消息类型 - val json = org.json.JSONObject(text) - val messageType = json.optString("type", "") - - // 添加消息类型的日志输出 - Log.d(TAG, "消息类型: $messageType") - - when (messageType) { - "websocket_audio" -> { - // 处理音频消息 - val audioDataString = json.optString("data", "") - if (audioDataString.isNotEmpty()) { - // 将Base64编码的音频数据解码为字节数组 - val audioData = android.util.Base64.decode(audioDataString, android.util.Base64.DEFAULT) - onAudioReceived?.invoke(audioData) - } - } - "websocket_transcript" -> { - // 处理统一的文本消息格式 - // 包含sender、is_final、timestamp等字段 - onTextReceived?.invoke(text) - } - "websocket_ready" -> { - // 连接就绪消息 - Log.i(TAG, "WebSocket连接就绪") - } - "websocket_stop" -> { - // 会话停止消息 - Log.i(TAG, "会话已结束") - } - "websocket_start" -> { - // 会话开始消息 - Log.i(TAG, "会话已开始") - } - else -> { - // 其他消息类型,当作普通文本消息处理 - onTextReceived?.invoke(text) - } - } - } catch (e: Exception) { - // 如果不是JSON格式,当作普通文本消息处理 - Log.d(TAG, "非JSON格式消息,当作普通文本处理") - onTextReceived?.invoke(text) - } - } - - override fun onMessage(webSocket: WebSocket, bytes: ByteString) { - val audioData = bytes.toByteArray() - onAudioReceived?.invoke(audioData) - } - - override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - Log.i(TAG, "WebSocket正在关闭,代码: $code, 原因: $reason") - webSocket.close(1000, null) - } - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.i(TAG, "WebSocket已关闭,代码: $code, 原因: $reason") - isConnected = false - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.DISCONNECTED) - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - val errorMessage = when { - t.message?.contains("Unable to parse TLS packet header") == true -> - "SSL错误:请检查服务器是否支持wss://,本地开发请使用ws://" - t.message?.contains("Connection refused") == true -> - "连接被拒绝:请检查服务器是否运行在指定端口" - t.message?.contains("timeout") == true -> - "连接超时:请检查网络连接和服务器地址" - else -> "连接失败: ${t.message}" - } - - Log.e(TAG, "WebSocket连接失败: ${t.message}", t) - isConnected = false - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) - onError?.invoke(errorMessage) - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/example.md b/apps/client/local_plugins/realtime/example.md deleted file mode 100644 index 74b8ebd4..00000000 --- a/apps/client/local_plugins/realtime/example.md +++ /dev/null @@ -1,274 +0,0 @@ -# Realtime Plugin 使用示例 - -## 基本用法 - -```dart -import 'package:realtime/realtime.dart'; - -class VoiceCallPage extends StatefulWidget { - @override - _VoiceCallPageState createState() => _VoiceCallPageState(); -} - -class _VoiceCallPageState extends State { - final RealtimeService _realtimeService = RealtimeService(); - StreamSubscription? _eventSubscription; - - ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; - VoiceStatus _voiceStatus = VoiceStatus.idle; - - @override - void initState() { - super.initState(); - _setupEventListener(); - _initializeService(); - } - - void _setupEventListener() { - _eventSubscription = _realtimeService.eventStream.listen((event) { - switch (event.type) { - case 'connectionStatusChanged': - setState(() { - _connectionStatus = _parseConnectionStatus(event.data); - }); - break; - case 'voiceStatusChanged': - setState(() { - _voiceStatus = _parseVoiceStatus(event.data); - }); - break; - case 'textReceived': - print('收到文本: ${event.data}'); - break; - case 'error': - print('错误: ${event.data}'); - break; - } - }); - } - - Future _initializeService() async { - try { - final success = await _realtimeService.initialize( - serverUrl: 'wss://your-vocode-server.com/ws', - sampleRate: 16000, - channels: 1, - bitsPerSample: 16, - ); - - if (success) { - print('Realtime服务初始化成功'); - } else { - print('Realtime服务初始化失败'); - } - } catch (e) { - print('初始化错误: $e'); - } - } - - Future _connect() async { - try { - final success = await _realtimeService.connect(); - if (!success) { - print('连接失败'); - } - } catch (e) { - print('连接错误: $e'); - } - } - - Future _disconnect() async { - try { - await _realtimeService.disconnect(); - } catch (e) { - print('断开连接错误: $e'); - } - } - - Future _startRecording() async { - try { - final success = await _realtimeService.startRecording(); - if (!success) { - print('开始录音失败'); - } - } catch (e) { - print('录音错误: $e'); - } - } - - Future _stopRecording() async { - try { - final success = await _realtimeService.stopRecording(); - if (!success) { - print('停止录音失败'); - } - } catch (e) { - print('停止录音错误: $e'); - } - } - - ConnectionStatus _parseConnectionStatus(String status) { - switch (status) { - case 'connected': return ConnectionStatus.connected; - case 'connecting': return ConnectionStatus.connecting; - case 'disconnected': return ConnectionStatus.disconnected; - case 'error': return ConnectionStatus.error; - default: return ConnectionStatus.disconnected; - } - } - - VoiceStatus _parseVoiceStatus(String status) { - switch (status) { - case 'recording': return VoiceStatus.recording; - case 'processing': return VoiceStatus.processing; - case 'playing': return VoiceStatus.playing; - case 'idle': return VoiceStatus.idle; - default: return VoiceStatus.idle; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text('实时语音聊天'), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 连接状态显示 - Text('连接状态: ${_connectionStatus.name}'), - SizedBox(height: 20), - - // 语音状态显示 - Text('语音状态: ${_voiceStatus.name}'), - SizedBox(height: 40), - - // 连接按钮 - ElevatedButton( - onPressed: _connectionStatus == ConnectionStatus.disconnected - ? _connect - : _disconnect, - child: Text(_connectionStatus == ConnectionStatus.disconnected - ? '连接' - : '断开'), - ), - SizedBox(height: 20), - - // 录音按钮 - ElevatedButton( - onPressed: _connectionStatus == ConnectionStatus.connected - ? (_voiceStatus == VoiceStatus.recording - ? _stopRecording - : _startRecording) - : null, - child: Text(_voiceStatus == VoiceStatus.recording - ? '停止录音' - : '开始录音'), - ), - ], - ), - ), - ); - } - - @override - void dispose() { - _eventSubscription?.cancel(); - _realtimeService.dispose(); - super.dispose(); - } -} -``` - -## GetX控制器中的使用 - -```dart -import 'package:get/get.dart'; -import 'package:realtime/realtime.dart'; - -class VoiceController extends GetxController { - final RealtimeService _realtimeService = RealtimeService(); - - final RxBool isConnected = false.obs; - final RxBool isRecording = false.obs; - - StreamSubscription? _eventSubscription; - - @override - void onInit() { - super.onInit(); - _setupEventListener(); - _initializeService(); - } - - void _setupEventListener() { - _eventSubscription = _realtimeService.eventStream.listen((event) { - switch (event.type) { - case 'connectionStatusChanged': - isConnected.value = event.data == 'connected'; - break; - case 'voiceStatusChanged': - isRecording.value = event.data == 'recording'; - break; - } - }); - } - - Future _initializeService() async { - await _realtimeService.initialize( - serverUrl: 'wss://your-server/ws', - ); - } - - Future toggleConnection() async { - if (isConnected.value) { - await _realtimeService.disconnect(); - } else { - await _realtimeService.connect(); - } - } - - Future toggleRecording() async { - if (isRecording.value) { - await _realtimeService.stopRecording(); - } else { - await _realtimeService.startRecording(); - } - } - - @override - void onClose() { - _eventSubscription?.cancel(); - _realtimeService.dispose(); - super.onClose(); - } -} -``` - -## 权限配置 - -### iOS -在 `ios/Runner/Info.plist` 中添加: - -```xml -NSMicrophoneUsageDescription -应用需要麦克风权限进行语音录制 -``` - -## 服务器配置示例 - -需要一个支持WebSocket的Vocode服务器,具体实现可参考Vocode官方文档。 - -服务器需要: -1. 接收16kHz/16-bit/单声道的PCM音频数据 -2. 返回相同格式的音频数据 -3. 支持文本消息交换 - -## 注意事项 - -1. 确保网络连接稳定 -2. 音频格式必须与服务器保持一致 -3. 及时处理事件流中的错误 -4. 在适当时机释放资源 \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/ios/realtime/Package.swift b/apps/client/local_plugins/realtime/ios/realtime/Package.swift deleted file mode 100644 index 3191b50b..00000000 --- a/apps/client/local_plugins/realtime/ios/realtime/Package.swift +++ /dev/null @@ -1,18 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "realtime", - platforms: [.iOS("16.0")], - products: [ - .library(name: "realtime", targets: ["realtime"]) - ], - dependencies: [], - targets: [ - .target( - name: "realtime", - dependencies: [], - path: "Sources/realtime" - ) - ] -) \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift b/apps/client/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift deleted file mode 100644 index acca2477..00000000 --- a/apps/client/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift +++ /dev/null @@ -1,377 +0,0 @@ -import AVFoundation -import os.log -import Accelerate - -/** - * 实时音频管理器 - * - * 负责音频的录制和播放,对应Android示例中的AudioRecord和AudioTrack功能 - */ -class RealtimeAudioManager { - - private let log = OSLog(subsystem: "com.yunqiinnovation.realtime", category: "RealtimeAudioManager") - - // 音频配置 - private var audioConfig: AudioConfig? - - // 录音相关 - private var audioEngine: AVAudioEngine? - private var inputNode: AVAudioInputNode? - private var recordingFormat: AVAudioFormat? - private var isRecording = false - - // 播放相关 - private var audioPlayer: AVAudioPlayerNode? - private var playbackFormat: AVAudioFormat? - private var playbackQueue: DispatchQueue - private var audioBufferQueue: [AVAudioPCMBuffer] = [] - private var isPlaying = false - - // 回调 - var onAudioData: ((Data) -> Void)? - var onStatusChanged: ((VoiceStatus) -> Void)? - var onRmsChanged: ((Float) -> Void)? - - // 录音缓冲区 - private var recordingBuffer: AVAudioPCMBuffer? - private var frameSize: Int = 320 // 20ms @ 16kHz - - init() { - playbackQueue = DispatchQueue(label: "com.realtime.playback", qos: .userInitiated) - } - - /// 激活会话,抢占音频焦点(暂停外部和内部其他音频) - private func activateAudioSession() { - do { - let session = AVAudioSession.sharedInstance() - try session.setCategory(.playAndRecord, - mode: .voiceChat, - options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]) - try session.setActive(true, options: .notifyOthersOnDeactivation) - os_log("音频会话已激活(抢占焦点)", log: log, type: .info) - } catch { - os_log("音频会话激活失败: %@", log: log, type: .error, error.localizedDescription) - } - } - - /// 释放会话,归还音频焦点(让外部和内部其他音频恢复) - private func deactivateAudioSession() { - do { - try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - os_log("音频会话已释放(归还焦点)", log: log, type: .info) - } catch { - os_log("音频会话释放失败: %@", log: log, type: .error, error.localizedDescription) - } - } - - /// 初始化音频管理器 - func initialize(config: AudioConfig) -> Bool { - self.audioConfig = config - self.frameSize = config.frameSize - - guard setupAudioEngine(config: config) else { - os_log("音频引擎初始化失败", log: log, type: .error) - return false - } - - guard setupAudioPlayer(config: config) else { - os_log("音频播放器初始化失败", log: log, type: .error) - return false - } - - os_log("音频管理器初始化成功", log: log, type: .info) - return true - } - - /// 设置音频引擎(录音) - private func setupAudioEngine(config: AudioConfig) -> Bool { - audioEngine = AVAudioEngine() - - guard let audioEngine = audioEngine else { return false } - - inputNode = audioEngine.inputNode - - // 创建录音格式:16kHz, 16-bit, 单声道 - recordingFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: Double(config.sampleRate), - channels: AVAudioChannelCount(config.channels), - interleaved: true) - - guard let recordingFormat = recordingFormat else { - os_log("无法创建录音格式", log: log, type: .error) - return false - } - - // 创建录音缓冲区 - let frameCount = AVAudioFrameCount(config.frameSize / (config.bitsPerSample / 8)) - recordingBuffer = AVAudioPCMBuffer(pcmFormat: recordingFormat, frameCapacity: frameCount) - - return true - } - - /// 设置音频播放器 - private func setupAudioPlayer(config: AudioConfig) -> Bool { - guard let audioEngine = audioEngine else { return false } - - audioPlayer = AVAudioPlayerNode() - - // 创建播放格式:16kHz, 16-bit, 单声道 - playbackFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: Double(config.sampleRate), - channels: AVAudioChannelCount(config.channels), - interleaved: true) - - guard let audioPlayer = audioPlayer, - let playbackFormat = playbackFormat else { - os_log("无法创建播放格式", log: log, type: .error) - return false - } - - // 连接播放器到音频引擎 - audioEngine.attach(audioPlayer) - audioEngine.connect(audioPlayer, to: audioEngine.outputNode, format: playbackFormat) - - return true - } - - /// 开始录音 - func startRecording() -> Bool { - guard let audioEngine = audioEngine, - let inputNode = inputNode, - let recordingFormat = recordingFormat, - !isRecording else { - return false - } - - // 抢占音频焦点(暂停外部和内部其他音乐) - activateAudioSession() - - // 安装录音tap - inputNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(frameSize / 2), format: recordingFormat) { [weak self] buffer, time in - self?.processRecordedAudio(buffer: buffer) - } - - do { - try audioEngine.start() - isRecording = true - onStatusChanged?(.recording) - os_log("开始录音", log: log, type: .info) - return true - } catch { - os_log("开始录音失败: %@", log: log, type: .error, error.localizedDescription) - return false - } - } - - /// 处理录制的音频数据 - private func processRecordedAudio(buffer: AVAudioPCMBuffer) { - guard let channelData = buffer.int16ChannelData?[0], - buffer.frameLength > 0 else { - return - } - - // 确保数据长度匹配frameSize - let dataLength = min(Int(buffer.frameLength) * 2, frameSize) // 2 bytes per sample - - // 转换为Data - let data = Data(bytes: channelData, count: dataLength) - - // 计算RMS值 - let rms = calculateRMS(buffer: buffer) - onRmsChanged?(rms) - - // 回调音频数据 - onAudioData?(data) - } - - /// 停止录音 - func stopRecording() -> Bool { - guard let audioEngine = audioEngine, - let inputNode = inputNode, - isRecording else { - return false - } - - inputNode.removeTap(onBus: 0) - audioEngine.stop() - isRecording = false - onStatusChanged?(.idle) - os_log("停止录音", log: log, type: .info) - - // 录音和播放都停止时归还焦点,让外部音乐恢复 - if !isPlaying { - deactivateAudioSession() - } - return true - } - - /// 播放音频数据 - func playAudioData(_ data: Data) { - guard let playbackFormat = playbackFormat, - let audioPlayer = audioPlayer else { - return - } - - playbackQueue.async { [weak self] in - self?.enqueueAudioData(data, format: playbackFormat, player: audioPlayer) - } - } - - /// 将音频数据加入播放队列 - private func enqueueAudioData(_ data: Data, format: AVAudioFormat, player: AVAudioPlayerNode) { - // 创建PCM缓冲区 - let frameCount = AVAudioFrameCount(data.count / 2) // 16-bit = 2 bytes per sample - - guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { - os_log("无法创建播放缓冲区", log: log, type: .error) - return - } - - buffer.frameLength = frameCount - - // 复制数据到缓冲区 - guard let channelData = buffer.int16ChannelData?[0] else { - return - } - - data.withUnsafeBytes { bytes in - let int16Pointer = bytes.bindMemory(to: Int16.self) - channelData.update(from: int16Pointer.baseAddress!, count: Int(frameCount)) - } - - // 计算播放数据的RMS值 - let rms = calculateRMS(buffer: buffer) - DispatchQueue.main.async { [weak self] in - self?.onRmsChanged?(rms) - } - - // 加入播放队列 - audioBufferQueue.append(buffer) - - // 如果没在播放,开始播放 - if !isPlaying { - startPlayback() - } - } - - /// 开始播放 - private func startPlayback() { - guard let audioEngine = audioEngine, - let audioPlayer = audioPlayer, - !audioBufferQueue.isEmpty else { - return - } - - if !audioEngine.isRunning { - do { - try audioEngine.start() - } catch { - os_log("启动音频引擎失败: %@", log: log, type: .error, error.localizedDescription) - return - } - } - - if !audioPlayer.isPlaying { - audioPlayer.play() - } - - isPlaying = true - onStatusChanged?(.playing) - - // 播放队列中的音频 - playNextBuffer() - } - - /// 播放下一个缓冲区 - private func playNextBuffer() { - guard let audioPlayer = audioPlayer, - !audioBufferQueue.isEmpty else { - isPlaying = false - onStatusChanged?(.idle) - return - } - - let buffer = audioBufferQueue.removeFirst() - - audioPlayer.scheduleBuffer(buffer) { [weak self] in - DispatchQueue.main.async { - self?.playNextBuffer() - } - } - } - - /// 停止播放 - func stopPlaying() -> Bool { - guard let audioPlayer = audioPlayer else { - return false - } - - audioPlayer.stop() - audioBufferQueue.removeAll() - isPlaying = false - onStatusChanged?(.idle) - os_log("停止播放", log: log, type: .info) - - // 录音和播放都停止时归还焦点,让外部音乐恢复 - if !isRecording { - deactivateAudioSession() - } - return true - } - - /// 更新配置 - func updateConfig(config: AudioConfig) -> Bool { - // 如果正在录音或播放,先停止 - if isRecording { - _ = stopRecording() - } - if isPlaying { - _ = stopPlaying() - } - - return initialize(config: config) - } - - /// 释放资源 - func dispose() { - _ = stopRecording() - _ = stopPlaying() - - audioEngine?.stop() - audioEngine = nil - audioPlayer = nil - audioBufferQueue.removeAll() - - os_log("音频管理器已释放", log: log, type: .info) - } - - /// 计算音频缓冲区的RMS值 -private func calculateRMS(buffer: AVAudioPCMBuffer) -> Float { - guard let channelData = buffer.int16ChannelData?[0], - buffer.frameLength > 0 else { - return 0.0 - } - - let frameCount = Int(buffer.frameLength) - let channelDataPointer = UnsafeMutablePointer(mutating: channelData) - - // 创建浮点缓冲区用于转换 - var floatData = [Float](repeating: 0.0, count: frameCount) - - // 1. 将16位整型转换为浮点型 - vDSP_vflt16(channelDataPointer, 1, &floatData, 1, vDSP_Length(frameCount)) - - // 2. 归一化到[-1.0, 1.0]范围 - var normalizedData = [Float](repeating: 0.0, count: frameCount) - var divisor: Float = 32768.0 - vDSP_vsdiv(floatData, 1, &divisor, &normalizedData, 1, vDSP_Length(frameCount)) - - // 3. 计算均方值 - var meanSquare: Float = 0.0 - vDSP_measqv(normalizedData, 1, &meanSquare, vDSP_Length(frameCount)) - - // 4. 计算RMS值 - let rms = sqrt(meanSquare) - return rms -} -} \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift b/apps/client/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift deleted file mode 100644 index 23f6720b..00000000 --- a/apps/client/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift +++ /dev/null @@ -1,319 +0,0 @@ -import Flutter -import UIKit -import AVFoundation -import os.log - -/** - * 实时语音聊天插件 - * - * 通过WebSocket连接Vocode服务器实现实时语音交互 - * 包含录音、播放、WebSocket通信等功能 - */ -@objc public class RealtimePlugin: NSObject, FlutterPlugin { - - private let log = OSLog(subsystem: "com.yunqiinnovation.realtime", category: "RealtimePlugin") - - // Flutter通道 - private var methodChannel: FlutterMethodChannel? - private var eventChannel: FlutterEventChannel? - private var eventSink: FlutterEventSink? - - // 核心组件 - private let audioManager = RealtimeAudioManager() - private let websocketManager = RealtimeWebSocketManager() - - // 配置参数 - private var serverUrl: String = "" - private var sampleRate: Int = 16000 - private var channels: Int = 1 - private var bitsPerSample: Int = 16 - - // 状态 - private var connectionStatus: ConnectionStatus = .disconnected - private var voiceStatus: VoiceStatus = .idle - - /// 插件注册 - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = RealtimePlugin() - - // 方法通道 - let methodChannel = FlutterMethodChannel(name: "realtime/methods", binaryMessenger: registrar.messenger()) - registrar.addMethodCallDelegate(instance, channel: methodChannel) - instance.methodChannel = methodChannel - - // 事件通道 - let eventChannel = FlutterEventChannel(name: "realtime/events", binaryMessenger: registrar.messenger()) - eventChannel.setStreamHandler(instance) - instance.eventChannel = eventChannel - - // 设置组件回调 - instance.setupCallbacks() - } - - /// 设置各组件的回调 - private func setupCallbacks() { - // 音频管理器回调 - audioManager.onAudioData = { [weak self] data in - // 发送到WebSocket - self?.websocketManager.sendAudioData(data) - // 同时发送PCM数据到Flutter用于可视化 - self?.sendPCMDataToFlutter(data) - } - - audioManager.onStatusChanged = { [weak self] status in - self?.voiceStatus = status - self?.sendEvent(type: "voiceStatusChanged", data: status.rawValue) - } - - // 新增RMS回调 - audioManager.onRmsChanged = { [weak self] rms in - self?.sendEvent(type: "audioRms", data: rms) - } - - // WebSocket管理器回调 - websocketManager.onConnectionStatusChanged = { [weak self] status in - self?.connectionStatus = status - self?.sendEvent(type: "connectionStatusChanged", data: status.rawValue) - } - - websocketManager.onAudioReceived = { [weak self] data in - // 添加音频数据前50字节的日志输出 - let dataCount = min(data.count, 50) - let dataPreview = data.prefix(dataCount).map { String(format: "%02x", $0) }.joined(separator: " ") - let previewText = data.count > 50 ? dataPreview + "..." : dataPreview - self?.os_log("收到音频数据前50字节: %@", log: self?.log ?? OSLog.default, type: .debug, previewText) - self?.os_log("音频数据总长度: %d 字节", log: self?.log ?? OSLog.default, type: .debug, data.count) - - self?.audioManager.playAudioData(data) - // 同时发送接收到的PCM数据到Flutter用于可视化 - self?.sendPCMDataToFlutter(data) - } - - websocketManager.onTextReceived = { [weak self] text in - // 直接发送原始JSON文本到Flutter层处理 - self?.sendEvent(type: "textReceived", data: text) - } - - websocketManager.onError = { [weak self] error in - self?.sendEvent(type: "error", data: error) - } - } - - /// 发送事件到Flutter端 - private func sendEvent(type: String, data: Any?) { - guard let eventSink = eventSink else { return } - - let event: [String: Any] = [ - "type": type, - "data": data ?? NSNull() - ] - - DispatchQueue.main.async { - eventSink(event) - } - } - - /// 将PCM数据发送到Flutter用于可视化 - private func sendPCMDataToFlutter(_ data: Data) { - // 确保数据长度是偶数(每2个字节一个16位采样) - let validSize = data.count & ~1 // 清除最低位,确保偶数 - - guard validSize > 0 else { return } - - // 将Data转换为Int16数组 (PCM16格式) - let pcm16Array = data.prefix(validSize).withUnsafeBytes { bytes in - let int16Pointer = bytes.bindMemory(to: Int16.self) - return Array(UnsafeBufferPointer(start: int16Pointer.baseAddress, count: validSize / 2)) - } - - // 转换为Int数组以便传递给Flutter - let pcmData = pcm16Array.map { Int($0) } - sendEvent(type: "pcmData", data: pcmData) - } - - /// 辅助日志方法 - private func os_log(_ message: String, log: OSLog, type: OSLogType, _ args: CVarArg...) { - if args.isEmpty { - os_log("%@", log: log, type: type, message) - } else { - os_log(message, log: log, type: type, args) - } - } -} - -// MARK: - FlutterPlugin -extension RealtimePlugin { - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "initialize": - handleInitialize(call, result) - case "connect": - handleConnect(result) - case "disconnect": - handleDisconnect(result) - case "startRecording": - handleStartRecording(result) - case "stopRecording": - handleStopRecording(result) - case "stopPlaying": - handleStopPlaying(result) - case "getConnectionStatus": - result(connectionStatus.rawValue) - case "getVoiceStatus": - result(voiceStatus.rawValue) - case "sendTextMessage": - handleSendTextMessage(call, result) - case "setAudioConfig": - handleSetAudioConfig(call, result) - case "dispose": - handleDispose(result) - default: - result(FlutterMethodNotImplemented) - } - } - - private func handleInitialize(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let serverUrl = args["serverUrl"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "服务器地址不能为空", details: nil)) - return - } - - self.serverUrl = serverUrl - self.sampleRate = args["sampleRate"] as? Int ?? 16000 - self.channels = args["channels"] as? Int ?? 1 - self.bitsPerSample = args["bitsPerSample"] as? Int ?? 16 - - // 初始化音频管理器 - let audioConfig = AudioConfig( - sampleRate: sampleRate, - channels: channels, - bitsPerSample: bitsPerSample - ) - - let success = audioManager.initialize(config: audioConfig) - if success { - websocketManager.initialize(serverUrl: serverUrl) - os_log("实时语音插件初始化成功", log: log, type: .info) - } else { - os_log("实时语音插件初始化失败", log: log, type: .error) - } - - result(success) - } - - private func handleConnect(_ result: @escaping FlutterResult) { - websocketManager.connect { [weak self] success in - DispatchQueue.main.async { - result(success) - } - } - } - - private func handleDisconnect(_ result: @escaping FlutterResult) { - websocketManager.disconnect() - result(true) - } - - private func handleStartRecording(_ result: @escaping FlutterResult) { - let success = audioManager.startRecording() - result(success) - } - - private func handleStopRecording(_ result: @escaping FlutterResult) { - let success = audioManager.stopRecording() - result(success) - } - - private func handleStopPlaying(_ result: @escaping FlutterResult) { - let success = audioManager.stopPlaying() - result(success) - } - - private func handleSendTextMessage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let message = args["message"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "消息内容不能为空", details: nil)) - return - } - - let success = websocketManager.sendTextMessage(message) - result(success) - } - - private func handleSetAudioConfig(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any] else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "参数无效", details: nil)) - return - } - - if let sampleRate = args["sampleRate"] as? Int { - self.sampleRate = sampleRate - } - if let channels = args["channels"] as? Int { - self.channels = channels - } - if let bitsPerSample = args["bitsPerSample"] as? Int { - self.bitsPerSample = bitsPerSample - } - - let audioConfig = AudioConfig( - sampleRate: sampleRate, - channels: channels, - bitsPerSample: bitsPerSample - ) - - let success = audioManager.updateConfig(config: audioConfig) - result(success) - } - - private func handleDispose(_ result: @escaping FlutterResult) { - audioManager.dispose() - websocketManager.dispose() - result(nil) - } -} - -// MARK: - FlutterStreamHandler -extension RealtimePlugin: FlutterStreamHandler { - - public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - self.eventSink = events - return nil - } - - public func onCancel(withArguments arguments: Any?) -> FlutterError? { - self.eventSink = nil - return nil - } -} - -// MARK: - 枚举定义 - -enum ConnectionStatus: String { - case disconnected = "disconnected" - case connecting = "connecting" - case connected = "connected" - case error = "error" -} - -enum VoiceStatus: String { - case idle = "idle" - case recording = "recording" - case processing = "processing" - case playing = "playing" -} - -// MARK: - 音频配置 - -struct AudioConfig { - let sampleRate: Int - let channels: Int - let bitsPerSample: Int - - var frameSize: Int { - // 20ms的帧大小 - return (sampleRate * channels * bitsPerSample / 8 * 20) / 1000 - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift b/apps/client/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift deleted file mode 100644 index 03910198..00000000 --- a/apps/client/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift +++ /dev/null @@ -1,323 +0,0 @@ -import Foundation -import Network -import os.log - -/** - * 实时WebSocket管理器 - * - * 负责与Vocode服务器的WebSocket通信,对应Android示例中的OkHttp WebSocket功能 - */ -class RealtimeWebSocketManager: NSObject { - - private let log = OSLog(subsystem: "com.yunqiinnovation.realtime", category: "RealtimeWebSocketManager") - - // WebSocket连接 - private var webSocketTask: URLSessionWebSocketTask? - private var urlSession: URLSession? - - // 服务器URL - private var serverUrl: String = "" - - // 连接状态 - private var isConnected = false - private var isConnecting = false - - // 回调 - var onConnectionStatusChanged: ((ConnectionStatus) -> Void)? - var onAudioReceived: ((Data) -> Void)? - var onTextReceived: ((String) -> Void)? - var onError: ((String) -> Void)? - - // 发送队列 - private let sendQueue = DispatchQueue(label: "com.realtime.websocket.send", qos: .userInitiated) - private let receiveQueue = DispatchQueue(label: "com.realtime.websocket.receive", qos: .userInitiated) - - override init() { - super.init() - setupURLSession() - } - - /// 设置URL会话 - private func setupURLSession() { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 60 - urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil) - } - - /// 初始化WebSocket管理器 - func initialize(serverUrl: String) { - self.serverUrl = serverUrl - os_log("WebSocket管理器初始化,服务器地址: %@", log: log, type: .info, serverUrl) - } - - /// 连接到服务器 - func connect(completion: @escaping (Bool) -> Void) { - guard !isConnected && !isConnecting else { - completion(false) - return - } - - guard let url = URL(string: serverUrl) else { - os_log("无效的服务器地址: %@", log: log, type: .error, serverUrl) - onError?("无效的服务器地址") - completion(false) - return - } - - isConnecting = true - onConnectionStatusChanged?(.connecting) - - // 创建WebSocket任务 - var request = URLRequest(url: url) - request.timeoutInterval = 30 - - webSocketTask = urlSession?.webSocketTask(with: request) - - // 开始监听消息 - startListening() - - // 开始连接 - webSocketTask?.resume() - - // 等待连接建立 - DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.checkConnection(completion: completion) - } - } - - /// 检查连接状态 - private func checkConnection(completion: @escaping (Bool) -> Void) { - guard let webSocketTask = webSocketTask else { - isConnecting = false - onConnectionStatusChanged?(.error) - completion(false) - return - } - - switch webSocketTask.state { - case .running: - isConnected = true - isConnecting = false - onConnectionStatusChanged?(.connected) - os_log("WebSocket连接成功", log: log, type: .info) - completion(true) - case .suspended: - // 等待连接完成 - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.checkConnection(completion: completion) - } - default: - isConnecting = false - onConnectionStatusChanged?(.error) - os_log("WebSocket连接失败", log: log, type: .error) - completion(false) - } - } - - /// 开始监听消息 - private func startListening() { - receiveMessage() - } - - /// 接收消息 - private func receiveMessage() { - webSocketTask?.receive { [weak self] result in - switch result { - case .success(let message): - self?.handleReceivedMessage(message) - // 继续监听下一条消息 - self?.receiveMessage() - case .failure(let error): - self?.handleReceiveError(error) - } - } - } - - /// 处理接收到的消息 - private func handleReceivedMessage(_ message: URLSessionWebSocketTask.Message) { - switch message { - case .data(let data): - // 保留对二进制数据的支持以防万一 - onAudioReceived?(data) - case .string(let text): - // 添加收到文本消息的日志输出,显示前20字符 - let textPreview = text.count > 20 ? String(text.prefix(20)) + "..." : text - os_log("收到文本消息前20字符: %@", log: log, type: .debug, textPreview) - - // 尝试解析为JSON来检查消息类型 - do { - if let jsonData = text.data(using: .utf8), - let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], - let messageType = json["type"] as? String { - - // 添加消息类型的日志输出 - os_log("消息类型: %@", log: log, type: .debug, messageType) - - switch messageType { - case "websocket_audio": - // 处理音频消息 - if let audioDataString = json["data"] as? String, - let audioData = Data(base64Encoded: audioDataString) { - onAudioReceived?(audioData) - } - case "websocket_transcript": - // 处理统一的文本消息格式 - // 包含sender、is_final、timestamp等字段 - onTextReceived?(text) - case "websocket_ready": - // 连接就绪消息 - os_log("WebSocket连接就绪", log: log, type: .info) - case "websocket_stop": - // 会话停止消息 - os_log("会话已结束", log: log, type: .info) - case "websocket_start": - // 会话开始消息 - os_log("会话已开始", log: log, type: .info) - default: - // 其他消息类型,当作普通文本消息处理 - onTextReceived?(text) - } - } else { - // 如果不是JSON格式,当作普通文本消息处理 - os_log("非JSON格式消息,当作普通文本处理", log: log, type: .debug) - onTextReceived?(text) - } - } catch { - // 如果解析失败,当作普通文本消息处理 - os_log("非JSON格式消息,当作普通文本处理", log: log, type: .debug) - onTextReceived?(text) - } - @unknown default: - os_log("收到未知类型消息", log: log, type: .error) - } - } - - /// 处理接收错误 - private func handleReceiveError(_ error: Error) { - os_log("WebSocket接收错误: %@", log: log, type: .error, error.localizedDescription) - - // 如果连接断开,尝试重连 - if isConnected { - isConnected = false - onConnectionStatusChanged?(.error) - onError?("连接断开: \(error.localizedDescription)") - } - } - - /// 发送音频数据 - func sendAudioData(_ data: Data) { - guard isConnected else { return } - - sendQueue.async { [weak self] in - // 将音频数据编码为Base64并包装成Vocode WebSocket格式 - let base64AudioData = data.base64EncodedString() - let audioMessage: [String: Any] = [ - "type": "websocket_audio", - "data": base64AudioData - ] - - do { - let jsonData = try JSONSerialization.data(withJSONObject: audioMessage) - if let jsonString = String(data: jsonData, encoding: .utf8) { - self?.webSocketTask?.send(.string(jsonString)) { error in - if let error = error { - self?.os_log("发送音频数据失败: %@", log: self?.log ?? OSLog.default, type: .error, error.localizedDescription) - } - } - } - } catch { - self?.os_log("音频数据JSON序列化失败: %@", log: self?.log ?? OSLog.default, type: .error, error.localizedDescription) - } - } - } - - /// 发送文本消息 - func sendTextMessage(_ message: String) -> Bool { - guard isConnected else { - os_log("WebSocket未连接,无法发送文本消息", log: log, type: .error) - return false - } - - sendQueue.async { [weak self] in - self?.webSocketTask?.send(.string(message)) { error in - if let error = error { - self?.os_log("发送文本消息失败: %@", log: self?.log ?? OSLog.default, type: .error, error.localizedDescription) - } - } - } - - return true - } - - /// 断开连接 - func disconnect() { - guard isConnected || isConnecting else { return } - - webSocketTask?.cancel(with: .goingAway, reason: nil) - webSocketTask = nil - - isConnected = false - isConnecting = false - onConnectionStatusChanged?(.disconnected) - os_log("WebSocket已断开连接", log: log, type: .info) - } - - /// 释放资源 - func dispose() { - disconnect() - urlSession?.invalidateAndCancel() - urlSession = nil - os_log("WebSocket管理器已释放", log: log, type: .info) - } - - // 修复方法访问性问题 - private func os_log(_ message: String, log: OSLog, type: OSLogType, _ args: CVarArg...) { - if args.isEmpty { - os_log("%@", log: log, type: type, message) - } else { - os_log(message, log: log, type: type, args) - } - } -} - -// MARK: - URLSessionWebSocketDelegate -extension RealtimeWebSocketManager: URLSessionWebSocketDelegate { - - func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { - os_log("WebSocket连接已建立", log: log, type: .info) - isConnected = true - isConnecting = false - onConnectionStatusChanged?(.connected) - } - - func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - os_log("WebSocket连接已关闭,关闭码: %d", log: log, type: .info, closeCode.rawValue) - isConnected = false - isConnecting = false - onConnectionStatusChanged?(.disconnected) - } -} - -// MARK: - URLSessionDelegate -extension RealtimeWebSocketManager: URLSessionDelegate { - - func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { - if let error = error { - os_log("URLSession失效: %@", log: log, type: .error, error.localizedDescription) - onError?("会话失效: \(error.localizedDescription)") - } - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - if let error = error { - os_log("URLSessionTask完成时出错: %@", log: log, type: .error, error.localizedDescription) - if isConnected || isConnecting { - isConnected = false - isConnecting = false - onConnectionStatusChanged?(.error) - onError?("连接错误: \(error.localizedDescription)") - } - } - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/lib/realtime.dart b/apps/client/local_plugins/realtime/lib/realtime.dart deleted file mode 100644 index 237e9e3e..00000000 --- a/apps/client/local_plugins/realtime/lib/realtime.dart +++ /dev/null @@ -1,243 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:flutter/services.dart'; - -/// 实时语音服务异常 -class RealtimeException implements Exception { - final String message; - - RealtimeException(this.message); - - @override - String toString() => 'RealtimeException: $message'; -} - -/// 连接状态 -enum ConnectionStatus { - disconnected, - connecting, - connected, - error, -} - -/// 语音状态 -enum VoiceStatus { - idle, - recording, - processing, - playing, -} - -/// 实时语音事件 -class RealtimeEvent { - final String type; - final dynamic data; - - RealtimeEvent({required this.type, this.data}); - - factory RealtimeEvent.fromMap(Map map) { - return RealtimeEvent( - type: map['type'] as String, - data: map['data'], - ); - } -} - -/// 实时语音聊天服务 -class RealtimeService { - static const MethodChannel _channel = MethodChannel('realtime/methods'); - static const EventChannel _eventChannel = EventChannel('realtime/events'); - - StreamController? _eventStreamController; - Stream? _eventStream; - - /// 获取事件流 - Stream get eventStream { - if (_eventStream == null) { - _eventStreamController = StreamController.broadcast(); - _eventStream = _eventStreamController!.stream; - - // 监听原生事件 - _eventChannel.receiveBroadcastStream().listen( - (dynamic event) { - if (event is Map) { - final eventMap = Map.from(event); - final realtimeEvent = RealtimeEvent.fromMap(eventMap); - _eventStreamController!.add(realtimeEvent); - } - }, - onError: (error) { - _eventStreamController!.addError(RealtimeException('事件流错误: $error')); - }, - ); - } - - return _eventStream!; - } - - /// 初始化实时语音服务 - /// - /// [serverUrl] WebSocket服务器地址 - /// [sampleRate] 采样率,默认16000 - /// [channels] 声道数,默认1(单声道) - /// [bitsPerSample] 位深,默认16 - Future initialize({ - required String serverUrl, - int sampleRate = 16000, - int channels = 1, - int bitsPerSample = 16, - }) async { - try { - final result = await _channel.invokeMethod( - 'initialize', - { - 'serverUrl': serverUrl, - 'sampleRate': sampleRate, - 'channels': channels, - 'bitsPerSample': bitsPerSample, - }, - ); - - return result ?? false; - } catch (e) { - throw RealtimeException('初始化失败: $e'); - } - } - - /// 连接到服务器 - Future connect() async { - try { - final result = await _channel.invokeMethod('connect'); - return result ?? false; - } catch (e) { - throw RealtimeException('连接失败: $e'); - } - } - - /// 断开连接 - Future disconnect() async { - try { - final result = await _channel.invokeMethod('disconnect'); - return result ?? false; - } catch (e) { - throw RealtimeException('断开连接失败: $e'); - } - } - - /// 开始录音 - Future startRecording() async { - try { - final result = await _channel.invokeMethod('startRecording'); - return result ?? false; - } catch (e) { - throw RealtimeException('开始录音失败: $e'); - } - } - - /// 停止录音 - Future stopRecording() async { - try { - final result = await _channel.invokeMethod('stopRecording'); - return result ?? false; - } catch (e) { - throw RealtimeException('停止录音失败: $e'); - } - } - - /// 停止播放 - Future stopPlaying() async { - try { - final result = await _channel.invokeMethod('stopPlaying'); - return result ?? false; - } catch (e) { - throw RealtimeException('停止播放失败: $e'); - } - } - - /// 获取连接状态 - Future getConnectionStatus() async { - try { - final result = await _channel.invokeMethod('getConnectionStatus'); - switch (result) { - case 'disconnected': - return ConnectionStatus.disconnected; - case 'connecting': - return ConnectionStatus.connecting; - case 'connected': - return ConnectionStatus.connected; - case 'error': - return ConnectionStatus.error; - default: - return ConnectionStatus.disconnected; - } - } catch (e) { - throw RealtimeException('获取连接状态失败: $e'); - } - } - - /// 获取语音状态 - Future getVoiceStatus() async { - try { - final result = await _channel.invokeMethod('getVoiceStatus'); - switch (result) { - case 'idle': - return VoiceStatus.idle; - case 'recording': - return VoiceStatus.recording; - case 'processing': - return VoiceStatus.processing; - case 'playing': - return VoiceStatus.playing; - default: - return VoiceStatus.idle; - } - } catch (e) { - throw RealtimeException('获取语音状态失败: $e'); - } - } - - /// 发送文本消息 - Future sendTextMessage(String message) async { - try { - final result = await _channel.invokeMethod( - 'sendTextMessage', - {'message': message}, - ); - return result ?? false; - } catch (e) { - throw RealtimeException('发送文本消息失败: $e'); - } - } - - /// 设置音频参数 - Future setAudioConfig({ - int? sampleRate, - int? channels, - int? bitsPerSample, - }) async { - try { - final params = {}; - if (sampleRate != null) params['sampleRate'] = sampleRate; - if (channels != null) params['channels'] = channels; - if (bitsPerSample != null) params['bitsPerSample'] = bitsPerSample; - - final result = await _channel.invokeMethod('setAudioConfig', params); - return result ?? false; - } catch (e) { - throw RealtimeException('设置音频参数失败: $e'); - } - } - - /// 释放资源 - Future dispose() async { - try { - await _channel.invokeMethod('dispose'); - _eventStreamController?.close(); - _eventStreamController = null; - _eventStream = null; - } catch (e) { - throw RealtimeException('释放资源失败: $e'); - } - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/realtime/pubspec.yaml b/apps/client/local_plugins/realtime/pubspec.yaml deleted file mode 100644 index 0eed88a7..00000000 --- a/apps/client/local_plugins/realtime/pubspec.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: realtime -description: 实时语音聊天插件,通过WebSocket连接Vocode服务器实现语音交互 -version: 0.0.1 -homepage: - -environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=2.5.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# Flutter插件配置 -flutter: - plugin: - platforms: - android: - package: com.yunqiinnovation.realtime - pluginClass: RealtimePlugin - ios: - pluginClass: RealtimePlugin - swiftPackage: - path: ios/realtime \ No newline at end of file diff --git a/apps/client/local_plugins/volcano_speech/LICENSE b/apps/client/local_plugins/volcano_speech/LICENSE deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/client/local_plugins/volcano_speech/README.md b/apps/client/local_plugins/volcano_speech/README.md deleted file mode 100644 index 572317b5..00000000 --- a/apps/client/local_plugins/volcano_speech/README.md +++ /dev/null @@ -1,206 +0,0 @@ -# 火山引擎语音服务插件 - -基于火山引擎语音SDK封装的Flutter插件,支持Android平台。 - -## 功能 -- 语音合成(TTS) - - 支持在线/离线/混合合成模式 - - 支持SSML格式文本 - - 支持情感合成和情感预测 - - 支持音量、语速、音高等多种参数调节 - - 复刻音色支持 - - 离线资源管理 -- 语音识别(ASR) - - 一次性识别 - - 连续识别 - - 长按识别 - - 热词优化 - - 多语言支持 - -## 使用说明 - -### TTS 语音合成 - -#### 基础用法 -```dart -import 'package:volcano_speech/volcano_speech.dart'; - -// 初始化 -await VolcanoSpeech.initialize(appId: 'YOUR_APP_ID', apiKey: 'YOUR_API_KEY'); - -// 设置音量、语速等参数 -await VolcanoSpeech.setSpeechParams( - rate: 0, // 语速: -500到500,0为正常速度 - volume: 100, // 音量: 0到100,默认100 - pitch: 0, // 音高: -500到500,0为正常音高 - silenceDuration: 500, // 静音段时长: 毫秒 -); - -// 设置音色 -await VolcanoSpeech.setVoice( - voiceName: 'zh_female_qingxin', - voiceType: 'qingxin' -); - -// 开始合成并播放 -await VolcanoSpeech.speakText(text: '这是一段测试文本'); - -// 暂停播放 -await VolcanoSpeech.pausePlayback(); - -// 继续播放 -await VolcanoSpeech.resumePlayback(); - -// 停止播放 -await VolcanoSpeech.stopSpeaking(); - -// 销毁引擎 -await VolcanoSpeech.dispose(); -``` - -#### 进阶用法 -```dart -// 设置工作模式 -await VolcanoSpeech.setWorkMode(mode: TtsWorkMode.alternate); // 先在线,断网时切换离线 - -// 设置离线发音人 -await VolcanoSpeech.setOfflineVoice( - voiceName: 'xifei', - voiceType: 'qingxin' -); - -// 下载离线资源 -await VolcanoSpeech.downloadOfflineResource( - voiceTypes: ['qingxin', 'zhenjiang'], - languages: ['zh-CN'] -); - -// 使用SSML格式文本 -await VolcanoSpeech.setTextType(type: TtsTextType.ssml); -await VolcanoSpeech.speakText( - text: '这是一段2023-10-01的语音合成' -); - -// 设置情感 -await VolcanoSpeech.setEmotion(emotion: 'happy'); - -// 启用情感预测 -await VolcanoSpeech.setEnableEmotionPredict(enable: true); - -// 启用服务端缓存 -await VolcanoSpeech.setEnableCache(enable: true); - -// 监听TTS进度事件 -VolcanoSpeech.ttsProgressEvents.listen((event) { - print('播放进度: ${(event.progress * 100).toStringAsFixed(1)}%'); -}); - -// 复刻音色支持 -await VolcanoSpeech.setEnableVoiceClone( - enable: true, - backendCluster: 'your_cluster_name' -); -``` - -### ASR 语音识别 -#### 一次性识别 -```dart -import 'package:volcano_speech/volcano_speech.dart'; - -// 初始化 -await VolcanoSpeech.initializeAsr( - appId: 'YOUR_APP_ID', - apiKey: 'YOUR_API_KEY', - supportedLanguages: ['zh-CN'], -); - -// 设置识别语言 -await VolcanoSpeech.setAsrLanguage(language: 'zh-CN'); - -// 设置热词(可选) -await VolcanoSpeech.setAsrHotWords( - hotWords: '{"hotwords":[{"word":"火山引擎","scale":2.0}]}', -); - -// 开始一次性识别 -try { - final result = await VolcanoSpeech.recognizeOnce(); - print('识别结果: ${result['text']}, 语言: ${result['language']}'); -} catch (e) { - print('识别出错: $e'); -} - -// 销毁引擎 -await VolcanoSpeech.disposeAsr(); -``` - -#### 连续识别 -```dart -import 'package:volcano_speech/volcano_speech.dart'; - -// 初始化 -await VolcanoSpeech.initializeAsr( - appId: 'YOUR_APP_ID', - apiKey: 'YOUR_API_KEY', -); - -// 监听识别事件 -VolcanoSpeech.asrEvents.listen((event) { - switch (event.type) { - case AsrEventType.sessionStarted: - print('识别会话开始'); - break; - case AsrEventType.sessionStopped: - print('识别会话结束'); - break; - case AsrEventType.recognizing: - print('正在识别: ${event.text}'); - break; - case AsrEventType.result: - print('识别结果: ${event.text}'); - break; - case AsrEventType.volumeChanged: - print('音量: ${event.volume}'); - break; - case AsrEventType.error: - print('识别错误: ${event.errorMessage}'); - break; - } -}); - -// 开始连续识别 -await VolcanoSpeech.startContinuousRecognition(); - -// 检查是否正在识别 -final isActive = await VolcanoSpeech.isContinuousRecognitionActive(); -print('是否正在识别: $isActive'); - -// 停止连续识别 -await VolcanoSpeech.stopContinuousRecognition(); -``` - -#### 长按识别 -```dart -import 'package:volcano_speech/volcano_speech.dart'; - -// 初始化 -await VolcanoSpeech.initializeAsr( - appId: 'YOUR_APP_ID', - apiKey: 'YOUR_API_KEY', -); - -// 监听识别事件 -VolcanoSpeech.asrEvents.listen((event) { - // 处理事件... -}); - -// 用户按下按钮时开始识别 -onPressed: () async { - await VolcanoSpeech.startListening(); -}, - -// 用户释放按钮时停止识别 -onReleased: () async { - await VolcanoSpeech.stopListening(); -}, -``` \ No newline at end of file diff --git a/apps/client/local_plugins/volcano_speech/android/build.gradle.kts b/apps/client/local_plugins/volcano_speech/android/build.gradle.kts deleted file mode 100644 index 54987bb9..00000000 --- a/apps/client/local_plugins/volcano_speech/android/build.gradle.kts +++ /dev/null @@ -1,64 +0,0 @@ -buildscript { - repositories { - google() - mavenCentral() - } - dependencies { - classpath("com.android.tools.build:gradle:7.3.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.21") - } -} - -plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") -} - -android { - namespace = "com.yunqiinnovation.volcano_speech" - compileSdk = 35 - - defaultConfig { - minSdk = 21 - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = "11" - } - - sourceSets { - getByName("main") { - manifest.srcFile("src/main/AndroidManifest.xml") - java.srcDirs("src/main/kotlin") - } - } - - // 添加lint选项 - lintOptions { - isCheckReleaseBuilds = false - } - -} - -dependencies { - // Flutter插件依赖 - // compileOnly("io.flutter:flutter_embedding_debug:1.0.0-a794cf2681") - debugImplementation("io.flutter:flutter_embedding_debug:1.0.0-a794cf2681") - releaseImplementation("io.flutter:flutter_embedding_release:1.0.0-a794cf2681") - - // 添加火山引擎语音合成SDK - implementation("com.bytedance.speechengine:speechengine_tob:0.0.5") - // implementation("com.bytedance.speechengine:speechengine_tts_tob:5.4.8") - // implementation("com.bytedance.speechengine:speechengine_asr_tob:1.1.7") - implementation(project(":speech")) -} - -repositories { - google() - mavenCentral() -} \ No newline at end of file diff --git a/apps/client/local_plugins/volcano_speech/android/settings.gradle.kts b/apps/client/local_plugins/volcano_speech/android/settings.gradle.kts deleted file mode 100644 index 7f534286..00000000 --- a/apps/client/local_plugins/volcano_speech/android/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "volcano_speech" \ No newline at end of file diff --git a/apps/client/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml deleted file mode 100644 index 338e8293..00000000 --- a/apps/client/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt b/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt deleted file mode 100644 index aeaae51b..00000000 --- a/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt +++ /dev/null @@ -1,764 +0,0 @@ -package com.yunqiinnovation.volcano_speech - -import android.content.Context -import android.os.Handler -import android.os.Looper -import com.bytedance.speech.speechengine.SpeechEngine -import com.bytedance.speech.speechengine.SpeechEngineDefines -import com.bytedance.speech.speechengine.SpeechEngineGenerator -import android.util.Log -import org.json.JSONException -import org.json.JSONObject -import org.json.JSONArray -import java.nio.ByteBuffer -import java.nio.ByteOrder - -/** - * 火山语音识别帮助类 (大模型版本) - */ -class VolcanoAsrHelper(private val context: Context) { - private val TAG = "VolcanoAsrHelper" - private val mainHandler = Handler(Looper.getMainLooper()) - - // 语音引擎相关 - private var engine: SpeechEngine? = null - private var engineHandler: Long = -1 - private var isInitialized = false - - // 当前回调 - private var recognizeCallback: RecognizeCallback? = null - private var continuousCallback: ContinuousRecognizeCallback? = null - - // 识别状态 - private var isRecognizing = false - private var isEngineStarted = false - private var isContinuousMode = false - - // 配置参数 - private var language = "zh-CN" - private var recorderType = SpeechEngineDefines.RECORDER_TYPE_RECORDER - - // 音频参数 - private var sampleRate = 16000 - private var channels = 1 - private var needResample = false - - /** - * ASR识别状态 - */ - enum class AsrState { - IDLE, // 空闲状态 - INITIALIZING, // 初始化中 - READY, // 就绪(已初始化) - RECOGNIZING, // 识别中 - ERROR // 错误状态 - } - - /** - * 当前ASR状态 - */ - var state: AsrState = AsrState.IDLE - private set - - /** - * 一次性识别回调接口 - */ - interface RecognizeCallback { - fun onResult(text: String, detectedLanguage: String) - fun onError(error: String) - } - - /** - * 连续识别回调接口 - */ - interface ContinuousRecognizeCallback { - fun onResult(text: String, detectedLanguage: String) - fun onRecognizing(recognizing: String, detectedLanguage: String) - fun onSessionStarted() - fun onSessionStopped() - fun onError(error: String) - } - - /** - * 初始化语音识别引擎 - */ - fun initialize(appId: String, token: String, resourceId: String = "volc.bigasr.sauc.duration"): Boolean { - if (isInitialized) { - Log.i(TAG, "引擎已经初始化") - return true - } - - state = AsrState.INITIALIZING - try { - // 准备环境 - try { - SpeechEngineGenerator.PrepareEnvironment(context, null) - } catch (e: Exception) { - Log.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化") - } - - // 创建引擎 - engine = SpeechEngineGenerator.getInstance() - engineHandler = engine?.createEngine() ?: -1 - - if (engineHandler == -1L) { - Log.e(TAG, "创建引擎失败") - state = AsrState.ERROR - return false - } - - // 设置上下文 - engine?.setContext(context) - - // 设置引擎类型为ASR - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.ASR_ENGINE) - - // 设置日志级别 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_WARN) - - // 设置用户ID和设备ID - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") - - // 设置鉴权信息 - 大模型版本不需要Bearer前缀 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId) - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, token) - - // 设置资源ID - 大模型必需 - // engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId) - - // 设置协议类型为Seed - 大模型必需 - // engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED) - - // 设置网络配置 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_ADDRESS_STRING, "wss://openspeech.bytedance.com") - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_URI_STRING, "/api/v3/sauc/bigmodel") - - // 设置超时时间 - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_CONN_TIMEOUT_INT, 12000) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_RECV_TIMEOUT_INT, 8000) - - // 默认设置音频来源为录音机 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_TYPE_STRING, recorderType) - - // 设置最大录音时长 (默认60秒) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_VAD_MAX_SPEECH_DURATION_INT, 60000) - - // 设置VAD静音判停时间,用户说话后停顿多久视为说话结束 - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_VAD_END_SILENCE_TIME_INT, 600) - - // 启用utterances返回 - // engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_UTTERANCES_BOOL, true) - // 设置VAD头部静音时间,用于过滤开始的杂音 - // engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_VAD_START_SILENCE_TIME_INT, 500) - - // 设置回声消除 - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_PRESET_INT, SpeechEngineDefines.RECORDER_PRESET_VOICE_COMMUNICATION) - - // 设置音频参数 - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_SAMPLE_RATE_INT, sampleRate) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_CHANNEL_NUM_INT, channels) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_UP_CHANNEL_NUM_INT, channels) - - // 启用语言检测 - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_LANG_BOOL, true) - - // 启用标点符号返回(默认启用) - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_NLU_PUNC_BOOL, true) - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_ENABLE_DDC_BOOL, true); - - // 使用增量返回模式,避免内存增长 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_RESULT_TYPE_STRING, - SpeechEngineDefines.ASR_RESULT_TYPE_SINGLE) - - // 初始化引擎 - val result = engine?.initEngine(engineHandler) - isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR - - if (isInitialized) { - Log.i(TAG, "引擎初始化成功") - state = AsrState.READY - - // 设置回调监听 - engine?.setListener(object : SpeechEngine.SpeechListener { - override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { - val stdData = String(data) - handleEngineEvent(type, stdData) - } - }) - - return true - } else { - Log.e(TAG, "引擎初始化失败: $result") - state = AsrState.ERROR - return false - } - } catch (e: Exception) { - Log.e(TAG, "初始化异常: ${e.message}", e) - state = AsrState.ERROR - return false - } - } - - /** - * 设置音频输入类型 - */ - fun setAudioInputType(type: String): Boolean { - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - if (isEngineStarted) { - Log.e(TAG, "引擎已启动,无法更改配置") - return false - } - - try { - when (type) { - "mic" -> recorderType = SpeechEngineDefines.RECORDER_TYPE_RECORDER - "stream" -> recorderType = SpeechEngineDefines.RECORDER_TYPE_STREAM - else -> { - Log.e(TAG, "不支持的音频输入类型: $type") - return false - } - } - - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_TYPE_STRING, recorderType) - return true - } catch (e: Exception) { - Log.e(TAG, "设置音频输入类型失败: ${e.message}", e) - return false - } - } - - /** - * 设置音频参数 - */ - fun setAudioParams(sampleRate: Int, channels: Int): Boolean { - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - if (isEngineStarted) { - Log.e(TAG, "引擎已启动,无法更改配置") - return false - } - - try { - this.sampleRate = sampleRate - this.channels = channels - - // 设置采样率和通道数 - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_SAMPLE_RATE_INT, sampleRate) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_CHANNEL_NUM_INT, channels) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_UP_CHANNEL_NUM_INT, channels) - - // 配置重采样参数 - needResample = sampleRate != 16000 || channels != 1 - if (needResample) { - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENABLE_RESAMPLER_BOOL, true) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_CUSTOM_SAMPLE_RATE_INT, sampleRate) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_CUSTOM_CHANNEL_INT, channels) - } else { - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENABLE_RESAMPLER_BOOL, false) - } - - return true - } catch (e: Exception) { - Log.e(TAG, "设置音频参数失败: ${e.message}", e) - return false - } - } - - /** - * 设置语言 - */ - fun setLanguage(language: String): Boolean { - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - try { - this.language = language - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_LANGUAGE_STRING, language) - return true - } catch (e: Exception) { - Log.e(TAG, "设置语言失败: ${e.message}", e) - return false - } - } - - /** - * 设置热词 - */ - fun setHotWords(hotWordsId: String): Boolean { - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - try { - if (hotWordsId.isNotEmpty()) { - // 大模型ASR需要通过请求参数设置热词 - val reqParams = "{\"corpus\":{\"boosting_table_id\":\"$hotWordsId\"}}" - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) - } - return true - } catch (e: Exception) { - Log.e(TAG, "设置热词失败: ${e.message}", e) - return false - } - } - - /** - * 一次性识别(异步) - */ - fun recognizeOnce(callback: RecognizeCallback): Boolean { - Log.i(TAG, "开始一次性识别") - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - if (isRecognizing) { - Log.e(TAG, "当前已在识别中") - return false - } - - try { - this.recognizeCallback = callback - this.continuousCallback = null - this.isContinuousMode = false - - // 停止当前引擎 - engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") - - // 开启自动判停,达到静音时间后自动停止识别 - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_AUTO_STOP_BOOL, true) - - - - // 启动引擎开始识别 - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") - - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - Log.e(TAG, "启动识别失败: $ret") - return false - } - - isRecognizing = true - isEngineStarted = true - state = AsrState.RECOGNIZING - return true - } catch (e: Exception) { - Log.e(TAG, "识别异常: ${e.message}", e) - return false - } - } - - /** - * 停止一次性识别 - */ - fun stopRecognize(): Boolean { - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - if (!isEngineStarted) { - Log.e(TAG, "引擎未启动") - return false - } - - try { - if (recorderType == SpeechEngineDefines.RECORDER_TYPE_RECORDER) { - // 麦克风输入-告知引擎音频输入完成 - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_FINISH_TALKING, "") - - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - Log.e(TAG, "停止识别失败: $ret") - return false - } - } else { - // 流式输入-需要完全停止引擎 - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") - - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - Log.e(TAG, "停止识别失败: $ret") - return false - } - - isRecognizing = false - isEngineStarted = false - state = AsrState.READY - } - - return true - } catch (e: Exception) { - Log.e(TAG, "停止识别异常: ${e.message}", e) - return false - } - } - - /** - * 开始连续识别 - */ - fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { - Log.i(TAG, "开始连续识别") - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - if (isRecognizing) { - Log.e(TAG, "当前已在识别中") - return false - } - - try { - this.continuousCallback = callback - this.recognizeCallback = null - this.isContinuousMode = true - - // 停止当前引擎 - engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") - - // 关闭自动判停,持续接收语音直到主动关闭 - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_AUTO_STOP_BOOL, false) - - - - - // 启动引擎开始识别 - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") - - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - Log.e(TAG, "启动连续识别失败: $ret") - return false - } - - isRecognizing = true - isEngineStarted = true - state = AsrState.RECOGNIZING - Log.i(TAG, "开始连续识别") - return true - } catch (e: Exception) { - Log.e(TAG, "连续识别异常: ${e.message}", e) - return false - } - } - - /** - * 停止连续识别 - */ - fun stopContinuousRecognition(): Boolean { - if (!isInitialized || !isRecognizing) { - Log.e(TAG, "引擎未初始化或未在识别中") - return false - } - - try { - // 停止引擎 - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") - - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - Log.e(TAG, "停止连续识别失败: $ret") - return false - } - - isRecognizing = false - isEngineStarted = false - state = AsrState.READY - return true - } catch (e: Exception) { - Log.e(TAG, "停止连续识别异常: ${e.message}", e) - return false - } - } - - /** - * 发送PCM音频数据到引擎 - * 注意:必须在setAudioInputType("stream")后,才能调用此方法 - */ - fun feedAudio(data: ByteArray): Boolean { - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - if (!isEngineStarted) { - Log.e(TAG, "引擎未启动") - return false - } - - if (recorderType != SpeechEngineDefines.RECORDER_TYPE_STREAM) { - Log.e(TAG, "当前录音类型不是stream模式,无法使用此方法") - return false - } - - try { - // 调用引擎feedAudio方法 - val ret = engine?.feedAudio(engineHandler, data, data.size) - - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - Log.e(TAG, "发送音频数据失败: $ret") - return false - } - - return true - } catch (e: Exception) { - Log.e(TAG, "发送音频数据异常: ${e.message}", e) - return false - } - } - - /** - * 发送任意格式音频的最后一块数据 - * 告知引擎音频输入已结束 - */ - fun feedAudioEnd(): Boolean { - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - if (!isEngineStarted) { - Log.e(TAG, "引擎未启动") - return false - } - - if (recorderType != SpeechEngineDefines.RECORDER_TYPE_STREAM) { - Log.e(TAG, "当前录音类型不是stream模式,无法使用此方法") - return false - } - - try { - // 空Buffer告知引擎输入结束 - val ret = engine?.feedAudio(engineHandler, ByteArray(0), 0) - - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - Log.e(TAG, "发送音频结束标记失败: $ret") - return false - } - - return true - } catch (e: Exception) { - Log.e(TAG, "发送音频结束标记异常: ${e.message}", e) - return false - } - } - - /** - * 发送Int16数组音频数据 - * 输入格式: PCM 16bit 小端 单声道或双声道 - */ - fun feedAudioInt16(audioData: ShortArray): Boolean { - // 将Int16数组转换为字节数组 - val byteBuffer = ByteBuffer.allocate(audioData.size * 2) - byteBuffer.order(ByteOrder.LITTLE_ENDIAN) - - for (sample in audioData) { - byteBuffer.putShort(sample) - } - - return feedAudio(byteBuffer.array()) - } - - /** - * 判断引擎是否启动 - */ - fun isEngineStarted(): Boolean { - return isEngineStarted - } - - /** - * 判断是否在识别中 - */ - fun isRecognizing(): Boolean { - return isRecognizing - } - - /** - * 释放资源 - */ - fun release() { - if (!isInitialized) { - return - } - - try { - // 停止引擎 - if (isEngineStarted) { - engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") - isRecognizing = false - isEngineStarted = false - } - - // 销毁引擎 - engine?.destroyEngine(engineHandler) - engineHandler = -1 - engine = null - isInitialized = false - state = AsrState.IDLE - - Log.i(TAG, "引擎已释放") - } catch (e: Exception) { - Log.e(TAG, "释放引擎异常: ${e.message}", e) - } - } - - /** - * 处理引擎事件 - */ - private fun handleEngineEvent(type: Int, data: String) { - when (type) { - SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { - // 引擎启动成功 - Log.i(TAG, "引擎启动成功") - mainHandler.post { - continuousCallback?.onSessionStarted() - } - } - - SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { - // 引擎停止 - Log.i(TAG, "引擎停止") - isRecognizing = false - isEngineStarted = false - state = AsrState.READY - - mainHandler.post { - continuousCallback?.onSessionStopped() - } - } - - SpeechEngineDefines.MESSAGE_TYPE_PARTIAL_RESULT -> { - // 中间识别结果(高频,降级为 debug) - try { - val json = JSONObject(data) - if (!json.has("result")) { - return - } - val text = extractTextFromResult(json) - val detectedLanguage = json.optString("lang", language) - - if (text.isNotEmpty()) { - mainHandler.post { - continuousCallback?.onRecognizing(text, detectedLanguage) - } - } - } catch (e: Exception) { - Log.e(TAG, "解析中间识别结果异常: ${e}") - } - } - - SpeechEngineDefines.MESSAGE_TYPE_FINAL_RESULT -> { - try { - val json = JSONObject(data) - if (!json.has("result")) { - Log.w(TAG, "最终结果没有 result 字段") - return - } - - val text = extractTextFromResult(json) - val detectedLanguage = json.optString("lang", language) - - if (text.isNotEmpty()) { - mainHandler.post { - if (isContinuousMode) { - Log.i(TAG, "最终识别结果(连续): $text") - continuousCallback?.onResult(text, detectedLanguage) - } else { - Log.i(TAG, "最终识别结果(单次): $text") - recognizeCallback?.onResult(text, detectedLanguage) - } - } - } - } catch (e: Exception) { - val errMsg = "解析最终识别结果异常: ${e.message}" - Log.e(TAG, errMsg) - Log.e(TAG, "原始数据: $data") - Log.e(TAG, "详细异常信息", e) - } - } - - SpeechEngineDefines.MESSAGE_TYPE_VOLUME_LEVEL -> { - // 忽略音量回调 - } - - SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { - // 错误信息 - try { - val json = JSONObject(data) - val errMsg = json.optString("err_msg", "未知错误") - val errCode = json.optInt("err_code", -1) - - Log.e(TAG, "引擎错误: code=$errCode, msg=$errMsg") - state = AsrState.ERROR - - mainHandler.post { - if (isContinuousMode) { - continuousCallback?.onError(errMsg) - } else { - recognizeCallback?.onError(errMsg) - } - isRecognizing = false - isEngineStarted = false - } - } catch (e: JSONException) { - Log.e(TAG, "解析错误信息异常: ${e.message}", e) - } - } - } - } - - /** - * 从多种可能的JSON格式中提取文本 - */ - private fun extractTextFromResult(json: JSONObject): String { - try { - if (!json.has("result")) { - return "" - } - - // 获取result字段(可能是JSONObject或JSONArray) - val resultObj = json.get("result") - - // 根据result的实际类型处理 - val text = when (resultObj) { - // 情况1: result是JSONObject,直接获取text字段 - is JSONObject -> { - resultObj.optString("text", "") - } - - // 情况2: result是JSONArray,获取第一个元素的text字段 - is JSONArray -> { - if (resultObj.length() == 0) { - "" - } else { - resultObj.getJSONObject(0).optString("text", "") - } - } - - // 其他情况 - else -> { - resultObj.toString() - } - } - - // 过滤空结果 - if (text.isEmpty()) { - return "" - } - - return text - } catch (e: Exception) { - Log.e(TAG, "提取文本异常: ${e.message}", e) - Log.e(TAG, "详细异常信息", e) - return "" - } - } -} diff --git a/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt b/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt deleted file mode 100644 index a53567cd..00000000 --- a/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt +++ /dev/null @@ -1,434 +0,0 @@ -package com.yunqiinnovation.volcano_speech - -import android.content.Context -import android.os.Handler -import android.os.Looper -import androidx.annotation.NonNull -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import io.flutter.plugin.common.EventChannel -import android.util.Log -import android.media.AudioFormat -import android.media.AudioManager -import android.media.AudioTrack -import com.deep_voice.speech.tts.TtsEvent -import com.deep_voice.speech.tts.TtsEventListener -import com.deep_voice.speech.tts.TtsEventType -import com.deep_voice.speech.tts.AudioDataListener - -/** VolcanoSpeechPlugin */ -class VolcanoSpeechPlugin: FlutterPlugin { - private val TAG = "VolcanoSpeechPlugin" - private lateinit var context: Context - private val mainHandler = Handler(Looper.getMainLooper()) - - // ASR相关 - private lateinit var asrChannel: MethodChannel - private lateinit var asrEventChannel: EventChannel - private var asrEventSink: EventChannel.EventSink? = null - - // TTS相关 - private lateinit var ttsChannel: MethodChannel - private lateinit var ttsEventChannel: EventChannel - private var ttsEventSink: EventChannel.EventSink? = null - - // TTS音频数据相关 - private lateinit var ttsAudioDataChannel: EventChannel - private var ttsAudioDataSink: EventChannel.EventSink? = null - - // TTS帮助类 - private lateinit var volcanoTtsHelper: VolcanoTtsHelper - - // ASR帮助类 - private lateinit var volcanoAsrHelper: VolcanoAsrHelper - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - - // 初始化ASR通道 - asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/asr") - asrChannel.setMethodCallHandler(AsrMethodHandler()) - - // 初始化TTS通道 - ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts") - ttsChannel.setMethodCallHandler(TtsMethodHandler()) - - // 初始化ASR事件通道 - asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/asr_events") - asrEventChannel.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - asrEventSink = events - } - - override fun onCancel(arguments: Any?) { - asrEventSink = null - } - }) - - // 初始化TTS事件通道 - ttsEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts_events") - ttsEventChannel.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - ttsEventSink = events - } - - override fun onCancel(arguments: Any?) { - ttsEventSink = null - } - }) - - // 初始化TTS音频数据通道 - ttsAudioDataChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts_audio_data") - ttsAudioDataChannel.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - ttsAudioDataSink = events - } - - override fun onCancel(arguments: Any?) { - ttsAudioDataSink = null - } - }) - - // 初始化TTS帮助类 - volcanoTtsHelper = VolcanoTtsHelper(context) - // 添加TTS事件监听 - volcanoTtsHelper.addListener(object : TtsEventListener { - override fun onEvent(event: TtsEvent) { - when (event.type) { - TtsEventType.SYNTHESIS_STARTED -> { - sendTtsEvent(mapOf("type" to "started")) - } - TtsEventType.SYNTHESIS_COMPLETED -> { - sendTtsEvent(mapOf("type" to "completed")) - } - TtsEventType.SYNTHESIS_CANCELED -> { - sendTtsEvent(mapOf("type" to "canceled")) - } - TtsEventType.ERROR -> { - val errorCode = event.params["errorCode"] as? String ?: "-1" - val errorMessage = event.params["errorMessage"] as? String ?: "未知错误" - sendTtsEvent(mapOf( - "type" to "error", - "errorCode" to errorCode, - "errorMessage" to errorMessage - )) - } - } - } - }) - - // 添加TTS音频数据监听 - volcanoTtsHelper.addAudioDataListener(object : AudioDataListener { - override fun onAudioData(data: ByteArray) { - sendTtsAudioData(data) - } - }) - - // 初始化ASR帮助类 - volcanoAsrHelper = VolcanoAsrHelper(context) - } - - // 发送ASR事件 - private fun sendAsrEvent(event: Map) { - if (asrEventSink == null) { - Log.w(TAG, "无法发送ASR事件:事件通道未准备好") - return - } - - mainHandler.post { - try { - asrEventSink?.success(event) - } catch (e: Exception) { - Log.e(TAG, "发送ASR事件失败: ${e.message}") - } - } - } - - // 发送TTS事件 - private fun sendTtsEvent(event: Map) { - Log.d(TAG, "发送TTS事件: $event") - if (ttsEventSink == null) { - Log.w(TAG, "无法发送TTS事件:事件通道未准备好") - return - } - - mainHandler.post { - try { - ttsEventSink?.success(event) - Log.d(TAG, "TTS事件发送成功") - } catch (e: Exception) { - Log.e(TAG, "发送TTS事件失败: ${e.message}") - } - } - } - - // 发送TTS音频数据 - private fun sendTtsAudioData(data: ByteArray) { - if (ttsAudioDataSink == null) { - return - } - - mainHandler.post { - try { - ttsAudioDataSink?.success(data) - } catch (e: Exception) { - Log.e(TAG, "发送TTS音频数据失败: ${e.message}") - } - } - } - - // ASR方法处理器 - inner class AsrMethodHandler : MethodCallHandler { - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - when (call.method) { - "getPlatformVersion" -> { - result.success("Android ${android.os.Build.VERSION.RELEASE}") - } - "initialize" -> { - val appId = call.argument("appId") ?: "" - val apiKey = call.argument("appToken") ?: "" - val resourceId = call.argument("resourceId") ?: "" - - if (appId.isEmpty() || apiKey.isEmpty() || resourceId.isEmpty()) { - result.error("INVALID_ARGUMENTS", "appId、apiKey和resourceId不能为空", null) - return - } - - val success = volcanoAsrHelper.initialize(appId, apiKey, resourceId) - result.success(success) - } - "setLanguage" -> { - val language = call.argument("language") ?: "zh-CN" - result.success(volcanoAsrHelper.setLanguage(language)) - } - "setHotWords" -> { - val hotWordsId = call.argument("hotWordsId") ?: "" - result.success(volcanoAsrHelper.setHotWords(hotWordsId)) - } - "recognizeOnce" -> { - // 旧方式,不再推荐使用 - // 确保当前不在连续识别中 - if (volcanoAsrHelper.isRecognizing()) { - result.error("ASR_BUSY", "当前正在识别中", null) - return - } - - volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.RecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) { - mainHandler.post { - result.success(mapOf( - "text" to text, - "language" to detectedLanguage - )) - } - } - - override fun onError(error: String) { - mainHandler.post { - result.error("ASR_ERROR", error, null) - } - } - }) - } - "startOnceRecognition" -> { - // 新方式,自动返回结果 - // 确保当前不在连续识别中 - if (volcanoAsrHelper.isRecognizing()) { - result.error("ASR_BUSY", "当前正在识别中", null) - return - } - - // 设置一次性识别,触发事件而不是直接返回结果 - volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.RecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) { - mainHandler.post { - // 发送最终结果事件 - sendAsrEvent(mapOf( - "type" to "result", - "text" to text, - "language" to detectedLanguage - )) - } - } - - override fun onError(error: String) { - mainHandler.post { - // 发送错误事件 - sendAsrEvent(mapOf( - "type" to "error", - "message" to error - )) - } - } - }) - - // 返回成功启动 - result.success(true) - } - "startContinuousRecognition" -> { - // 确保事件通道已准备好 - if (asrEventSink == null) { - result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始连续识别", null) - return - } - - val success = volcanoAsrHelper.startContinuousRecognition(object : VolcanoAsrHelper.ContinuousRecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) { - sendAsrEvent(mapOf( - "type" to "result", - "text" to text, - "language" to detectedLanguage - )) - } - - override fun onRecognizing(text: String, detectedLanguage: String) { - sendAsrEvent(mapOf( - "type" to "recognizing", - "text" to text, - "language" to detectedLanguage - )) - } - - override fun onSessionStarted() { - sendAsrEvent(mapOf("type" to "sessionStarted")) - } - - override fun onSessionStopped() { - sendAsrEvent(mapOf("type" to "sessionStopped")) - } - - - override fun onError(error: String) { - sendAsrEvent(mapOf( - "type" to "error", - "message" to error - )) - } - }) - - result.success(success) - } - "stopContinuousRecognition" -> { - val success = volcanoAsrHelper.stopContinuousRecognition() - result.success(success) - } - "stopRecognize" -> { - val success = volcanoAsrHelper.stopRecognize() - result.success(success) - } - "isContinuousRecognitionActive" -> { - result.success(volcanoAsrHelper.isRecognizing()) - } - "feedAudioData" -> { - val data = call.argument("data") ?: ByteArray(0) - result.success(volcanoAsrHelper.feedAudio(data)) - } - "feedAudioEnd" -> { - result.success(volcanoAsrHelper.feedAudioEnd()) - } - "release" -> { - volcanoAsrHelper.release() - result.success(true) - } - else -> { - result.notImplemented() - } - } - } - } - - // TTS方法处理器 - inner class TtsMethodHandler : MethodCallHandler { - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - when (call.method) { - "getPlatformVersion" -> { - result.success("Android ${android.os.Build.VERSION.RELEASE}") - } - "initialize" -> { - val appId = call.argument("appId") ?: "" - val token = call.argument("appToken") ?: "" - - if (appId.isEmpty() || token.isEmpty()) { - result.error("INVALID_ARGUMENTS", "appId和token不能为空", null) - return - } - - val success = volcanoTtsHelper.initialize(appId, token) - result.success(success) - } - "setVoice" -> { - val voice = call.argument("voice") ?: "" - - if (voice.isEmpty()) { - result.error("INVALID_ARGUMENTS", "voice不能为空", null) - return - } - - result.success(volcanoTtsHelper.setVoice(voice)) - } - "speakOnce" -> { - val text = call.argument("text") ?: "" - - if (text.isEmpty()) { - result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) - return - } - - val success = volcanoTtsHelper.speakOnce(text) - result.success(success) - } - "speakStream" -> { - val text = call.argument("text") ?: "" - - if (text.isEmpty()) { - result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) - return - } - - val success = volcanoTtsHelper.speakStream(text) - result.success(success) - } - "flushStream" -> { - val success = volcanoTtsHelper.flushStream() - result.success(success) - } - "stop" -> { - result.success(volcanoTtsHelper.stop()) - } - "isSpeaking" -> { - // 由于ITtsService不再定义isSpeaking方法,我们可以通过检查是否有正在合成的文本来判断 - result.success(false) // 简化处理,直接返回false - } - "getCurrentVoice" -> { - result.success(volcanoTtsHelper.getCurrentVoice()) - } - "enableAudioCallback" -> { - val enable = call.argument("enable") ?: false - volcanoTtsHelper.setEnableAudioCallback(enable) - result.success(true) - } - "release" -> { - volcanoTtsHelper.release() - result.success(true) - } - else -> { - result.notImplemented() - } - } - } - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - asrChannel.setMethodCallHandler(null) - ttsChannel.setMethodCallHandler(null) - asrEventChannel.setStreamHandler(null) - ttsEventChannel.setStreamHandler(null) - ttsAudioDataChannel.setStreamHandler(null) - - volcanoTtsHelper.release() - volcanoAsrHelper.release() - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt b/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt deleted file mode 100644 index 1802d5d2..00000000 --- a/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt +++ /dev/null @@ -1,691 +0,0 @@ -package com.yunqiinnovation.volcano_speech - -import android.content.Context -import android.os.Handler -import android.os.Looper -import com.bytedance.speech.speechengine.SpeechEngine -import com.bytedance.speech.speechengine.SpeechEngineDefines -import com.bytedance.speech.speechengine.SpeechEngineGenerator -import android.util.Log -import com.deep_voice.speech.tts.AudioDataListener -import com.deep_voice.speech.tts.ITtsService -import com.deep_voice.speech.tts.TtsEvent -import com.deep_voice.speech.tts.TtsEventListener -import com.deep_voice.speech.tts.TtsEventType -import org.json.JSONObject -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.UUID - -/** - * 火山语音合成服务 - 实现ITtsService接口 - */ -class VolcanoTtsHelper(private val context: Context) : ITtsService { - private val TAG = "VolcanoTtsHelper" - private val mainHandler = Handler(Looper.getMainLooper()) - - // 语音引擎相关 - private var engine: SpeechEngine? = null - private var engineHandler: Long = -1 - private var isInitialized = false - - // 语音合成状态 - private var isEngineStarted = false - private var currentReqId = "" - - // 配置参数 - private var voice = "zh_female_linjianvhai_moon_bigtts" - - // 文本队列管理 - private val pendingTexts = ConcurrentLinkedQueue() - private var isProcessingQueue = false - private val MAX_PENDING_SENTENCES = 20 // 最大等待句子数,避免内存占用过大 - - // 合成相关 - private var currentText = "" - private var synthesisFromPlayer = false - private var isSynthesisInProgress = false - - // 错误处理 - private val MAX_RETRY_COUNT = 3 - private var retryCount = MAX_RETRY_COUNT - - // 音频数据相关 - private var enableAudioCallback = false - private val audioDataListeners = mutableListOf() - - // 事件监听 - private val eventListeners = mutableListOf() - - // 流式文本处理 - private val streamBuffer = StringBuffer() - private var lastSpeakTime = 0L - - // 跟踪当前合成的播放数 - private var pendingPlayCount = 0 - - /** - * 当前选择的语音 - */ - fun getCurrentVoice(): String = voice - - /** - * 设置是否启用音频数据回调 - */ - fun setEnableAudioCallback(enable: Boolean) { - this.enableAudioCallback = enable - } - - /** - * 添加事件监听器 - */ - override fun addListener(listener: TtsEventListener) { - if (!eventListeners.contains(listener)) { - eventListeners.add(listener) - } - } - - /** - * 移除事件监听器 - */ - override fun removeListener(listener: TtsEventListener) { - eventListeners.remove(listener) - } - - /** - * 添加音频数据监听器 - */ - override fun addAudioDataListener(listener: AudioDataListener) { - if (!audioDataListeners.contains(listener)) { - audioDataListeners.add(listener) - // 如果添加了音频监听器,自动开启音频回调 - enableAudioCallback = true - } - } - - /** - * 移除音频数据监听器 - */ - override fun removeAudioDataListener(listener: AudioDataListener) { - audioDataListeners.remove(listener) - // 如果没有监听器了,可以关闭音频回调 - if (audioDataListeners.isEmpty()) { - enableAudioCallback = false - } - } - - /** - * 分发事件到所有监听器 - */ - private fun dispatchEvent(eventType: TtsEventType, params: Map = emptyMap()) { - val event = TtsEvent(eventType, params) - mainHandler.post { - eventListeners.forEach { it.onEvent(event) } - } - } - - /** - * 分发音频数据到所有监听器 - */ - private fun dispatchAudioData(data: ByteArray) { - if (audioDataListeners.isEmpty()) return - - mainHandler.post { - audioDataListeners.forEach { it.onAudioData(data) } - } - } - - /** - * 初始化语音合成引擎 - */ - override fun initialize(ttsAppId: String, ttsAppToken: String, ttsResource: String, language: String): Boolean { - if (isInitialized) { - return true - } - - try { - // 准备环境 - try { - SpeechEngineGenerator.PrepareEnvironment(context, null) - } catch (e: Exception) { - Log.w(TAG, "准备环境时发生异常: ${e.message}") - } - - // 创建引擎 - engine = SpeechEngineGenerator.getInstance() - engineHandler = engine?.createEngine() ?: -1 - - if (engineHandler == -1L) { - handleError(-1, "创建引擎失败") - return false - } - - // 设置基本参数 - engine?.setContext(context) - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.TTS_ENGINE) - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_DEBUG) - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") - - // 设置授权信息 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, ttsAppId) - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, "Bearer;${ttsAppToken}") - - // 设置协议和工作模式 - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WORK_MODE_INT, SpeechEngineDefines.TTS_WORK_MODE_ONLINE) - - // 设置服务地址和发音人 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ADDRESS_STRING, "wss://openspeech.bytedance.com") - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, "/api/v1/tts/ws_binary") - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_CLUSTER_STRING, "volcano_tts") - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, voice) - - // 设置播放进度回调和播放器 - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WITH_FRONTEND_INT, 1) - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_AUDIO_STREAM_TYPE_INT, SpeechEngineDefines.AUDIO_STREAM_TYPE_MEDIA) - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ENABLE_PLAYER_BOOL, true) - - // engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WITH_INTENT_BOOL, true) - - engine?.setOptionString(SpeechEngineDefines.PARAMS_KEY_TTS_EMOTION_STRING, "happy"); - - // 初始化引擎 - val result = engine?.initEngine(engineHandler) - isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR - - if (isInitialized) { - // 设置回调监听 - engine?.setListener(object : SpeechEngine.SpeechListener { - override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { - handleEngineEvent(type, String(data), data) - } - }) - - return true - } else { - handleError(result ?: -1, "引擎初始化失败") - return false - } - } catch (e: Exception) { - handleError(-1, "初始化异常: ${e.message}") - return false - } - } - - /** - * 统一处理错误 - */ - private fun handleError(code: Int, message: String, e: Exception? = null) { - if (e != null) { - Log.e(TAG, message, e) - } else { - Log.e(TAG, message) - } - dispatchEvent(TtsEventType.ERROR, mapOf( - "errorCode" to code.toString(), - "errorMessage" to message - )) - } - - /** - * 设置语音 - */ - override fun setVoice(voiceName: String): Boolean { - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - try { - this.voice = voiceName - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, voiceName) - return true - } catch (e: Exception) { - handleError(-1, "设置发音人失败: ${e.message}", e) - return false - } - } - - - - - /** - * 单次播放文本(未实现,推荐使用speakStream) - */ - override fun speakOnce(text: String): Boolean { - return false - } - - /** - * 将文本加入播放队列,支持连续播放多段文本 - */ - override fun speakStream(text: String): Boolean { - if (text.isEmpty()) { - return true - } - - try { - if (!isInitialized) { - Log.e(TAG, "引擎未初始化") - return false - } - - // 处理文本,移除特殊字符 - val processedText = text.replace(Regex("[#*]"), " ") - - // 添加新文本到缓冲区 - streamBuffer.append(text) - - // 增加防抖逻辑 - val currentTime = System.currentTimeMillis() - if (currentTime - lastSpeakTime < 600) { - return true - } - lastSpeakTime = currentTime - - val currentText = streamBuffer.toString() - if (currentText.isEmpty()) { - return true - } - - // 定义标点符号列表 - val punctuationMarks = listOf('.', '。', '!', '!', '?', '?', ';', ';', ',', ',', ':', ':') - - // 查找最后一个标点符号的位置 - var lastPunctuationIndex = -1 - for (i in currentText.indices.reversed()) { - if (currentText[i] in punctuationMarks) { - lastPunctuationIndex = i - break - } - } - - // 如果找到标点符号,则播放到该标点符号 - if (lastPunctuationIndex >= 0) { - // 提取要播放的文本(包含标点符号) - val textToSpeak = currentText.substring(0, lastPunctuationIndex + 1) - - // 剩余的文本保存在缓冲区中 - streamBuffer.delete(0, lastPunctuationIndex + 1) - - // 只有实际有文本时才处理 - if (textToSpeak.isNotEmpty()) { - // 将文本添加到队列 - Log.d(TAG, "textToSpeak: ${textToSpeak}") - pendingTexts.add(textToSpeak) - - // 根据引擎状态决定下一步操作 - if (!isEngineStarted) { - // 引擎未启动,启动它 - startEngine() - } else if (!isSynthesisInProgress && !synthesisFromPlayer) { - // 引擎已启动但当前空闲,触发下一个合成 - triggerNextSynthesis() - } - // 其他情况下引擎正忙,会在当前任务完成后处理队列 - } - } - // 如果没找到标点符号,保留全部文本在缓冲区,等待更多文本 - - return true - } catch (e: Exception) { - handleError(-1, "流式语音合成失败: ${e.message}", e) - return false - } - } - - /** - * 启动语音引擎 - */ - private fun startEngine(): Boolean { - if (!isInitialized) { - return false - } - - try { - // 设置连续播放场景模式 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL) - - // 启用音频数据回调(如果需要) - if (enableAudioCallback) { - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, 2) - } else { - engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, 0) - } - - // 发送启动引擎指令 - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") - - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - handleError(ret ?: -1, "启动引擎失败") - return false - } - - isEngineStarted = true - retryCount = MAX_RETRY_COUNT - - return true - } catch (e: Exception) { - handleError(-1, "启动引擎异常: ${e.message}", e) - return false - } - } - - /** - * 触发下一句合成 - */ - private fun triggerNextSynthesis(): Boolean { - if (!isEngineStarted || pendingTexts.isEmpty() || isSynthesisInProgress) { - return false - } - - val nextText = pendingTexts.poll() - currentText = nextText - - try { - Log.d(TAG, "开始合成: ${nextText}") - // 设置要合成的文本 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, nextText) - - // 标记合成开始 - isSynthesisInProgress = true - pendingPlayCount++ - - // 发送合成指令,不再使用请求ID - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNTHESIS, "") - - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - // 特殊处理播放器忙的情况 - if (ret == SpeechEngineDefines.ERR_SYNTHESIS_PLAYER_IS_BUSY) { - // 当播放器忙时,将文本放回队列前面,等待播放完成后再合成 - pendingTexts.add(nextText) - synthesisFromPlayer = true - isSynthesisInProgress = false - pendingPlayCount-- // 恢复计数 - return true - } else { - isSynthesisInProgress = false - pendingPlayCount-- // 恢复计数 - handleError(ret ?: -1, "合成失败") - return false - } - } - - return true - } catch (e: Exception) { - isSynthesisInProgress = false - pendingPlayCount-- // 恢复计数 - handleError(-1, "触发合成异常: ${e.message}", e) - return false - } - } - - /** - * 播放队列中剩余文本并标记流式输入结束 - */ - override fun flushStream(): Boolean { - if (!isInitialized) { - return false - } - - try { - // 获取缓冲区中剩余的文本 - val remainingText = streamBuffer.toString() - - // 清空缓冲区 - streamBuffer.delete(0, streamBuffer.length) - - // 如果缓冲区有未播放的文本,直接加入队列 - if (remainingText.isNotEmpty()) { - pendingTexts.add(remainingText) - } - - // 如果队列为空且引擎未启动,直接返回 - if (pendingTexts.isEmpty() && !isEngineStarted) { - return true - } - - // 如果引擎未启动但队列有内容,则启动引擎 - if (!isEngineStarted && !pendingTexts.isEmpty()) { - return startEngine() - } - - // 如果引擎已启动但没有正在进行的合成,触发下一个合成 - if (isEngineStarted && !isSynthesisInProgress && !synthesisFromPlayer && !pendingTexts.isEmpty()) { - return triggerNextSynthesis() - } - - return true - } catch (e: Exception) { - handleError(-1, "刷新流式文本失败: ${e.message}", e) - return false - } - } - - /** - * 停止播放 - */ - override fun stop(): Boolean { - if (!isInitialized) { - return false - } - - // 已经不在播放,无需操作 - if (!isEngineStarted) { - return true - } - - try { - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") - isEngineStarted = false - isSynthesisInProgress = false - - // 清空队列和状态 - pendingTexts.clear() - isProcessingQueue = false - synthesisFromPlayer = false - pendingPlayCount = 0 - - // 清空流缓冲区 - streamBuffer.delete(0, streamBuffer.length) - - // 发送取消事件 - dispatchEvent(TtsEventType.SYNTHESIS_CANCELED) - - return ret == SpeechEngineDefines.ERR_NO_ERROR - } catch (e: Exception) { - Log.e(TAG, "停止播放异常", e) - return false - } - } - - /** - * 释放资源 - */ - override fun release() { - if (!isInitialized) { - return - } - - try { - // 停止引擎 - stop() - - // 销毁引擎 - engine?.destroyEngine(engineHandler) - engineHandler = -1 - engine = null - isInitialized = false - pendingTexts.clear() - streamBuffer.delete(0, streamBuffer.length) - eventListeners.clear() - audioDataListeners.clear() - } catch (e: Exception) { - Log.e(TAG, "释放引擎异常", e) - } - } - - /** - * 处理引擎事件 - */ - private fun handleEngineEvent(type: Int, stringData: String, rawData: ByteArray? = null) { - when (type) { - SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { - // 引擎启动成功 - currentReqId = stringData - isEngineStarted = true - - // 如果队列不为空,在引擎启动后立即触发第一句合成 - if (!pendingTexts.isEmpty() && !isSynthesisInProgress) { - triggerNextSynthesis() - } - } - - SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { - // 引擎停止 - isEngineStarted = false - isSynthesisInProgress = false - pendingPlayCount = 0 - } - - SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_BEGIN -> { - // 合成开始 - isSynthesisInProgress = true - } - - SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_END -> { - // 合成结束 - isSynthesisInProgress = false - - // 重置重试计数 - retryCount = MAX_RETRY_COUNT - - // 如果不是从播放器触发的合成,尝试处理下一个文本 - if (!synthesisFromPlayer && !pendingTexts.isEmpty()) { - // 在合成结束回调中触发下一句合成 - triggerNextSynthesis() - } - } - - SpeechEngineDefines.MESSAGE_TYPE_TTS_START_PLAYING -> { - // 开始播放 - // Log.d(TAG, "开始播放MESSAGE_TYPE_TTS_START") - dispatchEvent(TtsEventType.SYNTHESIS_STARTED) - } - - SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> { - // 播放结束 - // Log.d(TAG, "结束播放MESSAGE_TYPE_TTS_FINISH, pendingTexts.size: ${pendingTexts.size}, pendingPlayCount: ${pendingPlayCount}") - - // 减少待播放计数 - if (pendingPlayCount > 0) { - pendingPlayCount-- - } - - // 如果是从播放器触发的合成,现在可以继续处理队列了 - if (synthesisFromPlayer) { - synthesisFromPlayer = false - // 播放器空闲,尝试触发下一句合成 - triggerNextSynthesis() - } else if (pendingTexts.isEmpty() && pendingPlayCount == 0) { - // 如果队列为空,发送播放完成事件 - // Log.d(TAG, "所有文本播放完成") - dispatchEvent(TtsEventType.SYNTHESIS_COMPLETED) - isProcessingQueue = false - - // 如果没有待播放内容,关闭引擎 - engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") - isEngineStarted = false - } else if (!isSynthesisInProgress) { - // 如果队列不为空且当前没有正在进行的合成,触发下一句合成 - triggerNextSynthesis() - } - } - - SpeechEngineDefines.MESSAGE_TYPE_TTS_AUDIO_DATA -> { - // 音频数据回调 - if (rawData != null && rawData.isNotEmpty()) { - dispatchAudioData(rawData) - } - } - - SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { - // 错误信息 - try { - val json = JSONObject(stringData) - val errCode = json.optInt("err_code", -1) - val errMsg = json.optString("err_msg", "未知错误") - - Log.e(TAG, "引擎错误: code=$errCode, msg=$errMsg, ${SpeechEngineDefines.CODE_TTS_INVALID_TEXT}") - - // 标记当前合成结束 - isSynthesisInProgress = false - - // 通知错误 - // dispatchEvent(TtsEventType.ERROR, mapOf( - // "errorCode" to errCode.toString(), - // "errorMessage" to errMsg - // )) - - // 检查是否需要更新计数 - if (pendingPlayCount > 0) { - pendingPlayCount-- - } - - // 尝试恢复或停止 - when (errCode) { - // 轻微合成错误,继续下一个文本 - SpeechEngineDefines.CODE_TTS_LIMIT_QPS, - SpeechEngineDefines.CODE_TTS_LIMIT_COUNT, - SpeechEngineDefines.CODE_TTS_SERVER_BUSY, - SpeechEngineDefines.CODE_TTS_LONG_TEXT, - SpeechEngineDefines.CODE_TTS_INVALID_TEXT, - SpeechEngineDefines.CODE_TTS_SYNTHESIS_TIMEOUT, - SpeechEngineDefines.CODE_TTS_SYNTHESIS_ERROR, - SpeechEngineDefines.CODE_TTS_SYNTHESIS_WAITING_TIMEOUT, - SpeechEngineDefines.CODE_TTS_ERROR_UNKNOWN -> { - if (!pendingTexts.isEmpty()) { - // 轻微错误,直接尝试下一句 - // Log.d(TAG, "轻微错误,直接尝试下一句, ${SpeechEngineDefines.CODE_TTS_INVALID_TEXT}") - triggerNextSynthesis() - } else { - - // stop() - } - } - - // 网络错误,尝试重试 - SpeechEngineDefines.CODE_CONNECT_TIMEOUT, - SpeechEngineDefines.CODE_RECEIVE_TIMEOUT, - SpeechEngineDefines.CODE_NET_LIB_ERROR -> { - retryCount-- - if (retryCount > 0) { - // 1秒后重试当前文本 - mainHandler.postDelayed({ - if (isEngineStarted) { - // 设置当前文本并触发合成 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, currentText) - isSynthesisInProgress = true - engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNTHESIS, "") - } - }, 1000) - } else { - stop() - } - } - - // 其他错误,停止引擎 - else -> { - Log.d(TAG, "其他错误,停止合成") - // stop() - } - } - } catch (e: Exception) { - isSynthesisInProgress = false - handleError(-1, "解析错误信息异常: ${e.message}", e) - stop() - } - } - } - } -} diff --git a/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt b/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt deleted file mode 100644 index 8fdfb122..00000000 --- a/apps/client/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.yunqiinnovation.volcano_speech.utils - -import android.util.Log - -/** - * 文件日志记录工具 - */ -object FileLogger { - private const val TAG = "VolcanoSpeech" - private var isDebugEnabled = true - - /** - * 设置是否启用调试日志 - */ - fun setDebugEnabled(enabled: Boolean) { - isDebugEnabled = enabled - } - - /** - * 记录调试日志 - */ - fun d(tag: String, message: String) { - if (isDebugEnabled) { - Log.d("$TAG-$tag", message) - } - } - - /** - * 记录信息日志 - */ - fun i(tag: String, message: String) { - Log.i("$TAG-$tag", message) - } - - /** - * 记录警告日志 - */ - fun w(tag: String, message: String) { - Log.w("$TAG-$tag", message) - } - - /** - * 记录错误日志 - */ - fun e(tag: String, message: String) { - Log.e("$TAG-$tag", message) - } - - /** - * 记录错误日志,带异常 - */ - fun e(tag: String, message: String, throwable: Throwable) { - Log.e("$TAG-$tag", message, throwable) - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/volcano_speech/ios/Classes/.gitkeep b/apps/client/local_plugins/volcano_speech/ios/Classes/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/client/local_plugins/volcano_speech/ios/volcano_speech.podspec b/apps/client/local_plugins/volcano_speech/ios/volcano_speech.podspec deleted file mode 100644 index 42b24bd7..00000000 --- a/apps/client/local_plugins/volcano_speech/ios/volcano_speech.podspec +++ /dev/null @@ -1,23 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint volcano_speech.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'volcano_speech' - s.version = '0.0.1' - s.summary = 'Volcano Speech plugin' - s.description = <<-DESC -A Flutter plugin for Volcano Speech services. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.platform = :ios, '16.0' - - # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' -end \ No newline at end of file diff --git a/apps/client/local_plugins/volcano_speech/pubspec.yaml b/apps/client/local_plugins/volcano_speech/pubspec.yaml deleted file mode 100644 index cd90d8b1..00000000 --- a/apps/client/local_plugins/volcano_speech/pubspec.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: volcano_speech -description: 火山引擎语音合成服务插件 -version: 0.0.1 -homepage: - -environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=2.5.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# The following section is specific to Flutter packages. -flutter: - # This section identifies this Flutter project as a plugin project. - plugin: - platforms: - android: - package: com.yunqiinnovation.volcano_speech - pluginClass: VolcanoSpeechPlugin - ios: - pluginClass: VolcanoSpeechPlugin \ No newline at end of file diff --git a/apps/client/test.json b/apps/client/test.json deleted file mode 100644 index 50055737..00000000 --- a/apps/client/test.json +++ /dev/null @@ -1,35 +0,0 @@ -intent={cmp=com.yunqiinnovation.deepsound/com.yunqiinnovation.ble_service.BleForegroundService} -com.yunqiinnovation.deepsound/com.yunqiinnovation.ble_service.BleWakeReceiver filter f42f2c2 - -# 1. 启动前台 Service (API 26+ 推荐用 start-foreground-service) -adb shell am start-foreground-service \ - -n com.yunqiinnovation.deepsound/com.yunqiinnovation.ble_service.BleForegroundService - -# 2. 发送广播到 BleWakeReceiver -adb shell am broadcast \ - -a com.yunqiinnovation.ble_service.ACTION_BLE_SCAN \ - -n com.yunqiinnovation.deepsound/com.yunqiinnovation.ble_service.BleWakeReceiver - - - adb shell dumpsys package com.yunqiinnovation.deepsound | grep BleWakeReceiver - - adb shell dumpsys package com.yunqiinnovation.deepsound | grep receiver - - adb logcat -v time | grep -Ei "BleWakeReceiver|BleMediaService" - - - - - mvn install:install-file \ - -Dfile= - -DgroupId=com.jieli.audio \ - -DartifactId=jl_audio_decode \ - -Dversion=2.0.0 \ - -Dpackaging=aar - - mvn install:install-file \ - -Dfile=/Users/wolfplus/Developer/Doc/Jieli_OTA_SDK_Android_1.9.2/libs/jl_bt_ota_V1.9.2-release.aar \ - -DgroupId=com.jieli.jl_bt_ota \ - -DartifactId=jl_bt_ota \ - -Dversion=1.9.2 \ - -Dpackaging=aar \ No newline at end of file diff --git a/apps/client/test2.json b/apps/client/test2.json deleted file mode 100644 index 5c6e10e7..00000000 --- a/apps/client/test2.json +++ /dev/null @@ -1,101 +0,0 @@ -curl -v -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' -H 'Accept: text/event-stream' -d '{ - "model": "doubao-1-5-pro-32k-250115", - "messages": [ - [], - { - "role": "user", - "content": "你好,你能听到我在说话吗?" - } - ], - "temperature": 0.7, - "max_tokens": 2000, - "stream": true, - "tools": [ - { - "type": "function", - "function": { - "name": "exit_interaction", - "description": "退出当前语音交互", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } - }, - { - "type": "function", - "function": { - "name": "enter_translation_mode", - "description": "用户请求进入实时翻译模式时,启动实时翻译功能", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } - }, - { - "type": "function", - "function": { - "name": "send_text_message", - "description": "发送短信", - "parameters": { - "type": "object", - "properties": { - "contact": { - "type": "string", - "description": "联系人姓名或电话号码" - }, - "message": { - "type": "string", - "description": "短信内容" - } - }, - "required": [ - "contact", - "message" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "make_phone_call", - "description": "拨打电话", - "parameters": { - "type": "object", - "properties": { - "contact": { - "type": "string", - "description": "联系人姓名或电话号码" - } - }, - "required": [ - "contact" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "open_app", - "description": "当用户请求打开特定应用时,启动相应应用", - "parameters": { - "type": "object", - "properties": { - "app_name": { - "type": "string", - "description": "应用名称" - } - }, - "required": [ - "app_name" - ] - } - } - } - ] -}' 'https: //ark.cn-beijing.volces.com/api/v3/chat/completions' \ No newline at end of file diff --git a/apps/client/test_mobile_elf.py b/apps/client/test_mobile_elf.py deleted file mode 100644 index 77d4449c..00000000 --- a/apps/client/test_mobile_elf.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -import ssl, time, hashlib, json, websocket - -DEVICE_NO = '199839C0AAAE99653D2E' -DEVICE_ID = '2032013791868043266' -PRODUCT_ID = '2031271967792472065' -PRODUCT_KEY = 'hotepZZwvouWQjHz' -DEVICE_SECRET = 'ijdthZGQELhkmigf' - -ts = str(int(time.time() * 1000)) -sign = hashlib.md5((DEVICE_SECRET + ts).encode()).hexdigest() -url = (f'wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr' - f'?deviceNo={DEVICE_NO}&deviceId={DEVICE_ID}' - f'&productId={PRODUCT_ID}&productKey={PRODUCT_KEY}' - f'&sign={sign}&sn=py-test&ts={ts}') - -print(f'connecting...\n{url}') -ws = websocket.WebSocket(sslopt={'cert_reqs': ssl.CERT_NONE}) -ws.settimeout(15) -ws.connect(url) -print('connected! sending start frame immediately...') - -import uuid -start_frame = json.dumps({ - 'type': 'start', - 'data': { - 'cuid': DEVICE_ID, - 'format': 'pcm', - 'sample': 16000, - 'support_dcs': 2, - 'chunk_size': 10240, - 'support_tts': True, - 'support_text2dcs': True, - 'user_agent': 'voitrans/1.0', - 'dialog_request_id': str(uuid.uuid4()), - 'access_rc': True, - 'support_part_tts': True, - 'need_stoplisten': True, - 'need_dialogue_finish': True, - 'result_trans2directive': False, - 'rc_version': '5', - 'messages': [], - 'client_context': [], - } -}) -ws.send(start_frame) -print('start frame sent, waiting for ready...') - -# 等待 ready -while True: - msg = ws.recv() - print('received:', msg) - data = json.loads(msg) - if data.get('type') == 'ready': - print('--- ready received, sending text "你好" ---') - break - -# 发送文本 -text_frame = json.dumps({'type': 'text', 'data': {'text': '你好'}}) -ws.send(text_frame) -print('text frame sent') - -# 发送 finish -finish_frame = json.dumps({'type': 'finish'}) -ws.send(finish_frame) -print('finish frame sent, waiting for AI response...') - -# 接收所有回包直到 end=1 -while True: - msg = ws.recv() - print('received:', msg) - try: - data = json.loads(msg) - if data.get('end') == 1: - print('--- session end (end=1), closing ---') - break - except Exception: - pass - -ws.close() -print('done.') diff --git a/deploy/admin/prod-deploy.sh b/deploy/admin/prod-deploy.sh index 1094056a..9020afe0 100755 --- a/deploy/admin/prod-deploy.sh +++ b/deploy/admin/prod-deploy.sh @@ -60,7 +60,7 @@ env_profile() { ENV_DESC="阿龙生产环境 · 阿里云" registry_profile along || return 1 # 镜像仓库:registry.ymaikj.com(实体在阿龙测试机 8.133.166.29,账号 admin) DEPLOY_HOST="47.116.104.181" # ← 阿龙生产服务器 IP - DEPLOY_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误) + DEPLOY_KEY="${DEPLOY_KEY:-/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem}" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误) DEPLOY_DIR="/home/work/starpivot/admin" ;; *) red "✗ 未知环境: ${DEPLOY_ENV}(应为 ${PROD_ENVS[*]// /|})"; exit 1 ;; diff --git a/deploy/app/prod-deploy.sh b/deploy/app/prod-deploy.sh index 174b455c..e4adfb89 100755 --- a/deploy/app/prod-deploy.sh +++ b/deploy/app/prod-deploy.sh @@ -106,7 +106,7 @@ region_profile() { # 远端部署目录/容器名 DEPLOY_HOST="47.116.104.181" # ← 阿龙生产服务器 IP DEPLOY_PORT=22 DEPLOY_USER=root - DEPLOY_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误) + DEPLOY_KEY="${DEPLOY_KEY:-/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem}" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误) REGION_COMPANY="along" # 镜像从 registry.ymaikj.com 拉(实体在阿龙测试机 8.133.166.29,账号 admin) # 目录不在这里覆盖:一律走 APPS 表(ym → /home/work/ym-a11)。 # 早期这里曾写死 DEPLOY_DIR_OVERRIDE=/home/work/starpivot/app,导致部 ym 时落到该目录、 diff --git a/deploy/console/prod-deploy.sh b/deploy/console/prod-deploy.sh index e717c155..cc49ad3b 100755 --- a/deploy/console/prod-deploy.sh +++ b/deploy/console/prod-deploy.sh @@ -60,7 +60,7 @@ env_profile() { ENV_DESC="阿龙生产环境 · 阿里云" registry_profile along || return 1 # 镜像仓库:registry.ymaikj.com(实体在阿龙测试机 8.133.166.29,账号 admin) DEPLOY_HOST="47.116.104.181" # ← 阿龙生产服务器 IP - DEPLOY_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误) + DEPLOY_KEY="${DEPLOY_KEY:-/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem}" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误) DEPLOY_DIR="/home/work/starpivot/console" ;; *) red "✗ 未知环境: ${DEPLOY_ENV}(应为 ${PROD_ENVS[*]// /|})"; exit 1 ;;